fix(video): 预加载Video.js资源并优化视频播放诊断
- 在HomePage和StudyDetailPage添加Video.js资源预加载函数,解决首次播放黑屏问题 - 为VideoPlayer组件添加加载状态占位符,改善用户体验 - 增强视频播放错误处理和调试日志,便于问题排查 - 修复play()方法调用时的Promise处理,避免静默失败 - 优化跨域视频加载配置和网络超时设置
Showing
4 changed files
with
166 additions
and
41 deletions
| ... | @@ -15,6 +15,7 @@ | ... | @@ -15,6 +15,7 @@ |
| 15 | x5-video-player-type="h5" | 15 | x5-video-player-type="h5" |
| 16 | x5-video-player-fullscreen="true" | 16 | x5-video-player-fullscreen="true" |
| 17 | preload="metadata" | 17 | preload="metadata" |
| 18 | + crossorigin="anonymous" | ||
| 18 | @play="handleNativePlay" | 19 | @play="handleNativePlay" |
| 19 | @pause="handleNativePause" | 20 | @pause="handleNativePause" |
| 20 | /> | 21 | /> |
| ... | @@ -31,6 +32,12 @@ | ... | @@ -31,6 +32,12 @@ |
| 31 | @pause="handlePause" | 32 | @pause="handlePause" |
| 32 | /> | 33 | /> |
| 33 | 34 | ||
| 35 | + <!-- Loading 占位符:Video.js 资源加载时显示 --> | ||
| 36 | + <div v-if="!useNativePlayer && !state" class="loading-placeholder"> | ||
| 37 | + <div class="loading-spinner"></div> | ||
| 38 | + <div class="loading-text">视频加载中...</div> | ||
| 39 | + </div> | ||
| 40 | + | ||
| 34 | <!-- <div v-if="showNetworkSpeedOverlay && !showErrorOverlay" class="speed-overlay"> | 41 | <!-- <div v-if="showNetworkSpeedOverlay && !showErrorOverlay" class="speed-overlay"> |
| 35 | <div class="speed-content"> | 42 | <div class="speed-content"> |
| 36 | <div | 43 | <div |
| ... | @@ -45,9 +52,8 @@ | ... | @@ -45,9 +52,8 @@ |
| 45 | </div> | 52 | </div> |
| 46 | </div> --> | 53 | </div> --> |
| 47 | 54 | ||
| 48 | - <div v-if="hlsDownloadSpeedText" class="hls-speed-badge">{{ hlsDownloadSpeedText }}</div> | 55 | + <div v-if="hlsDownloadSpeedText && !props.debug" class="hls-speed-badge"> |
| 49 | - <div v-if="props.debug" class="hls-debug-badge"> | 56 | + {{ hlsDownloadSpeedText }} |
| 50 | - {{ hlsSpeedDebugText || "debug:empty" }} | ||
| 51 | </div> | 57 | </div> |
| 52 | 58 | ||
| 53 | <!-- 错误提示覆盖层 --> | 59 | <!-- 错误提示覆盖层 --> |
| ... | @@ -62,17 +68,17 @@ | ... | @@ -62,17 +68,17 @@ |
| 62 | </template> | 68 | </template> |
| 63 | 69 | ||
| 64 | <script setup> | 70 | <script setup> |
| 65 | -import { defineAsyncComponent, ref } from "vue"; | 71 | +import { defineAsyncComponent, ref, computed, watch } from 'vue' |
| 66 | -import { useVideoPlayer } from "@/composables/useVideoPlayer"; | 72 | +import { useVideoPlayer } from '@/composables/useVideoPlayer' |
| 67 | 73 | ||
| 68 | const VideoPlayer = defineAsyncComponent(async () => { | 74 | const VideoPlayer = defineAsyncComponent(async () => { |
| 69 | - await import("video.js/dist/video-js.css"); | 75 | + await import('video.js/dist/video-js.css') |
| 70 | - await import("videojs-hls-quality-selector/dist/videojs-hls-quality-selector.css"); | 76 | + await import('videojs-hls-quality-selector/dist/videojs-hls-quality-selector.css') |
| 71 | - await import("videojs-contrib-quality-levels"); | 77 | + await import('videojs-contrib-quality-levels') |
| 72 | - await import("videojs-hls-quality-selector"); | 78 | + await import('videojs-hls-quality-selector') |
| 73 | - const mod = await import("@videojs-player/vue"); | 79 | + const mod = await import('@videojs-player/vue') |
| 74 | - return mod.VideoPlayer; | 80 | + return mod.VideoPlayer |
| 75 | -}); | 81 | +}) |
| 76 | 82 | ||
| 77 | const props = defineProps({ | 83 | const props = defineProps({ |
| 78 | options: { | 84 | options: { |
| ... | @@ -95,7 +101,7 @@ const props = defineProps({ | ... | @@ -95,7 +101,7 @@ const props = defineProps({ |
| 95 | }, | 101 | }, |
| 96 | debug: { | 102 | debug: { |
| 97 | type: Boolean, | 103 | type: Boolean, |
| 98 | - default: false | 104 | + default: false, |
| 99 | }, | 105 | }, |
| 100 | /** | 106 | /** |
| 101 | * iOS 环境下是否强制使用原生播放器 | 107 | * iOS 环境下是否强制使用原生播放器 |
| ... | @@ -103,14 +109,14 @@ const props = defineProps({ | ... | @@ -103,14 +109,14 @@ const props = defineProps({ |
| 103 | */ | 109 | */ |
| 104 | useNativeOnIos: { | 110 | useNativeOnIos: { |
| 105 | type: Boolean, | 111 | type: Boolean, |
| 106 | - default: true | 112 | + default: true, |
| 107 | - } | 113 | + }, |
| 108 | -}); | 114 | +}) |
| 109 | 115 | ||
| 110 | -const emit = defineEmits(["onPlay", "onPause"]); | 116 | +const emit = defineEmits(['onPlay', 'onPause']) |
| 111 | 117 | ||
| 112 | -const videoRef = ref(null); | 118 | +const videoRef = ref(null) |
| 113 | -const nativeVideoRef = ref(null); | 119 | +const nativeVideoRef = ref(null) |
| 114 | 120 | ||
| 115 | const { | 121 | const { |
| 116 | player, | 122 | player, |
| ... | @@ -127,68 +133,137 @@ const { | ... | @@ -127,68 +133,137 @@ const { |
| 127 | hlsSpeedDebugText, | 133 | hlsSpeedDebugText, |
| 128 | retryLoad, | 134 | retryLoad, |
| 129 | handleVideoJsMounted, | 135 | handleVideoJsMounted, |
| 130 | - tryNativePlay | 136 | + tryNativePlay, |
| 131 | -} = useVideoPlayer(props, emit, videoRef, nativeVideoRef); | 137 | +} = useVideoPlayer(props, emit, videoRef, nativeVideoRef) |
| 138 | + | ||
| 139 | +// 视频加载诊断(控制台日志) | ||
| 140 | +const logVideoDiagnostics = () => { | ||
| 141 | + if (!props.debug) return | ||
| 142 | + | ||
| 143 | + const isSameOrigin = (() => { | ||
| 144 | + if (typeof window === 'undefined' || !props.videoUrl) return false | ||
| 145 | + try { | ||
| 146 | + const videoUrl = new URL(props.videoUrl, window.location.href) | ||
| 147 | + return videoUrl.origin === window.location.origin | ||
| 148 | + } catch { | ||
| 149 | + return false | ||
| 150 | + } | ||
| 151 | + })() | ||
| 152 | + | ||
| 153 | + // 检查是否为 HLS 视频 | ||
| 154 | + const checkIsM3U8 = url => { | ||
| 155 | + if (!url) return false | ||
| 156 | + return url.toLowerCase().includes('.m3u8') | ||
| 157 | + } | ||
| 158 | + | ||
| 159 | + console.group('🎬 [VideoPlayer] 视频加载诊断') | ||
| 160 | + console.log('📌 URL:', videoUrlValue.value) | ||
| 161 | + console.log('🔗 同源:', isSameOrigin) | ||
| 162 | + console.log('🎥 播放器类型:', useNativePlayer.value ? '原生播放器' : 'Video.js') | ||
| 163 | + console.log('📦 挂载状态:', state.value ? '已挂载' : '加载中') | ||
| 164 | + console.log('🌐 HLS支持:', checkIsM3U8(videoUrlValue.value) ? '是' : '否') | ||
| 165 | + console.groupEnd() | ||
| 166 | +} | ||
| 167 | + | ||
| 168 | +// 监听视频加载状态变化 | ||
| 169 | +watch( | ||
| 170 | + () => [state.value, showErrorOverlay.value, errorMessage.value], | ||
| 171 | + ([currentState, showError, errorMsg]) => { | ||
| 172 | + if (props.debug) { | ||
| 173 | + console.group('🎬 [VideoPlayer] 状态变化') | ||
| 174 | + console.log('📊 当前状态:', currentState ? '已挂载' : '加载中') | ||
| 175 | + console.log('❌ 错误状态:', showError ? errorMsg : '无') | ||
| 176 | + console.groupEnd() | ||
| 177 | + } | ||
| 178 | + | ||
| 179 | + if (showError && props.debug) { | ||
| 180 | + console.group('❌ [VideoPlayer] 错误诊断') | ||
| 181 | + console.error('错误信息:', errorMsg) | ||
| 182 | + console.log('视频URL:', videoUrlValue.value) | ||
| 183 | + console.log('播放器类型:', useNativePlayer.value ? '原生' : 'Video.js') | ||
| 184 | + console.log('排查建议:') | ||
| 185 | + console.log(' 1. 检查 CDN 服务器是否正常运行') | ||
| 186 | + console.log(' 2. 检查 CORS 跨域配置是否正确') | ||
| 187 | + console.log(' 3. 检查网络连接是否稳定') | ||
| 188 | + console.log(' 4. 检查视频文件格式是否支持') | ||
| 189 | + console.groupEnd() | ||
| 190 | + } | ||
| 191 | + | ||
| 192 | + // 每次状态变化都输出诊断信息 | ||
| 193 | + if (currentState) { | ||
| 194 | + logVideoDiagnostics() | ||
| 195 | + } | ||
| 196 | + }, | ||
| 197 | + { immediate: true } | ||
| 198 | +) | ||
| 132 | 199 | ||
| 133 | // 事件处理 | 200 | // 事件处理 |
| 134 | /** | 201 | /** |
| 135 | * @description 处理播放事件 | 202 | * @description 处理播放事件 |
| 136 | * @param {Event|Object} payload - 事件对象或数据 | 203 | * @param {Event|Object} payload - 事件对象或数据 |
| 137 | */ | 204 | */ |
| 138 | -const handlePlay = (payload) => emit("onPlay", payload); | 205 | +const handlePlay = payload => { |
| 206 | + if (props.debug) { | ||
| 207 | + console.log('🎬 [VideoPlayer] 视频开始播放', payload) | ||
| 208 | + } | ||
| 209 | + emit('onPlay', payload) | ||
| 210 | +} | ||
| 139 | 211 | ||
| 140 | /** | 212 | /** |
| 141 | * @description 处理暂停事件 | 213 | * @description 处理暂停事件 |
| 142 | * @param {Event|Object} payload - 事件对象或数据 | 214 | * @param {Event|Object} payload - 事件对象或数据 |
| 143 | */ | 215 | */ |
| 144 | -const handlePause = (payload) => emit("onPause", payload); | 216 | +const handlePause = payload => { |
| 217 | + if (props.debug) { | ||
| 218 | + console.log('⏸️ [VideoPlayer] 视频暂停', payload) | ||
| 219 | + } | ||
| 220 | + emit('onPause', payload) | ||
| 221 | +} | ||
| 145 | 222 | ||
| 146 | /** | 223 | /** |
| 147 | * @description 处理原生播放事件 | 224 | * @description 处理原生播放事件 |
| 148 | * @param {Event} event - 事件对象 | 225 | * @param {Event} event - 事件对象 |
| 149 | */ | 226 | */ |
| 150 | -const handleNativePlay = (event) => emit("onPlay", event); | 227 | +const handleNativePlay = event => emit('onPlay', event) |
| 151 | 228 | ||
| 152 | /** | 229 | /** |
| 153 | * @description 处理原生暂停事件 | 230 | * @description 处理原生暂停事件 |
| 154 | * @param {Event} event - 事件对象 | 231 | * @param {Event} event - 事件对象 |
| 155 | */ | 232 | */ |
| 156 | -const handleNativePause = (event) => emit("onPause", event); | 233 | +const handleNativePause = event => emit('onPause', event) |
| 157 | 234 | ||
| 158 | // 暴露方法给父组件 | 235 | // 暴露方法给父组件 |
| 159 | defineExpose({ | 236 | defineExpose({ |
| 160 | pause() { | 237 | pause() { |
| 161 | if (useNativePlayer.value) { | 238 | if (useNativePlayer.value) { |
| 162 | try { | 239 | try { |
| 163 | - nativeVideoRef.value?.pause?.(); | 240 | + nativeVideoRef.value?.pause?.() |
| 164 | - emit('onPause', nativeVideoRef.value); | 241 | + emit('onPause', nativeVideoRef.value) |
| 165 | - } catch (e) { | 242 | + } catch (e) {} |
| 166 | - } | 243 | + return |
| 167 | - return; | ||
| 168 | } | 244 | } |
| 169 | 245 | ||
| 170 | if (player.value && !player.value.isDisposed()) { | 246 | if (player.value && !player.value.isDisposed()) { |
| 171 | try { | 247 | try { |
| 172 | - player.value.pause(); | 248 | + player.value.pause() |
| 173 | - emit('onPause', player.value); | 249 | + emit('onPause', player.value) |
| 174 | - } catch (e) { | 250 | + } catch (e) {} |
| 175 | - } | ||
| 176 | } | 251 | } |
| 177 | }, | 252 | }, |
| 178 | play() { | 253 | play() { |
| 179 | if (useNativePlayer.value) { | 254 | if (useNativePlayer.value) { |
| 180 | - tryNativePlay(); | 255 | + tryNativePlay() |
| 181 | - return; | 256 | + return |
| 182 | } | 257 | } |
| 183 | - player.value?.play()?.catch(() => {}); | 258 | + player.value?.play()?.catch(() => {}) |
| 184 | }, | 259 | }, |
| 185 | getPlayer() { | 260 | getPlayer() { |
| 186 | - return useNativePlayer.value ? nativeVideoRef.value : player.value; | 261 | + return useNativePlayer.value ? nativeVideoRef.value : player.value |
| 187 | }, | 262 | }, |
| 188 | getId() { | 263 | getId() { |
| 189 | - return props.videoId || "meta_id"; | 264 | + return props.videoId || 'meta_id' |
| 190 | }, | 265 | }, |
| 191 | -}); | 266 | +}) |
| 192 | </script> | 267 | </script> |
| 193 | 268 | ||
| 194 | <style scoped> | 269 | <style scoped> |
| ... | @@ -207,7 +282,43 @@ defineExpose({ | ... | @@ -207,7 +282,43 @@ defineExpose({ |
| 207 | } | 282 | } |
| 208 | 283 | ||
| 209 | .video-player.loading { | 284 | .video-player.loading { |
| 210 | - opacity: 0.6; | 285 | + opacity: 0; |
| 286 | +} | ||
| 287 | + | ||
| 288 | +/* Loading 占位符样式 */ | ||
| 289 | +.loading-placeholder { | ||
| 290 | + position: absolute; | ||
| 291 | + top: 0; | ||
| 292 | + left: 0; | ||
| 293 | + right: 0; | ||
| 294 | + bottom: 0; | ||
| 295 | + background: #000; | ||
| 296 | + display: flex; | ||
| 297 | + flex-direction: column; | ||
| 298 | + align-items: center; | ||
| 299 | + justify-content: center; | ||
| 300 | + z-index: 1; | ||
| 301 | +} | ||
| 302 | + | ||
| 303 | +.loading-spinner { | ||
| 304 | + width: 40px; | ||
| 305 | + height: 40px; | ||
| 306 | + border: 3px solid rgba(255, 255, 255, 0.3); | ||
| 307 | + border-top-color: #4caf50; | ||
| 308 | + border-radius: 50%; | ||
| 309 | + animation: spin 1s linear infinite; | ||
| 310 | +} | ||
| 311 | + | ||
| 312 | +@keyframes spin { | ||
| 313 | + to { | ||
| 314 | + transform: rotate(360deg); | ||
| 315 | + } | ||
| 316 | +} | ||
| 317 | + | ||
| 318 | +.loading-text { | ||
| 319 | + margin-top: 12px; | ||
| 320 | + color: rgba(255, 255, 255, 0.8); | ||
| 321 | + font-size: 14px; | ||
| 211 | } | 322 | } |
| 212 | 323 | ||
| 213 | /* 错误覆盖层样式 */ | 324 | /* 错误覆盖层样式 */ | ... | ... |
This diff is collapsed. Click to expand it.
| ... | @@ -368,6 +368,7 @@ | ... | @@ -368,6 +368,7 @@ |
| 368 | </template> | 368 | </template> |
| 369 | <VideoPlayer | 369 | <VideoPlayer |
| 370 | v-else | 370 | v-else |
| 371 | + :key="`video_${index}_${item.video_url}`" | ||
| 371 | :video-url="item.video_url" | 372 | :video-url="item.video_url" |
| 372 | :video-id="`home_recommend_video_${index}`" | 373 | :video-id="`home_recommend_video_${index}`" |
| 373 | :use-native-on-ios="false" | 374 | :use-native-on-ios="false" |
| ... | @@ -394,6 +395,16 @@ import LiveStreamCard from '@/components/courses/LiveStreamCard.vue' | ... | @@ -394,6 +395,16 @@ import LiveStreamCard from '@/components/courses/LiveStreamCard.vue' |
| 394 | import VideoPlayer from '@/components/media/VideoPlayer.vue' | 395 | import VideoPlayer from '@/components/media/VideoPlayer.vue' |
| 395 | import CheckInList from '@/components/checkin/CheckInList.vue' | 396 | import CheckInList from '@/components/checkin/CheckInList.vue' |
| 396 | 397 | ||
| 398 | +// Video.js 资源预加载(解决首次播放黑屏问题) | ||
| 399 | +const preloadVideoJs = () => { | ||
| 400 | + // 在用户可能点击视频前,预先加载 Video.js 资源 | ||
| 401 | + import('video.js/dist/video-js.css') | ||
| 402 | + import('videojs-hls-quality-selector/dist/videojs-hls-quality-selector.css') | ||
| 403 | + import('videojs-contrib-quality-levels') | ||
| 404 | + import('videojs-hls-quality-selector') | ||
| 405 | + import('@videojs-player/vue') | ||
| 406 | +} | ||
| 407 | + | ||
| 397 | import FeaturedCoursesSection from '@/components/homePage/FeaturedCoursesSection.vue' | 408 | import FeaturedCoursesSection from '@/components/homePage/FeaturedCoursesSection.vue' |
| 398 | import RecommendationsSection from '@/components/homePage/RecommendationsSection.vue' | 409 | import RecommendationsSection from '@/components/homePage/RecommendationsSection.vue' |
| 399 | import LatestActivitiesSection from '@/components/homePage/LatestActivitiesSection.vue' | 410 | import LatestActivitiesSection from '@/components/homePage/LatestActivitiesSection.vue' |
| ... | @@ -431,6 +442,9 @@ const activeTab = ref('推荐') // 当前激活的内容标签页 | ... | @@ -431,6 +442,9 @@ const activeTab = ref('推荐') // 当前激活的内容标签页 |
| 431 | const checkInTypes = ref([]) | 442 | const checkInTypes = ref([]) |
| 432 | 443 | ||
| 433 | onMounted(() => { | 444 | onMounted(() => { |
| 445 | + // 预加载 Video.js 资源,避免首次播放时黑屏 | ||
| 446 | + preloadVideoJs() | ||
| 447 | + | ||
| 434 | watch( | 448 | watch( |
| 435 | () => currentUser.value, | 449 | () => currentUser.value, |
| 436 | async newVal => { | 450 | async newVal => { | ... | ... |
This diff is collapsed. Click to expand it.
-
Please register or login to post a comment