hookehuyr

fix(video): 预加载Video.js资源并优化视频播放诊断

- 在HomePage和StudyDetailPage添加Video.js资源预加载函数,解决首次播放黑屏问题
- 为VideoPlayer组件添加加载状态占位符,改善用户体验
- 增强视频播放错误处理和调试日志,便于问题排查
- 修复play()方法调用时的Promise处理,避免静默失败
- 优化跨域视频加载配置和网络超时设置
...@@ -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.