hookehuyr

feat(视频播放器): 重构视频播放器逻辑并添加hls.js支持

将视频播放器核心逻辑抽离为useVideoPlayer组合式函数,优化错误处理和重试机制
添加hls.js依赖以支持HLS视频格式播放
简化VideoPlayer组件代码,提高可维护性
...@@ -37,6 +37,7 @@ ...@@ -37,6 +37,7 @@
37 "@vue-office/pptx": "^1.0.1", 37 "@vue-office/pptx": "^1.0.1",
38 "browser-md5-file": "^1.1.1", 38 "browser-md5-file": "^1.1.1",
39 "dayjs": "^1.11.13", 39 "dayjs": "^1.11.13",
40 + "hls.js": "^1.6.15",
40 "html-to-image": "^1.11.13", 41 "html-to-image": "^1.11.13",
41 "html2canvas": "^1.4.1", 42 "html2canvas": "^1.4.1",
42 "lodash": "^4.17.21", 43 "lodash": "^4.17.21",
......
...@@ -50,6 +50,9 @@ importers: ...@@ -50,6 +50,9 @@ importers:
50 dayjs: 50 dayjs:
51 specifier: ^1.11.13 51 specifier: ^1.11.13
52 version: 1.11.19 52 version: 1.11.19
53 + hls.js:
54 + specifier: ^1.6.15
55 + version: 1.6.15
53 html-to-image: 56 html-to-image:
54 specifier: ^1.11.13 57 specifier: ^1.11.13
55 version: 1.11.13 58 version: 1.11.13
...@@ -1246,6 +1249,9 @@ packages: ...@@ -1246,6 +1249,9 @@ packages:
1246 resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} 1249 resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
1247 engines: {node: '>= 0.4'} 1250 engines: {node: '>= 0.4'}
1248 1251
1252 + hls.js@1.6.15:
1253 + resolution: {integrity: sha512-E3a5VwgXimGHwpRGV+WxRTKeSp2DW5DI5MWv34ulL3t5UNmyJWCQ1KmLEHbYzcfThfXG8amBL+fCYPneGHC4VA==}
1254 +
1249 html-to-image@1.11.13: 1255 html-to-image@1.11.13:
1250 resolution: {integrity: sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==} 1256 resolution: {integrity: sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==}
1251 1257
...@@ -3266,6 +3272,8 @@ snapshots: ...@@ -3266,6 +3272,8 @@ snapshots:
3266 dependencies: 3272 dependencies:
3267 function-bind: 1.1.2 3273 function-bind: 1.1.2
3268 3274
3275 + hls.js@1.6.15: {}
3276 +
3269 html-to-image@1.11.13: {} 3277 html-to-image@1.11.13: {}
3270 3278
3271 html2canvas@1.4.1: 3279 html2canvas@1.4.1:
......
1 <template> 1 <template>
2 <div class="video-player-container"> 2 <div class="video-player-container">
3 + <!-- 原生播放器 (iOS 微信等环境) -->
3 <video 4 <video
4 v-if="useNativePlayer" 5 v-if="useNativePlayer"
5 ref="nativeVideoRef" 6 ref="nativeVideoRef"
...@@ -17,16 +18,19 @@ ...@@ -17,16 +18,19 @@
17 @play="handleNativePlay" 18 @play="handleNativePlay"
18 @pause="handleNativePause" 19 @pause="handleNativePause"
19 /> 20 />
21 +
22 + <!-- Video.js 播放器 (PC/Android/其他环境) -->
20 <VideoPlayer 23 <VideoPlayer
21 v-else 24 v-else
22 ref="videoRef" 25 ref="videoRef"
23 :options="videoOptions" 26 :options="videoOptions"
24 playsinline 27 playsinline
25 :class="['video-player', 'vjs-big-play-centered', { loading: !state }]" 28 :class="['video-player', 'vjs-big-play-centered', { loading: !state }]"
26 - @mounted="handleMounted" 29 + @mounted="handleVideoJsMounted"
27 @play="handlePlay" 30 @play="handlePlay"
28 @pause="handlePause" 31 @pause="handlePause"
29 /> 32 />
33 +
30 <!-- 错误提示覆盖层 --> 34 <!-- 错误提示覆盖层 -->
31 <div v-if="showErrorOverlay" class="error-overlay"> 35 <div v-if="showErrorOverlay" class="error-overlay">
32 <div class="error-content"> 36 <div class="error-content">
...@@ -39,11 +43,10 @@ ...@@ -39,11 +43,10 @@
39 </template> 43 </template>
40 44
41 <script setup> 45 <script setup>
42 -import { ref, computed, onMounted, onBeforeUnmount, watch } from "vue"; 46 +import { ref } from "vue";
43 import { VideoPlayer } from "@videojs-player/vue"; 47 import { VideoPlayer } from "@videojs-player/vue";
44 -import videojs from "video.js";
45 import "video.js/dist/video-js.css"; 48 import "video.js/dist/video-js.css";
46 -import { wxInfo } from "@/utils/tools" 49 +import { useVideoPlayer } from "@/composables/useVideoPlayer";
47 50
48 const props = defineProps({ 51 const props = defineProps({
49 options: { 52 options: {
...@@ -67,449 +70,30 @@ const props = defineProps({ ...@@ -67,449 +70,30 @@ const props = defineProps({
67 }); 70 });
68 71
69 const emit = defineEmits(["onPlay", "onPause"]); 72 const emit = defineEmits(["onPlay", "onPause"]);
73 +
70 const videoRef = ref(null); 74 const videoRef = ref(null);
71 const nativeVideoRef = ref(null); 75 const nativeVideoRef = ref(null);
72 -const player = ref(null);
73 -const state = ref(null);
74 -const showErrorOverlay = ref(false);
75 -const errorMessage = ref('');
76 -const retryCount = ref(0);
77 -const maxRetries = 3;
78 -const nativeReady = ref(false);
79 -let nativeListeners = null;
80 -const probeInfo = ref({
81 - ok: null,
82 - status: null,
83 - content_type: "",
84 - content_length: null,
85 - accept_ranges: "",
86 -});
87 -const probeLoading = ref(false);
88 -
89 -const useNativePlayer = computed(() => {
90 - return wxInfo().isIOSWeChat;
91 -});
92 -
93 -const videoUrlValue = computed(() => {
94 - return (props.videoUrl || "").trim();
95 -});
96 -
97 -const formatBytes = (bytes) => {
98 - const size = Number(bytes) || 0;
99 - if (!size) return "";
100 - const kb = 1024;
101 - const mb = kb * 1024;
102 - const gb = mb * 1024;
103 - if (size >= gb) return (size / gb).toFixed(2) + "GB";
104 - if (size >= mb) return (size / mb).toFixed(2) + "MB";
105 - if (size >= kb) return (size / kb).toFixed(2) + "KB";
106 - return String(size) + "B";
107 -};
108 -
109 -const getErrorHint = () => {
110 - if (probeInfo.value.status === 403) return "(403:无权限或已过期)";
111 - if (probeInfo.value.status === 404) return "(404:资源不存在)";
112 - if (probeInfo.value.status && probeInfo.value.status >= 500) return `(${probeInfo.value.status}:服务器异常)`;
113 -
114 - const len = probeInfo.value.content_length;
115 - if (len && len >= 1024 * 1024 * 1024) {
116 - const text = formatBytes(len);
117 - return text ? `(文件约${text},建议 WiFi)` : "(文件较大,建议 WiFi)";
118 - }
119 - return "";
120 -};
121 -
122 -const probeVideo = async () => {
123 - const url = videoUrlValue.value;
124 - if (!url || typeof fetch === "undefined") return;
125 - if (probeLoading.value) return;
126 -
127 - probeLoading.value = true;
128 - const controller = typeof AbortController !== "undefined" ? new AbortController() : null;
129 - const timeoutId = setTimeout(() => controller?.abort?.(), 8000);
130 -
131 - const setBaseInfo = (res) => {
132 - const contentLength = res.headers.get("content-length");
133 - probeInfo.value = {
134 - ok: res.ok,
135 - status: res.status,
136 - content_type: res.headers.get("content-type") || "",
137 - content_length: contentLength ? Number(contentLength) || null : null,
138 - accept_ranges: res.headers.get("accept-ranges") || "",
139 - };
140 - };
141 -
142 - try {
143 - const headRes = await fetch(url, {
144 - method: "HEAD",
145 - mode: "cors",
146 - cache: "no-store",
147 - signal: controller?.signal,
148 - });
149 - setBaseInfo(headRes);
150 - if (headRes.ok && probeInfo.value.content_length) return;
151 - } catch (e) {
152 - probeInfo.value = {
153 - ok: null,
154 - status: null,
155 - content_type: "",
156 - content_length: null,
157 - accept_ranges: "",
158 - };
159 - } finally {
160 - clearTimeout(timeoutId);
161 - probeLoading.value = false;
162 - }
163 -
164 - const controller2 = typeof AbortController !== "undefined" ? new AbortController() : null;
165 - const timeoutId2 = setTimeout(() => controller2?.abort?.(), 8000);
166 - try {
167 - const rangeRes = await fetch(url, {
168 - method: "GET",
169 - mode: "cors",
170 - cache: "no-store",
171 - headers: { Range: "bytes=0-1" },
172 - signal: controller2?.signal,
173 - });
174 - const contentRange = rangeRes.headers.get("content-range") || "";
175 - const match = contentRange.match(/\/(\d+)\s*$/);
176 - const total = match ? Number(match[1]) || null : null;
177 - const contentLength = rangeRes.headers.get("content-length");
178 - probeInfo.value = {
179 - ok: rangeRes.ok,
180 - status: rangeRes.status,
181 - content_type: rangeRes.headers.get("content-type") || "",
182 - content_length: total || (contentLength ? Number(contentLength) || null : null),
183 - accept_ranges: rangeRes.headers.get("accept-ranges") || "",
184 - };
185 - } catch (e) {
186 - } finally {
187 - clearTimeout(timeoutId2);
188 - }
189 -};
190 -
191 -const getVideoMimeType = (url) => {
192 - const urlText = (url || "").toLowerCase();
193 - if (urlText.includes(".m3u8")) return "application/x-mpegURL";
194 - if (urlText.includes(".mp4")) return "video/mp4";
195 - if (urlText.includes(".mov")) return "video/quicktime";
196 - return "";
197 -};
198 -
199 -const videoSources = computed(() => {
200 - const type = getVideoMimeType(videoUrlValue.value);
201 - if (type) {
202 - return [{ src: videoUrlValue.value, type }];
203 - }
204 - return [{ src: videoUrlValue.value }];
205 -});
206 -
207 -const videoOptions = computed(() => ({
208 - controls: true,
209 - preload: "metadata", // 改为metadata以减少初始加载
210 - responsive: true,
211 - autoplay: props.autoplay,
212 - playsinline: true,
213 - // 启用倍速播放功能
214 - playbackRates: [0.5, 0.75, 1, 1.25, 1.5, 2],
215 - sources: videoSources.value,
216 - // HTML5配置优化
217 - html5: {
218 - vhs: {
219 - overrideNative: !videojs.browser.IS_SAFARI,
220 - },
221 - nativeVideoTracks: false,
222 - nativeAudioTracks: false,
223 - nativeTextTracks: false,
224 - },
225 - // 错误处理配置
226 - errorDisplay: true,
227 - // 网络和加载配置
228 - techOrder: ['html5'],
229 - // onPlay: () => emit("onPlay"),
230 - // onPause: () => emit("onPause"),
231 - userActions: {
232 - hotkeys: true,
233 - doubleClick: true,
234 - },
235 - controlBar: {
236 - progressControl: {
237 - seekBar: {
238 - mouseTimeDisplay: {
239 - keepTooltipsInside: true,
240 - },
241 - },
242 - },
243 - },
244 - ...props.options,
245 -}));
246 -
247 -const applyNativeError = (mediaError) => {
248 - if (!mediaError) return;
249 - showErrorOverlay.value = true;
250 - switch (mediaError.code) {
251 - case 4:
252 - errorMessage.value = '视频格式不支持或无法加载,请检查网络连接' + getErrorHint();
253 - if (retryCount.value < maxRetries) {
254 - setTimeout(() => {
255 - retryLoad();
256 - }, 1000);
257 - }
258 - break;
259 - case 3:
260 - errorMessage.value = '视频解码失败,可能是文件损坏';
261 - break;
262 - case 2:
263 - errorMessage.value = '网络连接错误,请检查网络后重试' + getErrorHint();
264 - if (retryCount.value < maxRetries) {
265 - setTimeout(() => {
266 - retryLoad();
267 - }, 2000);
268 - }
269 - break;
270 - case 1:
271 - errorMessage.value = '视频加载被中止';
272 - break;
273 - default:
274 - errorMessage.value = '视频播放出现未知错误';
275 - }
276 -};
277 -
278 -const handleMounted = (payload) => {
279 - console.log('VideoPlayer: handleMounted 被调用');
280 - console.log('VideoPlayer: payload.player:', payload.player);
281 - state.value = payload.state;
282 - player.value = payload.player;
283 - if (player.value) {
284 - // 添加错误处理监听器
285 - player.value.on('error', (error) => {
286 - console.error('VideoJS播放错误:', error);
287 - const errorCode = player.value.error();
288 - if (errorCode) {
289 - console.error('错误代码:', errorCode.code, '错误信息:', errorCode.message);
290 -
291 - // 显示用户友好的错误信息
292 - showErrorOverlay.value = true;
293 -
294 - // 根据错误类型进行处理
295 - switch (errorCode.code) {
296 - case 4: // MEDIA_ERR_SRC_NOT_SUPPORTED
297 - errorMessage.value = '视频格式不支持或无法加载,请检查网络连接' + getErrorHint();
298 - console.warn('视频格式不支持,尝试重新加载...');
299 - // 自动重试(如果重试次数未超限)
300 - if (retryCount.value < maxRetries) {
301 - setTimeout(() => {
302 - retryLoad();
303 - }, 1000);
304 - }
305 - break;
306 - case 3: // MEDIA_ERR_DECODE
307 - errorMessage.value = '视频解码失败,可能是文件损坏';
308 - console.warn('视频解码错误');
309 - break;
310 - case 2: // MEDIA_ERR_NETWORK
311 - errorMessage.value = '网络连接错误,请检查网络后重试' + getErrorHint();
312 - console.warn('网络错误,尝试重新加载...');
313 - if (retryCount.value < maxRetries) {
314 - setTimeout(() => {
315 - retryLoad();
316 - }, 2000);
317 - }
318 - break;
319 - case 1: // MEDIA_ERR_ABORTED
320 - errorMessage.value = '视频加载被中止';
321 - console.warn('视频加载被中止');
322 - break;
323 - default:
324 - errorMessage.value = '视频播放出现未知错误';
325 - }
326 - }
327 - });
328 -
329 - // 添加加载状态监听
330 - player.value.on('loadstart', () => {
331 - console.log('开始加载视频');
332 - showErrorOverlay.value = false; // 隐藏错误提示
333 - });
334 -
335 - player.value.on('canplay', () => {
336 - console.log('视频可以播放');
337 - showErrorOverlay.value = false; // 隐藏错误提示
338 - retryCount.value = 0; // 重置重试计数
339 - });
340 -
341 - player.value.on('loadedmetadata', () => {
342 - console.log('视频元数据加载完成');
343 - });
344 -
345 - // TAG: 自动播放
346 - if (props.autoplay) {
347 - player.value.play().catch(error => {
348 - console.warn('自动播放失败:', error);
349 - });
350 - }
351 -
352 - // if (!wxInfo().isPc && !wxInfo().isWeiXinDesktop) { // 非PC端,且非微信PC端
353 - // // 监听视频播放状态
354 - // player.value.on('play', () => {
355 - // // 播放时隐藏controls
356 - // // player.value.controlBar.hide();
357 - // });
358 - // player.value.on('pause', () => {
359 -
360 - // })
361 - // // 添加touchstart事件监听
362 - // player.value.on('touchstart', (event) => {
363 - // // 阻止事件冒泡,避免触发controls的默认行为
364 - // event.preventDefault();
365 - // event.stopPropagation();
366 -
367 - // // 检查点击位置是否在controls区域
368 - // const controlBar = player.value.getChild('ControlBar');
369 - // const controlBarEl = controlBar && controlBar.el();
370 - // if (controlBarEl && controlBarEl.contains(event.target)) {
371 - // return; // 如果点击在controls区域,不执行自定义行为
372 - // }
373 -
374 - // if (player.value.paused()) {
375 - // player.value.play();
376 - // } else {
377 - // player.value.pause();
378 - // }
379 - // });
380 - // }
381 - }
382 -};
383 -
384 -const handlePlay = (payload) => {
385 - emit("onPlay", payload)
386 -};
387 -const handlePause = (payload) => {
388 - emit("onPause", payload)
389 -}
390 -
391 -const handleNativePlay = (event) => {
392 - emit("onPlay", event)
393 -};
394 -
395 -const handleNativePause = (event) => {
396 - emit("onPause", event)
397 -};
398 -
399 -const tryNativePlay = () => {
400 - const videoEl = nativeVideoRef.value;
401 - if (!videoEl) return;
402 -
403 - const playPromise = videoEl.play();
404 - if (playPromise && typeof playPromise.catch === "function") {
405 - playPromise.catch(() => {
406 - if (typeof window !== "undefined" && window.WeixinJSBridge) {
407 - window.WeixinJSBridge.invoke("getNetworkType", {}, () => {
408 - videoEl.play().catch(() => { });
409 - });
410 - }
411 - });
412 - }
413 -};
414 -
415 -/**
416 - * 重试加载视频
417 - */
418 -const retryLoad = () => {
419 - if (retryCount.value >= maxRetries) {
420 - errorMessage.value = '重试次数已达上限,请稍后再试';
421 - return;
422 - }
423 -
424 - retryCount.value++;
425 - showErrorOverlay.value = false;
426 -
427 - if (useNativePlayer.value) {
428 - const videoEl = nativeVideoRef.value;
429 - if (videoEl) {
430 - console.log(`第${retryCount.value}次重试加载视频`);
431 - nativeReady.value = false;
432 - const currentSrc = videoEl.currentSrc || videoEl.src;
433 - videoEl.pause();
434 - videoEl.removeAttribute("src");
435 - videoEl.load();
436 - videoEl.src = currentSrc || videoUrlValue.value;
437 - videoEl.load();
438 - tryNativePlay();
439 - }
440 - return;
441 - }
442 -
443 - if (player.value && !player.value.isDisposed()) {
444 - console.log(`第${retryCount.value}次重试加载视频`);
445 - player.value.load();
446 - }
447 -};
448 -
449 -onMounted(() => {
450 - void probeVideo();
451 - if (!useNativePlayer.value) return;
452 -
453 - const videoEl = nativeVideoRef.value;
454 - if (!videoEl) return;
455 -
456 - const onLoadStart = () => {
457 - showErrorOverlay.value = false;
458 - nativeReady.value = false;
459 - };
460 -
461 - const onCanPlay = () => {
462 - showErrorOverlay.value = false;
463 - retryCount.value = 0;
464 - nativeReady.value = true;
465 - };
466 -
467 - const onError = () => {
468 - applyNativeError(videoEl.error);
469 - };
470 -
471 - videoEl.addEventListener("loadstart", onLoadStart);
472 - videoEl.addEventListener("canplay", onCanPlay);
473 - videoEl.addEventListener("error", onError);
474 - nativeListeners = { videoEl, onLoadStart, onCanPlay, onError };
475 -
476 - if (props.autoplay) {
477 - tryNativePlay();
478 - if (typeof document !== "undefined") {
479 - document.addEventListener(
480 - "WeixinJSBridgeReady",
481 - () => {
482 - tryNativePlay();
483 - },
484 - { once: true }
485 - );
486 - }
487 - }
488 -
489 -});
490 -
491 -onBeforeUnmount(() => {
492 - if (nativeListeners?.videoEl) {
493 - nativeListeners.videoEl.removeEventListener("loadstart", nativeListeners.onLoadStart);
494 - nativeListeners.videoEl.removeEventListener("canplay", nativeListeners.onCanPlay);
495 - nativeListeners.videoEl.removeEventListener("error", nativeListeners.onError);
496 - nativeListeners = null;
497 - }
498 -
499 - if (videoRef.value?.$player) {
500 - videoRef.value.$player.dispose();
501 - }
502 -});
503 -
504 -watch(
505 - () => videoUrlValue.value,
506 - () => {
507 - retryCount.value = 0;
508 - showErrorOverlay.value = false;
509 - void probeVideo();
510 - }
511 -);
512 76
77 +const {
78 + player,
79 + state,
80 + useNativePlayer,
81 + videoUrlValue,
82 + videoOptions,
83 + showErrorOverlay,
84 + errorMessage,
85 + retryLoad,
86 + handleVideoJsMounted,
87 + tryNativePlay
88 +} = useVideoPlayer(props, emit, videoRef, nativeVideoRef);
89 +
90 +// 事件处理
91 +const handlePlay = (payload) => emit("onPlay", payload);
92 +const handlePause = (payload) => emit("onPause", payload);
93 +const handleNativePlay = (event) => emit("onPlay", event);
94 +const handleNativePause = (event) => emit("onPause", event);
95 +
96 +// 暴露方法给父组件
513 defineExpose({ 97 defineExpose({
514 pause() { 98 pause() {
515 if (useNativePlayer.value) { 99 if (useNativePlayer.value) {
...@@ -522,7 +106,7 @@ defineExpose({ ...@@ -522,7 +106,7 @@ defineExpose({
522 return; 106 return;
523 } 107 }
524 108
525 - if (player.value && !player.value.isDisposed && typeof player.value.isDisposed === 'function' && !player.value.isDisposed() && typeof player.value.pause === 'function') { 109 + if (player.value && !player.value.isDisposed()) {
526 try { 110 try {
527 player.value.pause(); 111 player.value.pause();
528 emit('onPause', player.value); 112 emit('onPause', player.value);
...@@ -536,61 +120,7 @@ defineExpose({ ...@@ -536,61 +120,7 @@ defineExpose({
536 tryNativePlay(); 120 tryNativePlay();
537 return; 121 return;
538 } 122 }
539 - 123 + player.value?.play()?.catch(console.warn);
540 - console.log('VideoPlayer: play() 被调用');
541 - console.log('VideoPlayer: player.value:', player.value);
542 - console.log('VideoPlayer: player.value?.isDisposed:', player.value?.isDisposed);
543 -
544 - if (!player.value) {
545 - console.error('VideoPlayer: player.value 不存在,播放器可能还没初始化');
546 - return;
547 - }
548 -
549 - if (!player.value.isDisposed || typeof player.value.isDisposed !== 'function') {
550 - console.error('VideoPlayer: isDisposed 方法不存在');
551 - return;
552 - }
553 -
554 - if (player.value.isDisposed()) {
555 - console.error('VideoPlayer: 播放器已被销毁');
556 - return;
557 - }
558 -
559 - console.log('VideoPlayer: 尝试播放视频');
560 -
561 - // 检查视频元素状态
562 - try {
563 - const tech = player.value.tech(true);
564 - if (tech && tech.el) {
565 - const videoEl = tech.el();
566 - console.log('VideoPlayer: videoEl.readyState:', videoEl?.readyState, '(0=HAVE_NOTHING, 1=HAVE_METADATA, 2=HAVE_CURRENT_DATA, 3=HAVE_FUTURE_DATA, 4=HAVE_ENOUGH_DATA)');
567 - console.log('VideoPlayer: videoEl.paused:', videoEl?.paused);
568 - console.log('VideoPlayer: videoEl.duration:', videoEl?.duration);
569 - console.log('VideoPlayer: videoEl.src:', videoEl?.src);
570 - }
571 - } catch (e) {
572 - console.warn('VideoPlayer: 无法获取video元素:', e);
573 - }
574 -
575 - player.value.play()
576 - .then(() => {
577 - console.log('VideoPlayer: play() 成功');
578 - })
579 - .catch(error => {
580 - console.error('VideoPlayer: play() 失败:', error.name, error.message);
581 - // 如果是因为自动播放策略失败,可以静音重试
582 - if (error.name === 'NotAllowedError') {
583 - console.log('VideoPlayer: 浏览器阻止自动播放,尝试静音播放');
584 - player.value.muted(true);
585 - player.value.play()
586 - .then(() => {
587 - console.log('VideoPlayer: 静音播放成功');
588 - })
589 - .catch(err => {
590 - console.error('VideoPlayer: 静音播放也失败:', err);
591 - });
592 - }
593 - });
594 }, 124 },
595 getPlayer() { 125 getPlayer() {
596 return useNativePlayer.value ? nativeVideoRef.value : player.value; 126 return useNativePlayer.value ? nativeVideoRef.value : player.value;
......
1 +import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue';
2 +import { wxInfo } from "@/utils/tools";
3 +import Hls from 'hls.js';
4 +import videojs from "video.js";
5 +
6 +/**
7 + * 视频播放核心逻辑 Hook
8 + * 处理不同环境下的播放器选择、HLS支持、自动播放策略等
9 + */
10 +export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
11 + // 播放器实例
12 + const player = ref(null);
13 + const state = ref(null);
14 + const hlsInstance = ref(null);
15 +
16 + // 错误处理相关
17 + const showErrorOverlay = ref(false);
18 + const errorMessage = ref('');
19 + const retryCount = ref(0);
20 + const maxRetries = 3;
21 +
22 + // 原生播放器状态
23 + const nativeReady = ref(false);
24 + let nativeListeners = null;
25 +
26 + // 资源探测信息
27 + const probeInfo = ref({
28 + ok: null,
29 + status: null,
30 + content_type: "",
31 + content_length: null,
32 + accept_ranges: "",
33 + });
34 + const probeLoading = ref(false);
35 +
36 + // 1. 环境判断与播放器选择
37 + const useNativePlayer = computed(() => {
38 + // iOS 微信环境下强制使用原生播放器,因为 video.js 在此环境有兼容性问题
39 + return wxInfo().isIOSWeChat;
40 + });
41 +
42 + // 2. 视频源处理
43 + const videoUrlValue = computed(() => {
44 + return (props.videoUrl || "").trim();
45 + });
46 +
47 + const isM3U8 = computed(() => {
48 + const url = videoUrlValue.value.toLowerCase();
49 + return url.includes('.m3u8');
50 + });
51 +
52 + const getVideoMimeType = (url) => {
53 + const urlText = (url || "").toLowerCase();
54 + if (urlText.includes(".m3u8")) return "application/x-mpegURL";
55 + if (urlText.includes(".mp4")) return "video/mp4";
56 + if (urlText.includes(".mov")) return "video/quicktime";
57 + return "";
58 + };
59 +
60 + const videoSources = computed(() => {
61 + const type = getVideoMimeType(videoUrlValue.value);
62 + if (type) {
63 + return [{ src: videoUrlValue.value, type }];
64 + }
65 + return [{ src: videoUrlValue.value }];
66 + });
67 +
68 + // 3. 错误处理逻辑
69 + const formatBytes = (bytes) => {
70 + const size = Number(bytes) || 0;
71 + if (!size) return "";
72 + const kb = 1024;
73 + const mb = kb * 1024;
74 + const gb = mb * 1024;
75 + if (size >= gb) return (size / gb).toFixed(2) + "GB";
76 + if (size >= mb) return (size / mb).toFixed(2) + "MB";
77 + if (size >= kb) return (size / kb).toFixed(2) + "KB";
78 + return String(size) + "B";
79 + };
80 +
81 + const getErrorHint = () => {
82 + if (probeInfo.value.status === 403) return "(403:无权限或已过期)";
83 + if (probeInfo.value.status === 404) return "(404:资源不存在)";
84 + if (probeInfo.value.status && probeInfo.value.status >= 500) return `(${probeInfo.value.status}:服务器异常)`;
85 +
86 + const len = probeInfo.value.content_length;
87 + if (len && len >= 1024 * 1024 * 1024) {
88 + const text = formatBytes(len);
89 + return text ? `(文件约${text},建议 WiFi)` : "(文件较大,建议 WiFi)";
90 + }
91 + return "";
92 + };
93 +
94 + // 资源探测
95 + const probeVideo = async () => {
96 + const url = videoUrlValue.value;
97 + if (!url || typeof fetch === "undefined") return;
98 + if (probeLoading.value) return;
99 +
100 + probeLoading.value = true;
101 + const controller = typeof AbortController !== "undefined" ? new AbortController() : null;
102 + const timeoutId = setTimeout(() => controller?.abort?.(), 8000);
103 +
104 + try {
105 + const headRes = await fetch(url, {
106 + method: "HEAD",
107 + mode: "cors",
108 + cache: "no-store",
109 + signal: controller?.signal,
110 + });
111 +
112 + const contentLength = headRes.headers.get("content-length");
113 + probeInfo.value = {
114 + ok: headRes.ok,
115 + status: headRes.status,
116 + content_type: headRes.headers.get("content-type") || "",
117 + content_length: contentLength ? Number(contentLength) || null : null,
118 + accept_ranges: headRes.headers.get("accept-ranges") || "",
119 + };
120 +
121 + if (headRes.ok && probeInfo.value.content_length) return;
122 + } catch (e) {
123 + // 忽略 HEAD 请求失败
124 + } finally {
125 + clearTimeout(timeoutId);
126 + probeLoading.value = false;
127 + }
128 +
129 + // 如果 HEAD 失败,尝试 GET Range 0-1
130 + const controller2 = typeof AbortController !== "undefined" ? new AbortController() : null;
131 + const timeoutId2 = setTimeout(() => controller2?.abort?.(), 8000);
132 + try {
133 + const rangeRes = await fetch(url, {
134 + method: "GET",
135 + mode: "cors",
136 + cache: "no-store",
137 + headers: { Range: "bytes=0-1" },
138 + signal: controller2?.signal,
139 + });
140 + const contentRange = rangeRes.headers.get("content-range") || "";
141 + const match = contentRange.match(/\/(\d+)\s*$/);
142 + const total = match ? Number(match[1]) || null : null;
143 + const contentLength = rangeRes.headers.get("content-length");
144 +
145 + probeInfo.value = {
146 + ok: rangeRes.ok,
147 + status: rangeRes.status,
148 + content_type: rangeRes.headers.get("content-type") || "",
149 + content_length: total || (contentLength ? Number(contentLength) || null : null),
150 + accept_ranges: rangeRes.headers.get("accept-ranges") || "",
151 + };
152 + } catch (e) {
153 + // 忽略错误
154 + } finally {
155 + clearTimeout(timeoutId2);
156 + }
157 + };
158 +
159 + const handleError = (code, message = '') => {
160 + showErrorOverlay.value = true;
161 + switch (code) {
162 + case 4: // MEDIA_ERR_SRC_NOT_SUPPORTED
163 + errorMessage.value = '视频格式不支持或无法加载,请检查网络连接' + getErrorHint();
164 + if (retryCount.value < maxRetries) {
165 + setTimeout(retryLoad, 1000);
166 + }
167 + break;
168 + case 3: // MEDIA_ERR_DECODE
169 + errorMessage.value = '视频解码失败,可能是文件损坏';
170 + break;
171 + case 2: // MEDIA_ERR_NETWORK
172 + errorMessage.value = '网络连接错误,请检查网络后重试' + getErrorHint();
173 + if (retryCount.value < maxRetries) {
174 + setTimeout(retryLoad, 2000);
175 + }
176 + break;
177 + case 1: // MEDIA_ERR_ABORTED
178 + errorMessage.value = '视频加载被中止';
179 + break;
180 + default:
181 + errorMessage.value = message || '视频播放出现未知错误';
182 + }
183 + };
184 +
185 + // 4. 原生播放器逻辑 (iOS微信)
186 + const initNativePlayer = () => {
187 + const videoEl = nativeVideoRef.value;
188 + if (!videoEl) return;
189 +
190 + // HLS 处理
191 + if (isM3U8.value && Hls.isSupported()) {
192 + // 如果原生支持 HLS (iOS Safari),直接用 src 即可,不需要 hls.js
193 + // 但如果是安卓微信等不支持原生 HLS 的环境,才需要 hls.js
194 + // 由于 useNativePlayer 仅针对 iOS 微信,而 iOS 原生支持 HLS,所以这里直接赋值 src 即可
195 + // 无需额外操作
196 + }
197 +
198 + const onLoadStart = () => {
199 + showErrorOverlay.value = false;
200 + nativeReady.value = false;
201 + };
202 +
203 + const onCanPlay = () => {
204 + showErrorOverlay.value = false;
205 + retryCount.value = 0;
206 + nativeReady.value = true;
207 + };
208 +
209 + const onError = () => {
210 + handleError(videoEl.error?.code);
211 + };
212 +
213 + videoEl.addEventListener("loadstart", onLoadStart);
214 + videoEl.addEventListener("canplay", onCanPlay);
215 + videoEl.addEventListener("error", onError);
216 + nativeListeners = { videoEl, onLoadStart, onCanPlay, onError };
217 +
218 + if (props.autoplay) {
219 + tryNativePlay();
220 + if (typeof document !== "undefined") {
221 + document.addEventListener(
222 + "WeixinJSBridgeReady",
223 + () => tryNativePlay(),
224 + { once: true }
225 + );
226 + }
227 + }
228 + };
229 +
230 + const tryNativePlay = () => {
231 + const videoEl = nativeVideoRef.value;
232 + if (!videoEl) return;
233 +
234 + const playPromise = videoEl.play();
235 + if (playPromise && typeof playPromise.catch === "function") {
236 + playPromise.catch(() => {
237 + if (typeof window !== "undefined" && window.WeixinJSBridge) {
238 + window.WeixinJSBridge.invoke("getNetworkType", {}, () => {
239 + videoEl.play().catch(() => {});
240 + });
241 + }
242 + });
243 + }
244 + };
245 +
246 + // 5. Video.js 播放器逻辑 (PC/Android)
247 + const videoOptions = computed(() => ({
248 + controls: true,
249 + preload: "metadata",
250 + responsive: true,
251 + autoplay: props.autoplay,
252 + playsinline: true,
253 + playbackRates: [0.5, 0.75, 1, 1.25, 1.5, 2],
254 + sources: videoSources.value,
255 + html5: {
256 + vhs: {
257 + overrideNative: !videojs.browser.IS_SAFARI, // 非 Safari 下使用 VHS 解析 HLS
258 + },
259 + nativeVideoTracks: false,
260 + nativeAudioTracks: false,
261 + nativeTextTracks: false,
262 + hls: {
263 + withCredentials: false
264 + }
265 + },
266 + errorDisplay: true,
267 + techOrder: ['html5'],
268 + userActions: {
269 + hotkeys: true,
270 + doubleClick: true,
271 + },
272 + controlBar: {
273 + progressControl: {
274 + seekBar: {
275 + mouseTimeDisplay: {
276 + keepTooltipsInside: true,
277 + },
278 + },
279 + },
280 + },
281 + ...props.options,
282 + }));
283 +
284 + const handleVideoJsMounted = (payload) => {
285 + state.value = payload.state;
286 + player.value = payload.player;
287 +
288 + if (player.value) {
289 + player.value.on('error', () => {
290 + const err = player.value.error();
291 + handleError(err?.code, err?.message);
292 + });
293 +
294 + player.value.on('loadstart', () => {
295 + showErrorOverlay.value = false;
296 + });
297 +
298 + player.value.on('canplay', () => {
299 + showErrorOverlay.value = false;
300 + retryCount.value = 0;
301 + });
302 +
303 + if (props.autoplay) {
304 + player.value.play().catch(console.warn);
305 + }
306 + }
307 + };
308 +
309 + // 6. 重试逻辑
310 + const retryLoad = () => {
311 + if (retryCount.value >= maxRetries) {
312 + errorMessage.value = '重试次数已达上限,请稍后再试';
313 + return;
314 + }
315 +
316 + retryCount.value++;
317 + showErrorOverlay.value = false;
318 +
319 + if (useNativePlayer.value) {
320 + const videoEl = nativeVideoRef.value;
321 + if (videoEl) {
322 + nativeReady.value = false;
323 + const currentSrc = videoEl.currentSrc || videoEl.src;
324 + videoEl.pause();
325 + videoEl.removeAttribute("src");
326 + videoEl.load();
327 + videoEl.src = currentSrc || videoUrlValue.value;
328 + videoEl.load();
329 + tryNativePlay();
330 + }
331 + } else {
332 + if (player.value && !player.value.isDisposed()) {
333 + player.value.load();
334 + }
335 + }
336 + };
337 +
338 + // 7. 生命周期与监听
339 + watch(() => videoUrlValue.value, () => {
340 + retryCount.value = 0;
341 + showErrorOverlay.value = false;
342 + void probeVideo();
343 +
344 + // 如果是原生播放器且 URL 变化,需要手动处理 HLS (如果是非 iOS Safari 环境)
345 + if (useNativePlayer.value && isM3U8.value) {
346 + // iOS 原生支持,不需要额外操作
347 + // 如果未来支持 Android 原生播放器且不支持 HLS,需在此处初始化 hls.js
348 + }
349 + });
350 +
351 + onMounted(() => {
352 + void probeVideo();
353 + if (useNativePlayer.value) {
354 + initNativePlayer();
355 + }
356 + });
357 +
358 + onBeforeUnmount(() => {
359 + if (nativeListeners?.videoEl) {
360 + nativeListeners.videoEl.removeEventListener("loadstart", nativeListeners.onLoadStart);
361 + nativeListeners.videoEl.removeEventListener("canplay", nativeListeners.onCanPlay);
362 + nativeListeners.videoEl.removeEventListener("error", nativeListeners.onError);
363 + }
364 +
365 + if (hlsInstance.value) {
366 + hlsInstance.value.destroy();
367 + }
368 +
369 + if (videoRef.value?.$player) {
370 + videoRef.value.$player.dispose();
371 + }
372 + });
373 +
374 + return {
375 + player,
376 + state,
377 + useNativePlayer,
378 + videoUrlValue,
379 + videoOptions,
380 + showErrorOverlay,
381 + errorMessage,
382 + retryLoad,
383 + handleVideoJsMounted,
384 + tryNativePlay
385 + };
386 +}