feat(视频播放器): 重构视频播放器逻辑并添加hls.js支持
将视频播放器核心逻辑抽离为useVideoPlayer组合式函数,优化错误处理和重试机制 添加hls.js依赖以支持HLS视频格式播放 简化VideoPlayer组件代码,提高可维护性
Showing
4 changed files
with
425 additions
and
500 deletions
| ... | @@ -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; | ... | ... |
src/composables/useVideoPlayer.js
0 → 100644
| 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 | +} |
-
Please register or login to post a comment