hookehuyr

feat(VideoPlayer): 添加iOS微信环境下的原生视频播放支持

为iOS微信环境添加原生video元素支持,优化视频播放体验
添加视频资源探测功能,提供更好的错误提示和重试机制
重构视频源处理逻辑,支持自动识别视频格式
1 <template> 1 <template>
2 <div class="video-player-container"> 2 <div class="video-player-container">
3 + <video
4 + v-if="useNativePlayer"
5 + ref="nativeVideoRef"
6 + class="video-player"
7 + :src="videoUrlValue"
8 + :autoplay="props.autoplay"
9 + :muted="props.autoplay"
10 + controls
11 + playsinline
12 + webkit-playsinline="true"
13 + x5-playsinline="true"
14 + x5-video-player-type="h5"
15 + x5-video-player-fullscreen="true"
16 + preload="metadata"
17 + @play="handleNativePlay"
18 + @pause="handleNativePause"
19 + />
3 <VideoPlayer 20 <VideoPlayer
21 + v-else
4 ref="videoRef" 22 ref="videoRef"
5 :options="videoOptions" 23 :options="videoOptions"
6 - crossorigin="anonymous"
7 playsinline 24 playsinline
8 :class="['video-player', 'vjs-big-play-centered', { loading: !state }]" 25 :class="['video-player', 'vjs-big-play-centered', { loading: !state }]"
9 @mounted="handleMounted" 26 @mounted="handleMounted"
...@@ -22,7 +39,7 @@ ...@@ -22,7 +39,7 @@
22 </template> 39 </template>
23 40
24 <script setup> 41 <script setup>
25 -import { ref, computed, onMounted, onBeforeUnmount } from "vue"; 42 +import { ref, computed, onMounted, onBeforeUnmount, watch } from "vue";
26 import { VideoPlayer } from "@videojs-player/vue"; 43 import { VideoPlayer } from "@videojs-player/vue";
27 import videojs from "video.js"; 44 import videojs from "video.js";
28 import "video.js/dist/video-js.css"; 45 import "video.js/dist/video-js.css";
...@@ -51,36 +68,151 @@ const props = defineProps({ ...@@ -51,36 +68,151 @@ const props = defineProps({
51 68
52 const emit = defineEmits(["onPlay", "onPause"]); 69 const emit = defineEmits(["onPlay", "onPause"]);
53 const videoRef = ref(null); 70 const videoRef = ref(null);
71 +const nativeVideoRef = ref(null);
54 const player = ref(null); 72 const player = ref(null);
55 const state = ref(null); 73 const state = ref(null);
56 const showErrorOverlay = ref(false); 74 const showErrorOverlay = ref(false);
57 const errorMessage = ref(''); 75 const errorMessage = ref('');
58 const retryCount = ref(0); 76 const retryCount = ref(0);
59 const maxRetries = 3; 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 +});
60 206
61 const videoOptions = computed(() => ({ 207 const videoOptions = computed(() => ({
62 controls: true, 208 controls: true,
63 preload: "metadata", // 改为metadata以减少初始加载 209 preload: "metadata", // 改为metadata以减少初始加载
64 responsive: true, 210 responsive: true,
65 autoplay: props.autoplay, 211 autoplay: props.autoplay,
212 + playsinline: true,
66 // 启用倍速播放功能 213 // 启用倍速播放功能
67 playbackRates: [0.5, 0.75, 1, 1.25, 1.5, 2], 214 playbackRates: [0.5, 0.75, 1, 1.25, 1.5, 2],
68 - // 添加多种格式支持 215 + sources: videoSources.value,
69 - sources: [
70 - {
71 - src: props.videoUrl,
72 - type: "video/mp4",
73 - },
74 - // 备用源,如果主源失败则尝试其他格式
75 - {
76 - src: props.videoUrl,
77 - type: "video/webm",
78 - },
79 - {
80 - src: props.videoUrl,
81 - type: "video/ogg",
82 - },
83 - ],
84 // HTML5配置优化 216 // HTML5配置优化
85 html5: { 217 html5: {
86 vhs: { 218 vhs: {
...@@ -112,6 +244,37 @@ const videoOptions = computed(() => ({ ...@@ -112,6 +244,37 @@ const videoOptions = computed(() => ({
112 ...props.options, 244 ...props.options,
113 })); 245 }));
114 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 +
115 const handleMounted = (payload) => { 278 const handleMounted = (payload) => {
116 console.log('VideoPlayer: handleMounted 被调用'); 279 console.log('VideoPlayer: handleMounted 被调用');
117 console.log('VideoPlayer: payload.player:', payload.player); 280 console.log('VideoPlayer: payload.player:', payload.player);
...@@ -131,7 +294,7 @@ const handleMounted = (payload) => { ...@@ -131,7 +294,7 @@ const handleMounted = (payload) => {
131 // 根据错误类型进行处理 294 // 根据错误类型进行处理
132 switch (errorCode.code) { 295 switch (errorCode.code) {
133 case 4: // MEDIA_ERR_SRC_NOT_SUPPORTED 296 case 4: // MEDIA_ERR_SRC_NOT_SUPPORTED
134 - errorMessage.value = '视频格式不支持或无法加载,请检查网络连接'; 297 + errorMessage.value = '视频格式不支持或无法加载,请检查网络连接' + getErrorHint();
135 console.warn('视频格式不支持,尝试重新加载...'); 298 console.warn('视频格式不支持,尝试重新加载...');
136 // 自动重试(如果重试次数未超限) 299 // 自动重试(如果重试次数未超限)
137 if (retryCount.value < maxRetries) { 300 if (retryCount.value < maxRetries) {
...@@ -145,7 +308,7 @@ const handleMounted = (payload) => { ...@@ -145,7 +308,7 @@ const handleMounted = (payload) => {
145 console.warn('视频解码错误'); 308 console.warn('视频解码错误');
146 break; 309 break;
147 case 2: // MEDIA_ERR_NETWORK 310 case 2: // MEDIA_ERR_NETWORK
148 - errorMessage.value = '网络连接错误,请检查网络后重试'; 311 + errorMessage.value = '网络连接错误,请检查网络后重试' + getErrorHint();
149 console.warn('网络错误,尝试重新加载...'); 312 console.warn('网络错误,尝试重新加载...');
150 if (retryCount.value < maxRetries) { 313 if (retryCount.value < maxRetries) {
151 setTimeout(() => { 314 setTimeout(() => {
...@@ -225,6 +388,30 @@ const handlePause = (payload) => { ...@@ -225,6 +388,30 @@ const handlePause = (payload) => {
225 emit("onPause", payload) 388 emit("onPause", payload)
226 } 389 }
227 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 +
228 /** 415 /**
229 * 重试加载视频 416 * 重试加载视频
230 */ 417 */
...@@ -237,20 +424,104 @@ const retryLoad = () => { ...@@ -237,20 +424,104 @@ const retryLoad = () => {
237 retryCount.value++; 424 retryCount.value++;
238 showErrorOverlay.value = false; 425 showErrorOverlay.value = false;
239 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 +
240 if (player.value && !player.value.isDisposed()) { 443 if (player.value && !player.value.isDisposed()) {
241 console.log(`第${retryCount.value}次重试加载视频`); 444 console.log(`第${retryCount.value}次重试加载视频`);
242 player.value.load(); 445 player.value.load();
243 } 446 }
244 }; 447 };
245 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 +
246 onBeforeUnmount(() => { 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 +
247 if (videoRef.value?.$player) { 499 if (videoRef.value?.$player) {
248 videoRef.value.$player.dispose(); 500 videoRef.value.$player.dispose();
249 } 501 }
250 }); 502 });
251 503
504 +watch(
505 + () => videoUrlValue.value,
506 + () => {
507 + retryCount.value = 0;
508 + showErrorOverlay.value = false;
509 + void probeVideo();
510 + }
511 +);
512 +
252 defineExpose({ 513 defineExpose({
253 pause() { 514 pause() {
515 + if (useNativePlayer.value) {
516 + try {
517 + nativeVideoRef.value?.pause?.();
518 + emit('onPause', nativeVideoRef.value);
519 + } catch (e) {
520 + console.warn('Video pause error:', e);
521 + }
522 + return;
523 + }
524 +
254 if (player.value && !player.value.isDisposed && typeof player.value.isDisposed === 'function' && !player.value.isDisposed() && typeof player.value.pause === 'function') { 525 if (player.value && !player.value.isDisposed && typeof player.value.isDisposed === 'function' && !player.value.isDisposed() && typeof player.value.pause === 'function') {
255 try { 526 try {
256 player.value.pause(); 527 player.value.pause();
...@@ -261,6 +532,11 @@ defineExpose({ ...@@ -261,6 +532,11 @@ defineExpose({
261 } 532 }
262 }, 533 },
263 play() { 534 play() {
535 + if (useNativePlayer.value) {
536 + tryNativePlay();
537 + return;
538 + }
539 +
264 console.log('VideoPlayer: play() 被调用'); 540 console.log('VideoPlayer: play() 被调用');
265 console.log('VideoPlayer: player.value:', player.value); 541 console.log('VideoPlayer: player.value:', player.value);
266 console.log('VideoPlayer: player.value?.isDisposed:', player.value?.isDisposed); 542 console.log('VideoPlayer: player.value?.isDisposed:', player.value?.isDisposed);
...@@ -317,7 +593,7 @@ defineExpose({ ...@@ -317,7 +593,7 @@ defineExpose({
317 }); 593 });
318 }, 594 },
319 getPlayer() { 595 getPlayer() {
320 - return player.value; 596 + return useNativePlayer.value ? nativeVideoRef.value : player.value;
321 }, 597 },
322 getId() { 598 getId() {
323 return props.videoId || "meta_id"; 599 return props.videoId || "meta_id";
......