hookehuyr

fix(video): 扩展原生播放器到 Android 微信环境

- 修复 Android 微信用户无法观看视频的问题
- Video.js 在微信 X5 内核下存在兼容性问题
- 移动端微信环境统一使用原生 <video> 播放器
- 权衡:移动端微信用户失去清晰度切换功能

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 -import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'; 1 +import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
2 -import { wxInfo } from "@/utils/tools"; 2 +import { wxInfo } from '@/utils/tools'
3 -import { buildVideoSources, canPlayHlsNatively } from "./videoPlayerSource"; 3 +import { buildVideoSources, canPlayHlsNatively } from './videoPlayerSource'
4 -import { useVideoProbe } from "./useVideoProbe"; 4 +import { useVideoProbe } from './useVideoProbe'
5 -import { useVideoPlaybackOverlays } from "./useVideoPlaybackOverlays"; 5 +import { useVideoPlaybackOverlays } from './useVideoPlaybackOverlays'
6 6
7 const is_safari_browser = () => { 7 const is_safari_browser = () => {
8 - if (typeof navigator === 'undefined') return false; 8 + if (typeof navigator === 'undefined') return false
9 - const ua = navigator.userAgent || ''; 9 + const ua = navigator.userAgent || ''
10 - const is_safari = /safari/i.test(ua) && !/chrome|crios|android|fxios|edg/i.test(ua); 10 + const is_safari = /safari/i.test(ua) && !/chrome|crios|android|fxios|edg/i.test(ua)
11 - return is_safari; 11 + return is_safari
12 -}; 12 +}
13 13
14 /** 14 /**
15 * - 使用方法 :您无需修改业务代码。只要传入的视频 URL 是七牛云生成的多码率 .m3u8 地址,播放器控制条右下角会自动出现“齿轮”图标,用户点击即可切换清晰度(或选择 Auto 自动切换)。 15 * - 使用方法 :您无需修改业务代码。只要传入的视频 URL 是七牛云生成的多码率 .m3u8 地址,播放器控制条右下角会自动出现“齿轮”图标,用户点击即可切换清晰度(或选择 Auto 自动切换)。
...@@ -44,54 +44,56 @@ const is_safari_browser = () => { ...@@ -44,54 +44,56 @@ const is_safari_browser = () => {
44 */ 44 */
45 export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) { 45 export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
46 // 播放器实例 46 // 播放器实例
47 - const player = ref(null); 47 + const player = ref(null)
48 - const state = ref(null); 48 + const state = ref(null)
49 49
50 // 错误处理相关 50 // 错误处理相关
51 - const showErrorOverlay = ref(false); 51 + const showErrorOverlay = ref(false)
52 - const errorMessage = ref(''); 52 + const errorMessage = ref('')
53 - const retryCount = ref(0); 53 + const retryCount = ref(0)
54 - const maxRetries = 3; 54 + const maxRetries = 3
55 - const canRetry = computed(() => retryCount.value < maxRetries); 55 + const canRetry = computed(() => retryCount.value < maxRetries)
56 56
57 - const hasEverPlayed = ref(false); 57 + const hasEverPlayed = ref(false)
58 - const hasStartedPlayback = ref(false); 58 + const hasStartedPlayback = ref(false)
59 59
60 // 原生播放器状态 60 // 原生播放器状态
61 - const nativeReady = ref(false); 61 + const nativeReady = ref(false)
62 - let nativeListeners = null; 62 + let nativeListeners = null
63 - let retry_error_check_timer = null; 63 + let retry_error_check_timer = null
64 64
65 // 1. 环境判断与播放器选择 65 // 1. 环境判断与播放器选择
66 const useNativePlayer = computed(() => { 66 const useNativePlayer = computed(() => {
67 // 如果 props 强制关闭原生播放器,则返回 false (使用 Video.js) 67 // 如果 props 强制关闭原生播放器,则返回 false (使用 Video.js)
68 if (props.useNativeOnIos === false) { 68 if (props.useNativeOnIos === false) {
69 - return false; 69 + return false
70 } 70 }
71 - // 默认逻辑:iOS 微信环境下使用原生播放器 71 + // 扩展逻辑:iOS 微信 + Android 微信都使用原生播放器
72 - return wxInfo().isIOSWeChat; 72 + // 理由:微信 X5 内核对原生 <video> 支持最好,避开 Video.js 在 X5 下的兼容性问题
73 - }); 73 + const info = wxInfo()
74 + return (info.isIOS && info.isWeiXin) || (info.isAndroid && info.isWeiXin)
75 + })
74 76
75 // 2. 视频源处理 77 // 2. 视频源处理
76 - const videoUrlValue = computed(() => { 78 + const videoUrlValue = computed(() => (props.videoUrl || '').trim())
77 - return (props.videoUrl || "").trim();
78 - });
79 79
80 // 3. HLS 支持判断 80 // 3. HLS 支持判断
81 const isM3U8 = computed(() => { 81 const isM3U8 = computed(() => {
82 - const url = videoUrlValue.value.toLowerCase(); 82 + const url = videoUrlValue.value.toLowerCase()
83 - return url.includes('.m3u8'); 83 + return url.includes('.m3u8')
84 - }); 84 + })
85 85
86 // 资源探测:只在“同源可探测”时执行,避免跨域 CORS 报错影响体验 86 // 资源探测:只在“同源可探测”时执行,避免跨域 CORS 报错影响体验
87 - const { probeInfo, probeVideo } = useVideoProbe(videoUrlValue); 87 + const { probeInfo, probeVideo } = useVideoProbe(videoUrlValue)
88 88
89 // 视频源构造:尽可能带上 type,老设备/部分内核对 blob/部分后缀会更稳定 89 // 视频源构造:尽可能带上 type,老设备/部分内核对 blob/部分后缀会更稳定
90 - const videoSources = computed(() => buildVideoSources({ 90 + const videoSources = computed(() =>
91 - url: videoUrlValue.value, 91 + buildVideoSources({
92 - video_id: props?.videoId, 92 + url: videoUrlValue.value,
93 - probe_content_type: probeInfo.value.content_type, 93 + video_id: props?.videoId,
94 - })); 94 + probe_content_type: probeInfo.value.content_type,
95 + })
96 + )
95 97
96 // 播放叠层:弱网提示 + HLS 速度展示(仅 video.js + m3u8) 98 // 播放叠层:弱网提示 + HLS 速度展示(仅 video.js + m3u8)
97 const { 99 const {
...@@ -112,122 +114,123 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) { ...@@ -112,122 +114,123 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
112 use_native_player: useNativePlayer, 114 use_native_player: useNativePlayer,
113 show_error_overlay: showErrorOverlay, 115 show_error_overlay: showErrorOverlay,
114 has_started_playback: hasStartedPlayback, 116 has_started_playback: hasStartedPlayback,
115 - }); 117 + })
116 118
117 // 6. 错误处理逻辑 119 // 6. 错误处理逻辑
118 - const formatBytes = (bytes) => { 120 + const formatBytes = bytes => {
119 - const size = Number(bytes) || 0; 121 + const size = Number(bytes) || 0
120 - if (!size) return ""; 122 + if (!size) return ''
121 - const kb = 1024; 123 + const kb = 1024
122 - const mb = kb * 1024; 124 + const mb = kb * 1024
123 - const gb = mb * 1024; 125 + const gb = mb * 1024
124 - if (size >= gb) return (size / gb).toFixed(2) + "GB"; 126 + if (size >= gb) return `${(size / gb).toFixed(2)}GB`
125 - if (size >= mb) return (size / mb).toFixed(2) + "MB"; 127 + if (size >= mb) return `${(size / mb).toFixed(2)}MB`
126 - if (size >= kb) return (size / kb).toFixed(2) + "KB"; 128 + if (size >= kb) return `${(size / kb).toFixed(2)}KB`
127 - return String(size) + "B"; 129 + return `${String(size)}B`
128 - }; 130 + }
129 131
130 const getErrorHint = () => { 132 const getErrorHint = () => {
131 - if (probeInfo.value.status === 403) return "(403:无权限或已过期)"; 133 + if (probeInfo.value.status === 403) return '(403:无权限或已过期)'
132 - if (probeInfo.value.status === 404) return "(404:资源不存在)"; 134 + if (probeInfo.value.status === 404) return '(404:资源不存在)'
133 - if (probeInfo.value.status && probeInfo.value.status >= 500) return `(${probeInfo.value.status}:服务器异常)`; 135 + if (probeInfo.value.status && probeInfo.value.status >= 500)
136 + return `(${probeInfo.value.status}:服务器异常)`
134 137
135 - const len = probeInfo.value.content_length; 138 + const len = probeInfo.value.content_length
136 if (len && len >= 1024 * 1024 * 1024) { 139 if (len && len >= 1024 * 1024 * 1024) {
137 - const text = formatBytes(len); 140 + const text = formatBytes(len)
138 - return text ? `(文件约${text},建议 WiFi)` : "(文件较大,建议 WiFi)"; 141 + return text ? `(文件约${text},建议 WiFi)` : '(文件较大,建议 WiFi)'
139 } 142 }
140 - return ""; 143 + return ''
141 - }; 144 + }
142 145
143 // 7. 错误处理逻辑 146 // 7. 错误处理逻辑
144 const handleError = (code, message = '') => { 147 const handleError = (code, message = '') => {
145 - showErrorOverlay.value = true; 148 + showErrorOverlay.value = true
146 - hideNetworkSpeed(); 149 + hideNetworkSpeed()
147 switch (code) { 150 switch (code) {
148 case 4: // MEDIA_ERR_SRC_NOT_SUPPORTED 151 case 4: // MEDIA_ERR_SRC_NOT_SUPPORTED
149 - errorMessage.value = '视频格式不支持或无法加载,请检查网络连接' + getErrorHint(); 152 + errorMessage.value = `视频格式不支持或无法加载,请检查网络连接${getErrorHint()}`
150 // 旧机型/弱网下可能出现短暂的“无法加载”,这里做有限次数重试 153 // 旧机型/弱网下可能出现短暂的“无法加载”,这里做有限次数重试
151 // if (retryCount.value < maxRetries) { 154 // if (retryCount.value < maxRetries) {
152 // setTimeout(retryLoad, 1000); 155 // setTimeout(retryLoad, 1000);
153 // } 156 // }
154 - break; 157 + break
155 case 3: // MEDIA_ERR_DECODE 158 case 3: // MEDIA_ERR_DECODE
156 - errorMessage.value = '视频解码失败,可能是文件损坏'; 159 + errorMessage.value = '视频解码失败,可能是文件损坏'
157 - break; 160 + break
158 case 2: // MEDIA_ERR_NETWORK 161 case 2: // MEDIA_ERR_NETWORK
159 - errorMessage.value = '网络连接错误,请检查网络后重试' + getErrorHint(); 162 + errorMessage.value = `网络连接错误,请检查网络后重试${getErrorHint()}`
160 if (retryCount.value < maxRetries) { 163 if (retryCount.value < maxRetries) {
161 - setTimeout(retryLoad, 2000); 164 + setTimeout(retryLoad, 2000)
162 } 165 }
163 - break; 166 + break
164 case 1: // MEDIA_ERR_ABORTED 167 case 1: // MEDIA_ERR_ABORTED
165 - errorMessage.value = '视频加载被中止'; 168 + errorMessage.value = '视频加载被中止'
166 - break; 169 + break
167 default: 170 default:
168 - errorMessage.value = message || '视频播放出现未知错误'; 171 + errorMessage.value = message || '视频播放出现未知错误'
169 } 172 }
170 - }; 173 + }
171 174
172 // 4. 原生播放器逻辑 (iOS微信) 175 // 4. 原生播放器逻辑 (iOS微信)
173 const initNativePlayer = () => { 176 const initNativePlayer = () => {
174 - const videoEl = nativeVideoRef.value; 177 + const videoEl = nativeVideoRef.value
175 - if (!videoEl) return; 178 + if (!videoEl) return
176 179
177 - setHlsDebug('native:init'); 180 + setHlsDebug('native:init')
178 181
179 // 原生播放器走系统内核:事件主要用于控制弱网提示与错误覆盖层 182 // 原生播放器走系统内核:事件主要用于控制弱网提示与错误覆盖层
180 const onLoadStart = () => { 183 const onLoadStart = () => {
181 - showErrorOverlay.value = false; 184 + showErrorOverlay.value = false
182 - nativeReady.value = false; 185 + nativeReady.value = false
183 - }; 186 + }
184 187
185 const onCanPlay = () => { 188 const onCanPlay = () => {
186 - showErrorOverlay.value = false; 189 + showErrorOverlay.value = false
187 - retryCount.value = 0; 190 + retryCount.value = 0
188 - nativeReady.value = true; 191 + nativeReady.value = true
189 - }; 192 + }
190 193
191 const onError = () => { 194 const onError = () => {
192 - handleError(videoEl.error?.code); 195 + handleError(videoEl.error?.code)
193 - }; 196 + }
194 197
195 const onPlay = () => { 198 const onPlay = () => {
196 - hideNetworkSpeed(); 199 + hideNetworkSpeed()
197 - setHlsDebug('native:play'); 200 + setHlsDebug('native:play')
198 - }; 201 + }
199 202
200 const onPause = () => { 203 const onPause = () => {
201 - hideNetworkSpeed(); 204 + hideNetworkSpeed()
202 - }; 205 + }
203 206
204 const onWaiting = () => { 207 const onWaiting = () => {
205 - if (videoEl.paused) return; 208 + if (videoEl.paused) return
206 - showNetworkSpeed(); 209 + showNetworkSpeed()
207 - setHlsDebug('native:waiting'); 210 + setHlsDebug('native:waiting')
208 - }; 211 + }
209 212
210 const onStalled = () => { 213 const onStalled = () => {
211 - if (videoEl.paused) return; 214 + if (videoEl.paused) return
212 - showNetworkSpeed(); 215 + showNetworkSpeed()
213 - setHlsDebug('native:stalled'); 216 + setHlsDebug('native:stalled')
214 - }; 217 + }
215 218
216 const onPlaying = () => { 219 const onPlaying = () => {
217 - hasEverPlayed.value = true; 220 + hasEverPlayed.value = true
218 - hasStartedPlayback.value = true; 221 + hasStartedPlayback.value = true
219 - hideNetworkSpeed(); 222 + hideNetworkSpeed()
220 - setHlsDebug('native:playing'); 223 + setHlsDebug('native:playing')
221 - }; 224 + }
222 - 225 +
223 - videoEl.addEventListener("loadstart", onLoadStart); 226 + videoEl.addEventListener('loadstart', onLoadStart)
224 - videoEl.addEventListener("canplay", onCanPlay); 227 + videoEl.addEventListener('canplay', onCanPlay)
225 - videoEl.addEventListener("error", onError); 228 + videoEl.addEventListener('error', onError)
226 - videoEl.addEventListener("play", onPlay); 229 + videoEl.addEventListener('play', onPlay)
227 - videoEl.addEventListener("pause", onPause); 230 + videoEl.addEventListener('pause', onPause)
228 - videoEl.addEventListener("waiting", onWaiting); 231 + videoEl.addEventListener('waiting', onWaiting)
229 - videoEl.addEventListener("stalled", onStalled); 232 + videoEl.addEventListener('stalled', onStalled)
230 - videoEl.addEventListener("playing", onPlaying); 233 + videoEl.addEventListener('playing', onPlaying)
231 234
232 nativeListeners = { 235 nativeListeners = {
233 videoEl, 236 videoEl,
...@@ -239,49 +242,45 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) { ...@@ -239,49 +242,45 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
239 onWaiting, 242 onWaiting,
240 onStalled, 243 onStalled,
241 onPlaying, 244 onPlaying,
242 - }; 245 + }
243 246
244 if (props.autoplay) { 247 if (props.autoplay) {
245 // iOS 微信 autoplay 需要用户手势/桥接事件配合,先尝试一次,再在 WeixinJSBridgeReady 时再试 248 // iOS 微信 autoplay 需要用户手势/桥接事件配合,先尝试一次,再在 WeixinJSBridgeReady 时再试
246 - tryNativePlay(); 249 + tryNativePlay()
247 - if (typeof document !== "undefined") { 250 + if (typeof document !== 'undefined') {
248 - document.addEventListener( 251 + document.addEventListener('WeixinJSBridgeReady', () => tryNativePlay(), { once: true })
249 - "WeixinJSBridgeReady",
250 - () => tryNativePlay(),
251 - { once: true }
252 - );
253 } 252 }
254 } 253 }
255 - }; 254 + }
256 255
257 const tryNativePlay = () => { 256 const tryNativePlay = () => {
258 - const videoEl = nativeVideoRef.value; 257 + const videoEl = nativeVideoRef.value
259 - if (!videoEl) return; 258 + if (!videoEl) return
260 259
261 - const playPromise = videoEl.play(); 260 + const playPromise = videoEl.play()
262 - if (playPromise && typeof playPromise.catch === "function") { 261 + if (playPromise && typeof playPromise.catch === 'function') {
263 playPromise.catch(() => { 262 playPromise.catch(() => {
264 - if (typeof window !== "undefined" && window.WeixinJSBridge) { 263 + if (typeof window !== 'undefined' && window.WeixinJSBridge) {
265 - window.WeixinJSBridge.invoke("getNetworkType", {}, () => { 264 + window.WeixinJSBridge.invoke('getNetworkType', {}, () => {
266 - videoEl.play().catch(() => {}); 265 + videoEl.play().catch(() => {})
267 - }); 266 + })
268 } 267 }
269 - }); 268 + })
270 } 269 }
271 - }; 270 + }
272 271
273 // 5. Video.js 播放器逻辑 (PC/Android) 272 // 5. Video.js 播放器逻辑 (PC/Android)
274 const shouldOverrideNativeHls = computed(() => { 273 const shouldOverrideNativeHls = computed(() => {
275 - if (!isM3U8.value) return false; 274 + if (!isM3U8.value) return false
276 - if (is_safari_browser()) return false; 275 + if (is_safari_browser()) return false
277 // 非 Safari 且不具备原生 HLS 时,强制 video.js 的 VHS 来解 m3u8 276 // 非 Safari 且不具备原生 HLS 时,强制 video.js 的 VHS 来解 m3u8
278 - return !canPlayHlsNatively(); 277 + return !canPlayHlsNatively()
279 - }); 278 + })
280 279
281 const videoOptions = computed(() => { 280 const videoOptions = computed(() => {
282 const base = { 281 const base = {
283 controls: true, 282 controls: true,
284 - preload: "metadata", 283 + preload: 'metadata',
285 responsive: true, 284 responsive: true,
286 autoplay: props.autoplay, 285 autoplay: props.autoplay,
287 playsinline: true, 286 playsinline: true,
...@@ -295,8 +294,8 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) { ...@@ -295,8 +294,8 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
295 nativeAudioTracks: false, 294 nativeAudioTracks: false,
296 nativeTextTracks: false, 295 nativeTextTracks: false,
297 hls: { 296 hls: {
298 - withCredentials: false 297 + withCredentials: false,
299 - } 298 + },
300 }, 299 },
301 techOrder: ['html5'], 300 techOrder: ['html5'],
302 userActions: { 301 userActions: {
...@@ -314,246 +313,249 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) { ...@@ -314,246 +313,249 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
314 }, 313 },
315 ...props.options, 314 ...props.options,
316 errorDisplay: false, 315 errorDisplay: false,
317 - }; 316 + }
318 if (!base.poster) { 317 if (!base.poster) {
319 - delete base.poster; 318 + delete base.poster
320 } 319 }
321 - return base; 320 + return base
322 - }); 321 + })
323 322
324 // 8. Video.js 挂载处理 323 // 8. Video.js 挂载处理
325 - const handleVideoJsMounted = (payload) => { 324 + const handleVideoJsMounted = payload => {
326 - state.value = payload.state; 325 + state.value = payload.state
327 - player.value = payload.player; 326 + player.value = payload.player
328 327
329 if (player.value) { 328 if (player.value) {
330 - setHlsDebug('mounted'); 329 + setHlsDebug('mounted')
331 330
332 - const quality_selector_inited = { value: false }; 331 + const quality_selector_inited = { value: false }
333 const setupQualitySelector = () => { 332 const setupQualitySelector = () => {
334 - if (quality_selector_inited.value) return; 333 + if (quality_selector_inited.value) return
335 - if (!isM3U8.value) return; 334 + if (!isM3U8.value) return
336 - const p = player.value; 335 + const p = player.value
337 - if (!p || (typeof p.isDisposed === "function" && p.isDisposed())) return; 336 + if (!p || (typeof p.isDisposed === 'function' && p.isDisposed())) return
338 - if (typeof p.hlsQualitySelector !== "function") return; 337 + if (typeof p.hlsQualitySelector !== 'function') return
339 - if (typeof p.qualityLevels !== "function") return; 338 + if (typeof p.qualityLevels !== 'function') return
340 - 339 +
341 - let tech = null; 340 + let tech = null
342 try { 341 try {
343 - tech = typeof p.tech === "function" ? p.tech({ IWillNotUseThisInPlugins: true }) : null; 342 + tech = typeof p.tech === 'function' ? p.tech({ IWillNotUseThisInPlugins: true }) : null
344 } catch (e) { 343 } catch (e) {
345 - tech = null; 344 + tech = null
346 } 345 }
347 - if (!tech) return; 346 + if (!tech) return
348 // videojs-hls-quality-selector 旧版本依赖 tech.hls,而 video.js 7 默认是 tech.vhs,这里做兼容别名 347 // videojs-hls-quality-selector 旧版本依赖 tech.hls,而 video.js 7 默认是 tech.vhs,这里做兼容别名
349 if (!tech.hls && tech.vhs) { 348 if (!tech.hls && tech.vhs) {
350 try { 349 try {
351 - tech.hls = tech.vhs; 350 + tech.hls = tech.vhs
352 } catch (e) { 351 } catch (e) {
353 - void e; 352 + void e
354 } 353 }
355 } 354 }
356 - if (!tech.hls) return; 355 + if (!tech.hls) return
357 356
358 try { 357 try {
359 p.hlsQualitySelector({ 358 p.hlsQualitySelector({
360 displayCurrentQuality: true, 359 displayCurrentQuality: true,
361 - }); 360 + })
362 - quality_selector_inited.value = true; 361 + quality_selector_inited.value = true
363 } catch (e) { 362 } catch (e) {
364 - void e; 363 + void e
365 } 364 }
366 - }; 365 + }
367 366
368 - setupQualitySelector(); 367 + setupQualitySelector()
369 368
370 player.value.on('error', () => { 369 player.value.on('error', () => {
371 - const err = player.value.error(); 370 + const err = player.value.error()
372 - handleError(err?.code, err?.message); 371 + handleError(err?.code, err?.message)
373 - }); 372 + })
374 373
375 player.value.on('loadstart', () => { 374 player.value.on('loadstart', () => {
376 - showErrorOverlay.value = false; 375 + showErrorOverlay.value = false
377 - setupQualitySelector(); 376 + setupQualitySelector()
378 - }); 377 + })
379 378
380 player.value.on('canplay', () => { 379 player.value.on('canplay', () => {
381 - showErrorOverlay.value = false; 380 + showErrorOverlay.value = false
382 - retryCount.value = 0; 381 + retryCount.value = 0
383 - setupQualitySelector(); 382 + setupQualitySelector()
384 - }); 383 + })
385 384
386 player.value.on('play', () => { 385 player.value.on('play', () => {
387 - hideNetworkSpeed(); 386 + hideNetworkSpeed()
388 - startHlsDownloadSpeed(); 387 + startHlsDownloadSpeed()
389 - setHlsDebug('play'); 388 + setHlsDebug('play')
390 - }); 389 + })
391 390
392 player.value.on('pause', () => { 391 player.value.on('pause', () => {
393 - hideNetworkSpeed(); 392 + hideNetworkSpeed()
394 - stopHlsDownloadSpeed('pause'); 393 + stopHlsDownloadSpeed('pause')
395 - setHlsDebug('pause'); 394 + setHlsDebug('pause')
396 - }); 395 + })
397 396
398 player.value.on('waiting', () => { 397 player.value.on('waiting', () => {
399 - if (!hasEverPlayed.value) return; 398 + if (!hasEverPlayed.value) return
400 - if (player.value?.paused?.()) return; 399 + if (player.value?.paused?.()) return
401 // 已经播放过且当前未暂停,才认为是“卡顿等待”,显示弱网提示 400 // 已经播放过且当前未暂停,才认为是“卡顿等待”,显示弱网提示
402 - showNetworkSpeed(); 401 + showNetworkSpeed()
403 - startHlsDownloadSpeed(); 402 + startHlsDownloadSpeed()
404 - setHlsDebug('waiting'); 403 + setHlsDebug('waiting')
405 - }); 404 + })
406 405
407 player.value.on('stalled', () => { 406 player.value.on('stalled', () => {
408 - if (!hasEverPlayed.value) return; 407 + if (!hasEverPlayed.value) return
409 - if (player.value?.paused?.()) return; 408 + if (player.value?.paused?.()) return
410 - showNetworkSpeed(); 409 + showNetworkSpeed()
411 - startHlsDownloadSpeed(); 410 + startHlsDownloadSpeed()
412 - setHlsDebug('stalled'); 411 + setHlsDebug('stalled')
413 - }); 412 + })
414 413
415 player.value.on('playing', () => { 414 player.value.on('playing', () => {
416 - hasEverPlayed.value = true; 415 + hasEverPlayed.value = true
417 - hasStartedPlayback.value = true; 416 + hasStartedPlayback.value = true
418 - hideNetworkSpeed(); 417 + hideNetworkSpeed()
419 - setHlsDebug('playing'); 418 + setHlsDebug('playing')
420 - }); 419 + })
421 420
422 player.value.on('ended', () => { 421 player.value.on('ended', () => {
423 - stopHlsDownloadSpeed('ended'); 422 + stopHlsDownloadSpeed('ended')
424 - setHlsDebug('ended'); 423 + setHlsDebug('ended')
425 - }); 424 + })
426 425
427 if (props.autoplay) { 426 if (props.autoplay) {
428 - player.value.play().catch(() => {}); 427 + player.value.play().catch(() => {})
429 } 428 }
430 } 429 }
431 - }; 430 + }
432 431
433 // 6. 重试逻辑 432 // 6. 重试逻辑
434 const retryLoad = () => { 433 const retryLoad = () => {
435 if (!canRetry.value) { 434 if (!canRetry.value) {
436 - showErrorOverlay.value = true; 435 + showErrorOverlay.value = true
437 - return; 436 + return
438 } 437 }
439 438
440 - retryCount.value++; 439 + retryCount.value++
441 - showErrorOverlay.value = false; 440 + showErrorOverlay.value = false
442 - hideNetworkSpeed(); 441 + hideNetworkSpeed()
443 - stopHlsDownloadSpeed('retry'); 442 + stopHlsDownloadSpeed('retry')
444 443
445 if (retry_error_check_timer) { 444 if (retry_error_check_timer) {
446 - clearTimeout(retry_error_check_timer); 445 + clearTimeout(retry_error_check_timer)
447 - retry_error_check_timer = null; 446 + retry_error_check_timer = null
448 } 447 }
449 448
450 if (useNativePlayer.value) { 449 if (useNativePlayer.value) {
451 // 原生 video 需要手动重置 src/load 450 // 原生 video 需要手动重置 src/load
452 - const videoEl = nativeVideoRef.value; 451 + const videoEl = nativeVideoRef.value
453 if (videoEl) { 452 if (videoEl) {
454 - nativeReady.value = false; 453 + nativeReady.value = false
455 - const currentSrc = videoEl.currentSrc || videoEl.src; 454 + const currentSrc = videoEl.currentSrc || videoEl.src
456 - videoEl.pause(); 455 + videoEl.pause()
457 - videoEl.removeAttribute("src"); 456 + videoEl.removeAttribute('src')
458 - videoEl.load(); 457 + videoEl.load()
459 - videoEl.src = currentSrc || videoUrlValue.value; 458 + videoEl.src = currentSrc || videoUrlValue.value
460 - videoEl.load(); 459 + videoEl.load()
461 - tryNativePlay(); 460 + tryNativePlay()
462 461
463 retry_error_check_timer = setTimeout(() => { 462 retry_error_check_timer = setTimeout(() => {
464 - const err_code = videoEl?.error?.code; 463 + const err_code = videoEl?.error?.code
465 if (err_code) { 464 if (err_code) {
466 - handleError(err_code); 465 + handleError(err_code)
467 } 466 }
468 - }, 800); 467 + }, 800)
469 } 468 }
470 } else { 469 } else {
471 // video.js 走自身 load 刷新 470 // video.js 走自身 load 刷新
472 if (player.value && !player.value.isDisposed()) { 471 if (player.value && !player.value.isDisposed()) {
473 - const p = player.value; 472 + const p = player.value
474 try { 473 try {
475 - p.pause?.(); 474 + p.pause?.()
476 } catch (e) { 475 } catch (e) {
477 - void e; 476 + void e
478 } 477 }
479 try { 478 try {
480 - p.error?.(null); 479 + p.error?.(null)
481 } catch (e) { 480 } catch (e) {
482 - void e; 481 + void e
483 } 482 }
484 try { 483 try {
485 - p.src?.(videoSources.value); 484 + p.src?.(videoSources.value)
486 } catch (e) { 485 } catch (e) {
487 - void e; 486 + void e
488 } 487 }
489 try { 488 try {
490 - p.load?.(); 489 + p.load?.()
491 } catch (e) { 490 } catch (e) {
492 - void e; 491 + void e
493 } 492 }
494 try { 493 try {
495 - p.play?.()?.catch?.(() => {}); 494 + p.play?.()?.catch?.(() => {})
496 } catch (e) { 495 } catch (e) {
497 - void e; 496 + void e
498 } 497 }
499 498
500 retry_error_check_timer = setTimeout(() => { 499 retry_error_check_timer = setTimeout(() => {
501 - const err = p?.error?.(); 500 + const err = p?.error?.()
502 if (err?.code) { 501 if (err?.code) {
503 - handleError(err.code, err.message); 502 + handleError(err.code, err.message)
504 } 503 }
505 - }, 800); 504 + }, 800)
506 } 505 }
507 } 506 }
508 - }; 507 + }
509 508
510 // 7. 生命周期与监听 509 // 7. 生命周期与监听
511 - watch(() => videoUrlValue.value, () => { 510 + watch(
512 - retryCount.value = 0; 511 + () => videoUrlValue.value,
513 - showErrorOverlay.value = false; 512 + () => {
514 - hideNetworkSpeed(); 513 + retryCount.value = 0
515 - stopHlsDownloadSpeed('url_change'); 514 + showErrorOverlay.value = false
516 - hasEverPlayed.value = false; 515 + hideNetworkSpeed()
517 - hasStartedPlayback.value = false; 516 + stopHlsDownloadSpeed('url_change')
518 - // 地址变更后刷新探测信息,错误提示会基于 probeInfo 补充更准确的原因 517 + hasEverPlayed.value = false
519 - void probeVideo(); 518 + hasStartedPlayback.value = false
520 - 519 + // 地址变更后刷新探测信息,错误提示会基于 probeInfo 补充更准确的原因
521 - // 如果是原生播放器且 URL 变化,需要手动处理 HLS (如果是非 iOS Safari 环境) 520 + void probeVideo()
522 - if (useNativePlayer.value && isM3U8.value) { 521 +
523 - // iOS 原生支持,不需要额外操作 522 + // 如果是原生播放器且 URL 变化,需要手动处理 HLS (如果是非 iOS Safari 环境)
524 - // 如果未来支持 Android 原生播放器且不支持 HLS,需在此处初始化 hls.js 523 + if (useNativePlayer.value && isM3U8.value) {
524 + // iOS 原生支持,不需要额外操作
525 + // 如果未来支持 Android 原生播放器且不支持 HLS,需在此处初始化 hls.js
526 + }
525 } 527 }
526 - }); 528 + )
527 529
528 onMounted(() => { 530 onMounted(() => {
529 - void probeVideo(); 531 + void probeVideo()
530 if (useNativePlayer.value) { 532 if (useNativePlayer.value) {
531 - initNativePlayer(); 533 + initNativePlayer()
532 } 534 }
533 - }); 535 + })
534 536
535 onBeforeUnmount(() => { 537 onBeforeUnmount(() => {
536 if (retry_error_check_timer) { 538 if (retry_error_check_timer) {
537 - clearTimeout(retry_error_check_timer); 539 + clearTimeout(retry_error_check_timer)
538 - retry_error_check_timer = null; 540 + retry_error_check_timer = null
539 } 541 }
540 542
541 if (nativeListeners?.videoEl) { 543 if (nativeListeners?.videoEl) {
542 - nativeListeners.videoEl.removeEventListener("loadstart", nativeListeners.onLoadStart); 544 + nativeListeners.videoEl.removeEventListener('loadstart', nativeListeners.onLoadStart)
543 - nativeListeners.videoEl.removeEventListener("canplay", nativeListeners.onCanPlay); 545 + nativeListeners.videoEl.removeEventListener('canplay', nativeListeners.onCanPlay)
544 - nativeListeners.videoEl.removeEventListener("error", nativeListeners.onError); 546 + nativeListeners.videoEl.removeEventListener('error', nativeListeners.onError)
545 - nativeListeners.videoEl.removeEventListener("play", nativeListeners.onPlay); 547 + nativeListeners.videoEl.removeEventListener('play', nativeListeners.onPlay)
546 - nativeListeners.videoEl.removeEventListener("pause", nativeListeners.onPause); 548 + nativeListeners.videoEl.removeEventListener('pause', nativeListeners.onPause)
547 - nativeListeners.videoEl.removeEventListener("waiting", nativeListeners.onWaiting); 549 + nativeListeners.videoEl.removeEventListener('waiting', nativeListeners.onWaiting)
548 - nativeListeners.videoEl.removeEventListener("stalled", nativeListeners.onStalled); 550 + nativeListeners.videoEl.removeEventListener('stalled', nativeListeners.onStalled)
549 - nativeListeners.videoEl.removeEventListener("playing", nativeListeners.onPlaying); 551 + nativeListeners.videoEl.removeEventListener('playing', nativeListeners.onPlaying)
550 } 552 }
551 553
552 - disposeOverlays(); 554 + disposeOverlays()
553 if (videoRef.value?.$player) { 555 if (videoRef.value?.$player) {
554 - videoRef.value.$player.dispose(); 556 + videoRef.value.$player.dispose()
555 } 557 }
556 - }); 558 + })
557 559
558 return { 560 return {
559 player, 561 player,
...@@ -572,6 +574,6 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) { ...@@ -572,6 +574,6 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
572 hlsSpeedDebugText, 574 hlsSpeedDebugText,
573 retryLoad, 575 retryLoad,
574 handleVideoJsMounted, 576 handleVideoJsMounted,
575 - tryNativePlay 577 + tryNativePlay,
576 - }; 578 + }
577 } 579 }
......