feat(VideoPlayer): 添加iOS微信环境下的原生视频播放支持
为iOS微信环境添加原生video元素支持,优化视频播放体验 添加视频资源探测功能,提供更好的错误提示和重试机制 重构视频源处理逻辑,支持自动识别视频格式
Showing
1 changed file
with
297 additions
and
21 deletions
| 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"; | ... | ... |
-
Please register or login to post a comment