From 17022d498a96dcdef7084ad7e200c1687fc7cd6f Mon Sep 17 00:00:00 2001 From: chenghuan Date: Thu, 9 Apr 2026 16:48:57 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=9B=B4=E6=96=B0=E8=A7=86=E9=A2=91?= =?UTF-8?q?=E6=92=AD=E6=94=BE=E5=99=A8=E7=BB=84=E4=BB=B6=EF=BC=8C=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=E7=AB=A0=E8=8A=82=E8=A7=86=E9=A2=91=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=E5=B9=B6=E4=BC=98=E5=8C=96=E8=BF=94=E5=9B=9E=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/course/chapter-video.vue | 235 +++++ components/nav-bar/back-events.ts | 1 + components/nav-bar/nav-bar.vue | 25 +- components/nav-bar/use-safe-back.ts | 46 + .../video-player/components/ControlBar.vue | 162 --- .../video-player/components/ProgressBar.vue | 183 ---- .../video-player/components/SpeedControl.vue | 122 --- .../video-player/components/VolumeControl.vue | 112 --- .../video-player/composables/useVideoAPI.ts | 73 -- .../composables/useVideoPlayer.ts | 189 ---- .../composables/useVideoProgress.ts | 133 --- components/video-player/index.vue | 947 ++++++++---------- manifest.json | 11 +- pages/course/details/chapter.vue | 10 +- .../components/modules/video.vue | 25 +- .../components/yb-video/yb-video.vue | 98 +- .../static/html/css/yb-player.css | 25 +- .../static/html/dist/yb-player.js | 392 ++++++-- .../yingbing-video/static/html/video.html | 2 + 19 files changed, 1192 insertions(+), 1599 deletions(-) create mode 100644 components/course/chapter-video.vue create mode 100644 components/nav-bar/back-events.ts create mode 100644 components/nav-bar/use-safe-back.ts delete mode 100644 components/video-player/components/ControlBar.vue delete mode 100644 components/video-player/components/ProgressBar.vue delete mode 100644 components/video-player/components/SpeedControl.vue delete mode 100644 components/video-player/components/VolumeControl.vue delete mode 100644 components/video-player/composables/useVideoAPI.ts delete mode 100644 components/video-player/composables/useVideoPlayer.ts delete mode 100644 components/video-player/composables/useVideoProgress.ts diff --git a/components/course/chapter-video.vue b/components/course/chapter-video.vue new file mode 100644 index 0000000..6ff1a72 --- /dev/null +++ b/components/course/chapter-video.vue @@ -0,0 +1,235 @@ + + + diff --git a/components/nav-bar/back-events.ts b/components/nav-bar/back-events.ts new file mode 100644 index 0000000..3e8f2d7 --- /dev/null +++ b/components/nav-bar/back-events.ts @@ -0,0 +1 @@ +export const BEFORE_NAVIGATE_BACK_EVENT = '__edu_before_navigate_back__' diff --git a/components/nav-bar/nav-bar.vue b/components/nav-bar/nav-bar.vue index bb92bcc..5c2436c 100644 --- a/components/nav-bar/nav-bar.vue +++ b/components/nav-bar/nav-bar.vue @@ -15,8 +15,9 @@ diff --git a/components/nav-bar/use-safe-back.ts b/components/nav-bar/use-safe-back.ts new file mode 100644 index 0000000..8ba29c0 --- /dev/null +++ b/components/nav-bar/use-safe-back.ts @@ -0,0 +1,46 @@ +import { ref } from 'vue' +import { onBackPress } from '@dcloudio/uni-app' + +import { BEFORE_NAVIGATE_BACK_EVENT } from './back-events' + +/** + * 安全返回组合式函数。 + * 在页面 setup 中调用,自动注册 onBackPress 拦截。 + * 返回前先通过事件通知所有订阅组件(如 VideoPlayer)执行清理, + * 延迟后再执行 navigateBack,避免 renderjs 组件销毁竞态。 + * + * 用法: + * const { safeNavigateBack } = useSafeBack() + * // 配合 即可,无需额外代码 + */ +export function useSafeBack(options?: { delay?: number }) { + const leaving = ref(false) + const delay = options?.delay ?? 80 + + const safeNavigateBack = () => { + if (leaving.value) return + leaving.value = true + + uni.$emit(BEFORE_NAVIGATE_BACK_EVENT) + + setTimeout(() => { + uni.navigateBack() + }, delay) + } + + onBackPress((event: any) => { + if (event?.from === 'navigateBack') { + return false + } + if (leaving.value) { + return true + } + safeNavigateBack() + return true + }) + + return { + leaving, + safeNavigateBack, + } +} diff --git a/components/video-player/components/ControlBar.vue b/components/video-player/components/ControlBar.vue deleted file mode 100644 index 4982e25..0000000 --- a/components/video-player/components/ControlBar.vue +++ /dev/null @@ -1,162 +0,0 @@ - - - - - diff --git a/components/video-player/components/ProgressBar.vue b/components/video-player/components/ProgressBar.vue deleted file mode 100644 index 188346b..0000000 --- a/components/video-player/components/ProgressBar.vue +++ /dev/null @@ -1,183 +0,0 @@ - - - - - diff --git a/components/video-player/components/SpeedControl.vue b/components/video-player/components/SpeedControl.vue deleted file mode 100644 index f81b420..0000000 --- a/components/video-player/components/SpeedControl.vue +++ /dev/null @@ -1,122 +0,0 @@ - - - - - diff --git a/components/video-player/components/VolumeControl.vue b/components/video-player/components/VolumeControl.vue deleted file mode 100644 index fb00b1b..0000000 --- a/components/video-player/components/VolumeControl.vue +++ /dev/null @@ -1,112 +0,0 @@ - - - - - diff --git a/components/video-player/composables/useVideoAPI.ts b/components/video-player/composables/useVideoAPI.ts deleted file mode 100644 index dce309e..0000000 --- a/components/video-player/composables/useVideoAPI.ts +++ /dev/null @@ -1,73 +0,0 @@ -// 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' as VideoErrorType, - 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 deleted file mode 100644 index da09384..0000000 --- a/components/video-player/composables/useVideoPlayer.ts +++ /dev/null @@ -1,189 +0,0 @@ -// 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 deleted file mode 100644 index 352045a..0000000 --- a/components/video-player/composables/useVideoProgress.ts +++ /dev/null @@ -1,133 +0,0 @@ -// 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) - // 采用服务器记录的播放位置 - let position = serverPosition - - // 如果播放位置接近视频结尾(最后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 index 7e6f602..def6b89 100644 --- a/components/video-player/index.vue +++ b/components/video-player/index.vue @@ -1,523 +1,466 @@ - - diff --git a/manifest.json b/manifest.json index 3a61a0b..c04e465 100644 --- a/manifest.json +++ b/manifest.json @@ -3,15 +3,20 @@ "appid" : "__UNI__1250B39", "description" : "吴门国际", "sassImplementationName" : "node-sass", - "versionName" : "1.1.18", - "versionCode" : 1118, + "versionName" : "1.1.19", + "versionCode" : 1119, "transformPx" : false, /* 5+App特有相关 */ "app-plus" : { "usingComponents" : true, "nvueStyleCompiler" : "uni-app", "compilerVersion" : 3, - "screenOrientation" : [ "portrait-primary" ], + "screenOrientation" : [ + "portrait-primary", + "portrait-secondary", + "landscape-primary", + "landscape-secondary" + ], "compatible" : { "ignoreVersion" : true }, diff --git a/pages/course/details/chapter.vue b/pages/course/details/chapter.vue index 32dc105..5d3ffcc 100644 --- a/pages/course/details/chapter.vue +++ b/pages/course/details/chapter.vue @@ -6,11 +6,12 @@ - @@ -49,10 +50,15 @@ import { ref } from 'vue' import { onLoad } from '@dcloudio/uni-app' import { courseApi } from '@/api/modules/course' -import AliPlayer from '@/components/ali-video/index.vue' import type { IChapterDetail } from '@/types/course' import type { IVideoInfo } from '@/types/video' +import { mainClient } from '@/api/clients' + +import CourseVideo from '@/components/course/chapter-video.vue' +import { useSafeBack } from '@/components/nav-bar/use-safe-back' +useSafeBack() + // 页面参数 const chapterId = ref(0) const courseTitle = ref('') diff --git a/uni_modules/yingbing-video/components/modules/video.vue b/uni_modules/yingbing-video/components/modules/video.vue index 949e8c6..ab221a1 100644 --- a/uni_modules/yingbing-video/components/modules/video.vue +++ b/uni_modules/yingbing-video/components/modules/video.vue @@ -254,9 +254,21 @@ methods: { dataIdWatcher (newVal) { if ( newVal ) { - this.dom = document.querySelector('.rvideo' + newVal) - this.domSlot = document.querySelector('.rvideoslot' + newVal) - this.init() + // 延迟一下再查找 DOM 元素,确保 DOM 已经渲染完成 + setTimeout(() => { + this.dom = document.querySelector('.rvideo' + newVal) + this.domSlot = document.querySelector('.rvideoslot' + newVal) + // 检查 DOM 元素是否存在 + if ( this.dom && this.domSlot ) { + this.init() + } else { + console.error('yb-video: DOM 元素未找到,可能渲染失败', { + dataId: newVal, + dom: this.dom, + domSlot: this.domSlot + }) + } + }, 100) } }, async init () { @@ -348,8 +360,9 @@ parseSrc (path) { // #ifdef H5 const isHash = window.location.hash - const route = this.dom.getAttribute('data-route') - const pathName = isHash ? window.location.pathname : window.location.pathname.replace(route, '') + // 安全检查 this.dom 是否存在 + const route = this.dom ? this.dom.getAttribute('data-route') : '' + const pathName = isHash ? window.location.pathname : window.location.pathname.replace(route || '', '') return window.location.origin + pathName + path.substring(1) // #endif // #ifdef APP-VUE @@ -455,6 +468,7 @@ isLive: params.isLive, header: params.header, controls: params.controls, + alwaysShowControls: params.alwaysShowControls, height: '100%', objectFit: params.objectFit, crossOrigin: params.crossOrigin, @@ -465,6 +479,7 @@ workIndex: params.workIndex, subtitles: params.subtitles, subtitleIndex: params.subtitleIndex, + playbackRates: params.playbackRates, custom, decoder: { hls: { diff --git a/uni_modules/yingbing-video/components/yb-video/yb-video.vue b/uni_modules/yingbing-video/components/yb-video/yb-video.vue index 6366904..47ddf7d 100644 --- a/uni_modules/yingbing-video/components/yb-video/yb-video.vue +++ b/uni_modules/yingbing-video/components/yb-video/yb-video.vue @@ -105,6 +105,16 @@ type: Number, default: 1 }, + //倍速列表配置 + playbackRates: { + type: Array, + default: () => [] + }, + //是否显示倍速提示 + showRateTips: { + type: Boolean, + default: false + }, //循环播放 loop: { type: Boolean, @@ -219,8 +229,13 @@ return 'web' } } - } + }, // #endif + //是否一直显示控件 + alwaysShowControls: { + type: Boolean, + default: false + } }, computed: { boxStyle () { @@ -513,36 +528,38 @@ //重加载视频 async reloadVideo () { const arg = { - src: this.parseSrc(this.src), - segments: this.parseSegments(this.segments), - title: this.title, - poster: this.poster, - type: this.type, - three: this.three, - initialTime: this.initialTime, - duration: this.duration, - autoplay: this.autoplay, - preload: this.preload, - muted: this.muted, - loop: this.loop, - playbackRate: this.playbackRate, - isLive: this.isLive, - header: this.header, - controls: this.controls, - objectFit: this.objectFit, - crossOrigin: this.crossOrigin, - openDirection: this.openDirection, - exitDirection: this.exitDirection, - quality: this.parseList(this.quality), - works: this.works, - workIndex: this.workIndex, - subtitles: this.parseList(this.subtitles), - subtitleIndex: this.subtitleIndex, - custom: await this.parseCustom(), - flvConfig: this.flvConfig, - hlsConfig: this.hlsConfig, - jsmpegConfig: this.jsmpegConfig - } + src: this.parseSrc(this.src), + segments: this.parseSegments(this.segments), + title: this.title, + poster: this.poster, + type: this.type, + three: this.three, + initialTime: this.initialTime, + duration: this.duration, + autoplay: this.autoplay, + preload: this.preload, + muted: this.muted, + loop: this.loop, + playbackRate: this.playbackRate, + playbackRates: this.playbackRates, + isLive: this.isLive, + header: this.header, + controls: this.controls, + alwaysShowControls: this.alwaysShowControls, + objectFit: this.objectFit, + crossOrigin: this.crossOrigin, + openDirection: this.openDirection, + exitDirection: this.exitDirection, + quality: this.parseList(this.quality), + works: this.works, + workIndex: this.workIndex, + subtitles: this.parseList(this.subtitles), + subtitleIndex: this.subtitleIndex, + custom: await this.parseCustom(), + flvConfig: this.flvConfig, + hlsConfig: this.hlsConfig, + jsmpegConfig: this.jsmpegConfig + } this.evalJS('reloadVideo', arg) }, //卸载视频 @@ -608,7 +625,8 @@ this.updateTimer = setTimeout(() => { const arg = { header: this.header, - controls: this.controls + controls: this.controls, + alwaysShowControls: this.alwaysShowControls } this.evalJS('updateConfig', arg) }, 200) @@ -778,6 +796,12 @@ playbackRate (newVal) { this.setVideo('playbackRate', newVal) }, + //监听倍速列表 + playbackRates (newVal) { + if (newVal && newVal.length > 0) { + this.reloadVideo() + } + }, //监听循环属性 loop (newVal) { this.setVideo('loop', newVal) @@ -791,9 +815,13 @@ this.updateConfig() }, //监听controls - controls () { - this.updateConfig() - }, + controls () { + this.updateConfig() + }, + //监听alwaysShowControls + alwaysShowControls () { + this.updateConfig() + }, //深度监听custom custom: { handler(newVal, oldVal) { diff --git a/uni_modules/yingbing-video/static/html/css/yb-player.css b/uni_modules/yingbing-video/static/html/css/yb-player.css index 5577246..5b7f75b 100644 --- a/uni_modules/yingbing-video/static/html/css/yb-player.css +++ b/uni_modules/yingbing-video/static/html/css/yb-player.css @@ -53,6 +53,14 @@ pointer-events: none; } +.yb-player-time { + min-width: 38px; +} + +/* .yb-player-duration { + width: 35px; +} */ + .yb-player-bottom-progress { position: absolute; bottom: 0; @@ -120,22 +128,22 @@ width: 0; flex: 1; position: relative; - height: 2px; + height: 30px; margin: 0 10px; } .yb-player-range-track { position: absolute; - top: 0; + top: 14px; left: 0; right: 0; - bottom: 0; + bottom: 14px; background-color: #999; } .yb-player-range-focus, .yb-player-range-preload { position: absolute; - top: 0; + top: 14px; left: 0; - bottom: 0; + bottom: 14px; background-color: #fff; width: 0; } @@ -162,7 +170,7 @@ background: none; outline: none; width: 100%; - height: 2px; + height: 30px; margin: 0; position: absolute; top: 0; @@ -501,6 +509,11 @@ left: 50%; transform: translate(-50%, -50%); z-index: 1; + background-color: rgba(0, 0, 0, 0.3); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; } .yb-player-center svg { width: 50px; diff --git a/uni_modules/yingbing-video/static/html/dist/yb-player.js b/uni_modules/yingbing-video/static/html/dist/yb-player.js index 88fcede..13481dc 100644 --- a/uni_modules/yingbing-video/static/html/dist/yb-player.js +++ b/uni_modules/yingbing-video/static/html/dist/yb-player.js @@ -36,6 +36,7 @@ var YbPlayer = /*#__PURE__*/function () { isLive = _ref.isLive, header = _ref.header, controls = _ref.controls, + alwaysShowControls = _ref.alwaysShowControls, height = _ref.height, crossOrigin = _ref.crossOrigin, objectFit = _ref.objectFit, @@ -50,7 +51,8 @@ var YbPlayer = /*#__PURE__*/function () { works = _ref.works, workIndex = _ref.workIndex, subtitles = _ref.subtitles, - subtitleIndex = _ref.subtitleIndex; + subtitleIndex = _ref.subtitleIndex, + playbackRates = _ref.playbackRates; _classCallCheck(this, YbPlayer); this.container = typeof container == 'string' ? document.querySelector(container) : container; this.src = src; //播放链接 @@ -63,10 +65,36 @@ var YbPlayer = /*#__PURE__*/function () { this.loop = loop; //是否循环播放 this.muted = muted; //是否静音 this.playbackRate = playbackRate || 1; //默认播放倍速 + this.playbackRates = playbackRates && playbackRates.length > 0 ? playbackRates : [{ + text: '0.5倍速', + value: 0.5 + }, { + text: '0.75倍速', + value: 0.75 + }, { + text: '正常倍速', + value: 1 + }, { + text: '1.25倍速', + value: 1.25 + }, { + text: '1.5倍速', + value: 1.5 + }, { + text: '1.75倍速', + value: 1.75 + }, { + text: '2倍速', + value: 2 + }, { + text: '3倍速', + value: 3 + }]; //倍速列表配置 this.preload = preload; //是否预加载 this.isLive = isLive; //是否直播 this.header = header; //显示头部控制栏 this.controls = controls; //显示底部控制栏 + this.alwaysShowControls = alwaysShowControls; //是否一直显示控件 this.height = height || 'auto'; //视频高度 this.crossOrigin = crossOrigin; //跨域属性 anonymous-它有一个默认值。它定义了将在不传递凭据信息的情况下发送CORS请求 use-credentials-将发送带有凭据、cookie 和证书的 cross-origin 请求 this.objectFit = objectFit; //当视频宽高超出容器时的表现形式 fill-内容拉伸填充 contain-保持比例内容缩放 cover-保持比例内容可能被剪切 none-内容不重置 scale-down-保持比例从none或contain选一个 initial-默认值 @@ -97,6 +125,9 @@ var YbPlayer = /*#__PURE__*/function () { this._toastTimer = null; //消息隐藏定时器 this._danmuTimer = null; //弹幕定时器 this._seizingTimer = null; //卡死定时器(播放一些直播源的时候,可能会出现卡死无反应的情况,需要做出处理) + this._rateTimer = null; //倍速显示定时器 + this._errorRetryCount = 0; //错误重试次数 + this._maxErrorRetry = 0; //最大重试次数 this._event = {}; } //开启全屏按钮 @@ -111,7 +142,12 @@ var YbPlayer = /*#__PURE__*/function () { }, { key: "load", value: function load() { - this.container.innerHTML = "\n\t\t\t
\n\t\t\t\t
\n\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t
\n\t\t\t\t
\n\t\t\t\t\t
").concat(YbPlayer.lockIcon, "
\n\t\t\t\t\t
").concat(YbPlayer.lockIcon, "
\n\t\t\t\t
\n\t\t\t\t
\n\t\t\t\t
\n\t\t\t\t
\n\t\t\t
\n\t\t"); + var posterHtml = ''; + // 针对 iOS 平台添加备用封面显示 + if (this.poster && /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream) { + posterHtml = '
'; + } + this.container.innerHTML = posterHtml + "\n\t\t\t
\n\t\t\t\t
\n\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t
\n\t\t\t\t
\n\t\t\t\t\t
").concat(YbPlayer.lockIcon, "
\n\t\t\t\t\t
").concat(YbPlayer.lockIcon, "
\n\t\t\t\t
\n\t\t\t\t
\n\t\t\t\t
\n\t\t\t\t
\n\t\t\t
\n\t\t"); this._bindfullscreenerror = this._fullscreenerror.bind(this); this._bindfullscreenchanged = this._fullscreenchanged.bind(this); this.container.addEventListener('fullscreenerror', this._bindfullscreenerror); @@ -129,6 +165,7 @@ var YbPlayer = /*#__PURE__*/function () { this._clearDanmuTimer(); this._clearToastTimer(); this._clearControlsTimer(); + this._clearRateTimer(); this._removeBackbuttonListener(); this._event = {}; //卸载所有监听事件 if (this.container) { @@ -198,8 +235,8 @@ var YbPlayer = /*#__PURE__*/function () { _this2.emit('durationchange', _this2.getDuration()); }; this.video.onloadeddata = function () { - //非直播时初始化播放时长 - if (_this2.initialTime && !_this2.isLive) _this2.seek(_this2.initialTime); + //非直播时初始化播放时长(错误恢复时不执行) + if (_this2.initialTime && !_this2.isLive && !_this2._isErrorRecovering) _this2.seek(_this2.initialTime); _this2.emit('loadeddata', { duration: _this2.getDuration(), videoWidth: _this2.video.videoWidth, @@ -227,12 +264,18 @@ var YbPlayer = /*#__PURE__*/function () { if (_this2.cm) _this2.cm.setConfig('playbackRate', playbackRate); _this2.emit('ratechange', playbackRate); _this2.setInnerHTML('yb-player-header-rate', '倍速x' + playbackRate); + _this2._clearRateTimer(); var rateEl = _this2.container.getElementsByClassName('yb-player-rate')[0] || document.createElement('DIV'); if (![1, 1.0].includes(playbackRate)) { rateEl.setAttribute('class', 'yb-player-rate'); rateEl.innerHTML = "\n\t\t\t\t\t
\n\t\t\t\t\t\t\n\t\t\t\t\t
\n\t\t\t\t\t".concat(playbackRate + '倍速播放中', "\n\t\t\t\t"); var wrapperEl = _this2.container.getElementsByClassName('yb-player-wrapper')[0]; if (wrapperEl) wrapperEl.appendChild(rateEl); + _this2._rateTimer = window.setTimeout(function () { + if (rateEl && rateEl.parentNode) { + rateEl.parentNode.removeChild(rateEl); + } + }, 2000); } else { if (rateEl) rateEl.remove(); } @@ -325,7 +368,6 @@ var YbPlayer = /*#__PURE__*/function () { }; this.video.onerror = function (e) { if (e && e.target.error) { - // 网络问题或其他不可恢复的错误 var code = e.target.error.code; var errorMsg = ''; switch (code) { @@ -342,10 +384,26 @@ var YbPlayer = /*#__PURE__*/function () { errorMsg = '视频源不支持或地址无效'; break; } - _this2.showError(errorMsg); - _this2.unloadVideo(); + console.log('[video.onerror] 检测到视频错误,错误码:' + code + ', 错误信息:' + errorMsg); + + // 错误码 3 尝试跳过问题区间后继续播放 + if (code === 3) { + _this2._skipErrorAndRetry(errorMsg); + } else { + // 其他错误直接提示错误信息 + _this2.showError(errorMsg); + _this2.unloadVideo(); + } + + // emit 错误事件,传递详细的错误信息 + _this2.emit('error', { + code: code, + message: errorMsg, + error: e + }); + } else { + _this2.emit('error', e); } - _this2.emit('error', e); }; //视频长按菜单取消 this.video.oncontextmenu = function (e) { @@ -382,6 +440,7 @@ var YbPlayer = /*#__PURE__*/function () { key: "unloadVideo", value: function unloadVideo() { this._clearSeizingTimer(); + this._errorRetryCount = 0; // 重置错误计数 this.unloadCustom(); this.unloadDecoder(); this.unloadPano(); @@ -857,12 +916,13 @@ var YbPlayer = /*#__PURE__*/function () { wrapperEl.appendChild(toolbarEl); //先插入不显示的工具栏,因为需要计算工具栏尺寸 var element = this.container.querySelector(selector); //获取点击选择器 var rect = element.getBoundingClientRect(); //获取点击元素的尺寸布局信息 + var containerRect = this.container.getBoundingClientRect(); //获取容器相对于视口的位置 var boxWidth = this.container.offsetWidth; var boxHeight = this.container.offsetHeight; - var top = rect.top + rect.height; //计算顶部定位 - var left = rect.left; //计算左边定位 - var right = boxWidth - rect.right; //计算右边定位 - var bottom = boxHeight - rect.bottom + rect.height; //计算底部定位 + var top = rect.top - containerRect.top + rect.height; //计算顶部定位(相对于容器) + var left = rect.left - containerRect.left; //计算左边定位(相对于容器) + var right = containerRect.right - rect.right; //计算右边定位 + var bottom = containerRect.bottom - rect.bottom + rect.height; //计算底部定位 var isTop = true; //是否定位顶部 true-定位顶部 false-定位底部 var isLeft = true; //是否定位左边 true-定位左边 false-定位右边 if (top > boxHeight / 2) isTop = false; //判断点击元素是否在盒子上半部 @@ -1072,25 +1132,7 @@ var YbPlayer = /*#__PURE__*/function () { key: "showPlaybackRate", value: function showPlaybackRate() { var _this10 = this; - var arr = [{ - text: '0.5倍速', - value: 0.5 - }, { - text: '正常倍速', - value: 1 - }, { - text: '1.5倍速', - value: 1.5 - }, { - text: '1.75倍速', - value: 1.75 - }, { - text: '2倍速', - value: 2 - }, { - text: '3倍速', - value: 3 - }]; + var arr = this.playbackRates; var list = arr.map(function (a, k) { return { text: a.text, @@ -1419,7 +1461,7 @@ var YbPlayer = /*#__PURE__*/function () { if (['timeDiffrence', 'disableScroll', 'disableTop', 'disableBottom', 'fontScale'].includes(key)) _this.cm.reset(); } _this.emit('danmuconfigchange', config); //派发弹幕配置更改事件,以便开发者外部记录 - }, 500); + }, 2000); } this.showPopup('弹幕设置', div); } @@ -1653,8 +1695,13 @@ var YbPlayer = /*#__PURE__*/function () { }); var children = progressEl.children; for (var i = 0; i < children.length; i++) { - // children[i].onclick = arr[i].click - YbPlayer.tap(children[i], arr[i].click); + (function(index) { + var originalClick = arr[index].click; + YbPlayer.tap(children[index], function() { + if (originalClick) originalClick(); + _this17.showControls(); + }); + })(i); //如果标记了非全屏元素,则在全屏时需要隐藏 if (children[i].classList.contains('yb-player-unfull') && this.getFullscreen()) { children[i].classList.add('yb-player-hide'); @@ -1771,8 +1818,13 @@ var YbPlayer = /*#__PURE__*/function () { }); var children = controlsBottomEl.children; for (var _i = 0; _i < children.length; _i++) { - // children[i].onclick = arr[i].click - YbPlayer.tap(children[_i], arr[_i].click); + (function(index) { + var originalClick = arr[index].click; + YbPlayer.tap(children[index], function() { + if (originalClick) originalClick(); + _this17.showControls(); + }); + })(_i); } } @@ -1844,8 +1896,13 @@ var YbPlayer = /*#__PURE__*/function () { }); var children = heightEl.children; for (var _i2 = 0; _i2 < children.length; _i2++) { - // children[i].onclick = arr[i].click - YbPlayer.tap(children[_i2], arr[_i2].click); + (function(index) { + var originalClick = arr[index].click; + YbPlayer.tap(children[index], function() { + if (originalClick) originalClick(); + _this17.showControls(); + }); + })(_i2); } } @@ -1855,13 +1912,19 @@ var YbPlayer = /*#__PURE__*/function () { if (slots.length > 0 && wrapperEl) { var slotEl = document.createElement('DIV'); slotEl.setAttribute('class', 'yb-player-slot'); + var _this = this; slots.forEach(function (slot, key) { slotEl.innerHTML += slot.innerHTML; }); var children = slotEl.children; for (var _i3 = 0; _i3 < children.length; _i3++) { - // children[i].onclick = slots[i].click - YbPlayer.tap(children[_i3], slots[_i3].click); + (function(index) { + var originalClick = slots[index].click; + YbPlayer.tap(children[index], function() { + if (originalClick) originalClick(); + _this.showControls(); + }); + })(_i3); if (slots[_i3].followControls) { children[_i3].setAttribute('data-controls', 1); if (!this.getControls()) children[_i3].classList.add('yb-player-hide'); @@ -1871,6 +1934,10 @@ var YbPlayer = /*#__PURE__*/function () { } //重新设置一次全屏状态 this._setFullscreenStatus(); + // 如果设置了alwaysShowControls,自动显示控件 + if (this.alwaysShowControls) { + this.showControls(); + } } //卸载自定义 }, { @@ -2089,7 +2156,13 @@ var YbPlayer = /*#__PURE__*/function () { value: function setConfig(key, value) { var _this20 = this; Object.keys(this).forEach(function (k) { - if (k == key) _this20[k] = value; + if (k == key) { + _this20[k] = value; + //如果更新的是alwaysShowControls属性,需要重新显示控件 + if (k == 'alwaysShowControls' && value) { + _this20.showControls(); + } + } }); } //动态手势事件配置 @@ -2409,7 +2482,14 @@ var YbPlayer = /*#__PURE__*/function () { value: function hideLoading() { var div = this.container.getElementsByClassName('yb-player-loading')[0]; if (div) div.remove(); - if (this.video.paused) this.showCenterPlay();else this.hideCenter(); + // if (this.video.paused) this.showCenterPlay();else this.hideCenter(); + if (this._isErrorRecovering) { + this.hideCenter(); + } else if (this.video.paused) { + this.showCenterPlay(); + } else { + this.hideCenter(); + } } //展示中间播放按钮 }, { @@ -2519,6 +2599,152 @@ var YbPlayer = /*#__PURE__*/function () { value: function seek(time) { if (this.video) this.video.currentTime = time; } + /** + * 跳过错误区间并重试播放 + * @param {String} errorMsg 错误信息 + */ + }, { + key: "_skipErrorAndRetry", + value: function _skipErrorAndRetry(errorMsg) { + var _this = this; + + // 检查 video 对象是否存在 + if (!this.video) { + console.log('[错误恢复] video 对象不存在,无法恢复'); + this.showError(errorMsg); + this.unloadVideo(); + return; + } + + // 超过最大重试次数,显示错误并卸载视频 + if (this._errorRetryCount >= this._maxErrorRetry) { + this.showError(errorMsg); + this.unloadVideo(); + return; + } + + this._errorRetryCount++; + + // 第 1 次错误静默处理,显示 loading + if (this._errorRetryCount === 1) { + this.showLoading(); + } + + // 获取当前播放时间,向后跳过 1 秒(跳过损坏的 GOP 区间) + var currentTime = this.video.currentTime || 0; + // 确保 currentTime 是有效数字 + if (isNaN(currentTime) || currentTime < 0) { + currentTime = 0; + } + var duration = this.getDuration() || 0; + var skipTime = Math.min(currentTime + 1, duration); + + console.log('[错误恢复] 开始处理,当前时间:' + currentTime + 's, 跳转到:' + skipTime + 's'); + + // 关键:保存当前 src,然后重新加载视频来恢复状态 + // var currentSrc = this.video.currentSrc || this.video.src; + console.log('[错误恢复] 重新加载视频...'); + + // 保存当前倍速设置 + var currentPlaybackRate = this.video.playbackRate || 1; + console.log('[错误恢复] 保存倍速:' + currentPlaybackRate); + + // 设置标志,告诉 loadeddata 不要执行 seek + this._isErrorRecovering = true; + + // 重新设置 src 并加载 + // this.video.src = currentSrc; + this.video.load(); + + // 清除之前的定时器(如果有) + if (this._errorRecoveryTimer) { + clearTimeout(this._errorRecoveryTimer); + } + + // 等待元数据加载完成 + var onLoadedMetadata = function() { + // 检查 video 对象是否仍然存在 + if (!_this.video) { + console.log('[错误恢复] loadedmetadata 触发,但 video 对象已不存在'); + return; + } + + console.log('[错误恢复] loadedmetadata 触发,设置 currentTime = ' + skipTime); + _this.video.removeEventListener('loadedmetadata', onLoadedMetadata); + + // 清除超时定时器 + if (_this._errorRecoveryTimer) { + clearTimeout(_this._errorRecoveryTimer); + } + + // 恢复倍速设置 + _this.video.playbackRate = currentPlaybackRate; + console.log('[错误恢复] 恢复倍速:' + currentPlaybackRate); + + // 设置跳转位置 + _this.video.currentTime = skipTime; + + // 等待 seek 完成后立即播放 + var onSeeked = function() { + // 检查 video 对象是否仍然存在 + if (!_this2.video) { + console.log('[错误恢复] seeked 触发,但 video 对象已不存在'); + return; + } + + console.log('[错误恢复] seeked 触发,实际位置:' + _this2.video.currentTime); + _this2.video.removeEventListener('seeked', onSeeked); + _this2.tryPlayAfterSeek(errorMsg); + }; + + _this.video.addEventListener('seeked', onSeeked); + }; + + this.video.addEventListener('loadedmetadata', onLoadedMetadata); + + // loadedmetadata 超时处理 + this._errorRecoveryTimer = setTimeout(function() { + console.log('[错误恢复] loadedmetadata 超时,直接尝试播放'); + if (_this.video) { + _this.video.removeEventListener('loadedmetadata', onLoadedMetadata); + } + _this.tryPlayAfterSeek(errorMsg); + }, 5000); + } + + // 尝试播放 + }, { + key: "tryPlayAfterSeek", + value: function tryPlayAfterSeek(errorMsg) { + var _this2 = this; + + console.log('[错误恢复] 准备播放,当前状态:paused=' + this.video.paused + ', readyState=' + this.video.readyState + ', currentTime=' + this.video.currentTime); + + // 直接调用 play(),Promise 会处理缓冲 + var playPromise = this.video.play(); + console.log('[错误恢复] play() 已调用'); + + if (playPromise && typeof playPromise.then === 'function') { + playPromise.then(function() { + console.log('[错误恢复] 播放成功!'); + _this2._errorRetryCount = 0; + _this2._isErrorRecovering = false; // 清除错误恢复标志 + // 清除所有定时器 + if (_this2._errorRecoveryTimer) { + clearTimeout(_this2._errorRecoveryTimer); + _this2._errorRecoveryTimer = null; + } + _this2.hideLoading(); + }).catch(function(err) { + console.log('[错误恢复] 播放失败:', err); + _this2._skipErrorAndRetry(errorMsg); + }); + } else { + _this2._errorRetryCount = 0; + _this2._isErrorRecovering = false; + _this2.hideLoading(); + } + } //播放上一个视频 }, { key: "prev", @@ -2669,19 +2895,27 @@ var YbPlayer = /*#__PURE__*/function () { value: function exitFullscreen() { var _this26 = this; this.setDirection(false).then(function () { - var cfs = document.exitFullscreen || document.mozCancelFullScreen || document.msExitFullscreen || document.webkitExitFullscreen; - if (typeof cfs != 'undefined' && cfs) { - cfs.call(document); - } else if (typeof window.ActiveXObject !== "undefined") { - //IE浏览器,模拟按下F11键退出全屏 - var wscript = new ActiveXObject("WScript.Shell"); - if (wscript != null) { - wscript.SendKeys("{F11}"); - } - } else { - _this26.container.classList.remove('yb-player-openfull'); - _this26._fullscreenchanged(); - } + // #ifdef APP-PLUS + // 不使用浏览器的退出全屏了,全部通过css模拟退出全屏 + _this26.container.classList.remove('yb-player-openfull'); + _this26._fullscreenchanged(); + // #endif + + // #ifndef APP-PLUS + // var cfs = document.exitFullscreen || document.mozCancelFullScreen || document.msExitFullscreen || document.webkitExitFullscreen; + // if (typeof cfs != 'undefined' && cfs) { + // cfs.call(document); + // } else if (typeof window.ActiveXObject !== "undefined") { + // //IE浏览器,模拟按下F11键退出全屏 + // var wscript = new ActiveXObject("WScript.Shell"); + // if (wscript != null) { + // wscript.SendKeys("{F11}"); + // } + // } else { + // _this26.container.classList.remove('yb-player-openfull'); + // _this26._fullscreenchanged(); + // } + // #endif }); } //开启全屏 @@ -2691,18 +2925,25 @@ var YbPlayer = /*#__PURE__*/function () { var _this27 = this; this.openDirection = direction || this.openDirection; this.setDirection(true).then(function () { - var rfs = document.documentElement.requestFullscreen || document.documentElement.webkitRequestFullscreen || document.documentElement.mozRequestFullscreen || document.documentElement.requestFullScreen || document.documentElement.webkitRequestFullScreen || document.documentElement.mozRequestFullScreen; - if (typeof rfs != 'undefined' && rfs) { - rfs.call(_this27.container); - } else if (typeof window.ActiveXObject !== "undefined") { - //IE浏览器,模拟按下F11全屏 - var wscript = new ActiveXObject("WScript.Shell"); - if (wscript != null) { - wscript.SendKeys("{F11}"); - } - } else { - _this27._fullscreenerror(); - } + // #ifdef APP-PLUS + // 不使用浏览器的全屏了,全部通过css模拟全屏 + _this27._fullscreenerror(); + // #endif + + // #ifndef APP-PLUS + // var rfs = document.documentElement.requestFullscreen || document.documentElement.webkitRequestFullscreen || document.documentElement.mozRequestFullscreen || document.documentElement.requestFullScreen || document.documentElement.webkitRequestFullScreen || document.documentElement.mozRequestFullScreen; + // if (typeof rfs != 'undefined' && rfs) { + // rfs.call(_this27.container); + // } else if (typeof window.ActiveXObject !== "undefined") { + // //IE浏览器,模拟按下F11全屏 + // var wscript = new ActiveXObject("WScript.Shell"); + // if (wscript != null) { + // wscript.SendKeys("{F11}"); + // } + // } else { + // _this27._fullscreenerror(); + // } + // #endif }); } //是否支持全屏API @@ -2759,14 +3000,17 @@ var YbPlayer = /*#__PURE__*/function () { this.emit('controlschange', { show: true }); - this._controlsTimer = window.setTimeout(function () { - _this28.hideControls(); - }, 5000); + if (!this.alwaysShowControls) { + this._controlsTimer = window.setTimeout(function () { + _this28.hideControls(); + }, 5000); + } } //关闭控制栏 }, { key: "hideControls", value: function hideControls(item, timer) { + if (this.alwaysShowControls) return; this._clearControlsTimer(); this.container.getElementsByClassName('yb-player-controls')[0].classList.remove('yb-player-controls-show'); this.container.getElementsByClassName('yb-player-header')[0].classList.remove('yb-player-header-show'); @@ -2902,6 +3146,14 @@ var YbPlayer = /*#__PURE__*/function () { this._seizingTimer = null; } } + }, { + key: "_clearRateTimer", + value: function _clearRateTimer() { + if (this._rateTimer) { + window.clearTimeout(this._rateTimer); + this._rateTimer = null; + } + } }, { key: "_removeBackbuttonListener", value: function _removeBackbuttonListener() { diff --git a/uni_modules/yingbing-video/static/html/video.html b/uni_modules/yingbing-video/static/html/video.html index 5c22c7b..e27cd1d 100644 --- a/uni_modules/yingbing-video/static/html/video.html +++ b/uni_modules/yingbing-video/static/html/video.html @@ -69,6 +69,7 @@ isLive: params.isLive, header: params.header, controls: params.controls, + alwaysShowControls: params.alwaysShowControls, height: '100%', objectFit: params.objectFit, crossOrigin: params.crossOrigin, @@ -79,6 +80,7 @@ workIndex: params.workIndex, subtitles: params.subtitles, subtitleIndex: params.subtitleIndex, + playbackRates: params.playbackRates, custom: params.custom, decoder: { hls: {