hookehuyr

fix(视频播放器): 改进重试逻辑并优化错误处理样式

添加重试次数限制判断条件canRetry
移除自动重试逻辑改为手动触发
优化错误提示UI样式和间距
添加重试后的错误状态检查定时器
...@@ -55,7 +55,7 @@ ...@@ -55,7 +55,7 @@
55 <div class="error-content"> 55 <div class="error-content">
56 <div class="error-icon">⚠️</div> 56 <div class="error-icon">⚠️</div>
57 <div class="error-message">{{ errorMessage }}</div> 57 <div class="error-message">{{ errorMessage }}</div>
58 - <button @click="retryLoad" class="retry-button">重试</button> 58 + <button v-if="canRetry" @click="retryLoad" class="retry-button">重试</button>
59 </div> 59 </div>
60 </div> 60 </div>
61 </div> 61 </div>
...@@ -113,6 +113,7 @@ const { ...@@ -113,6 +113,7 @@ const {
113 videoOptions, 113 videoOptions,
114 showErrorOverlay, 114 showErrorOverlay,
115 errorMessage, 115 errorMessage,
116 + canRetry,
116 showNetworkSpeedOverlay, 117 showNetworkSpeedOverlay,
117 networkSpeedText, 118 networkSpeedText,
118 hlsDownloadSpeedText, 119 hlsDownloadSpeedText,
...@@ -218,18 +219,19 @@ defineExpose({ ...@@ -218,18 +219,19 @@ defineExpose({
218 .error-content { 219 .error-content {
219 text-align: center; 220 text-align: center;
220 color: white; 221 color: white;
221 - padding: 20px; 222 + padding: 14px;
223 + max-width: 84%;
222 } 224 }
223 225
224 .error-icon { 226 .error-icon {
225 - font-size: 48px; 227 + font-size: 32px;
226 - margin-bottom: 16px; 228 + margin-bottom: 8px;
227 } 229 }
228 230
229 .error-message { 231 .error-message {
230 - font-size: 16px; 232 + font-size: 14px;
231 - margin-bottom: 20px; 233 + margin-bottom: 12px;
232 - line-height: 1.5; 234 + line-height: 1.35;
233 } 235 }
234 236
235 .speed-overlay { 237 .speed-overlay {
...@@ -307,10 +309,10 @@ defineExpose({ ...@@ -307,10 +309,10 @@ defineExpose({
307 background: #007bff; 309 background: #007bff;
308 color: white; 310 color: white;
309 border: none; 311 border: none;
310 - padding: 10px 20px; 312 + padding: 8px 14px;
311 - border-radius: 4px; 313 + border-radius: 8px;
312 cursor: pointer; 314 cursor: pointer;
313 - font-size: 14px; 315 + font-size: 13px;
314 transition: background-color 0.3s; 316 transition: background-color 0.3s;
315 } 317 }
316 318
...@@ -322,6 +324,14 @@ defineExpose({ ...@@ -322,6 +324,14 @@ defineExpose({
322 display: none !important; 324 display: none !important;
323 } 325 }
324 326
327 +:deep(.vjs-error-display) {
328 + display: none !important;
329 +}
330 +
331 +:deep(.vjs-modal-dialog) {
332 + display: none !important;
333 +}
334 +
325 /* 修复 Video.js 在移动端拖动进度条时的报错 */ 335 /* 修复 Video.js 在移动端拖动进度条时的报错 */
326 :deep(.vjs-progress-control) { 336 :deep(.vjs-progress-control) {
327 touch-action: none; 337 touch-action: none;
......
...@@ -267,3 +267,96 @@ describe('useVideoPlayer blob URL 兜底类型识别', () => { ...@@ -267,3 +267,96 @@ describe('useVideoPlayer blob URL 兜底类型识别', () => {
267 expect(videoOptions.value.sources[0].type).toBe('video/mp4') 267 expect(videoOptions.value.sources[0].type).toBe('video/mp4')
268 }) 268 })
269 }) 269 })
270 +
271 +describe('useVideoPlayer 重试上限与错误回退', () => {
272 + beforeEach(() => {
273 + vi.useFakeTimers()
274 + })
275 +
276 + afterEach(() => {
277 + vi.clearAllTimers()
278 + vi.useRealTimers()
279 + })
280 +
281 + it('重试失败时恢复错误提示,并在达到上限后不可再重试', () => {
282 + const props = {
283 + options: {},
284 + videoUrl: 'https://example.com/test.mp4',
285 + videoId: 'v1.mp4',
286 + autoplay: false,
287 + debug: false,
288 + useNativeOnIos: true
289 + }
290 +
291 + const emit = vi.fn()
292 + const videoRef = ref(null)
293 + const nativeVideoRef = ref(null)
294 +
295 + const {
296 + handleVideoJsMounted,
297 + retryLoad,
298 + showErrorOverlay,
299 + errorMessage,
300 + canRetry,
301 + retryCount
302 + } = useVideoPlayer(
303 + props,
304 + emit,
305 + videoRef,
306 + nativeVideoRef
307 + )
308 +
309 + const listeners = {}
310 + let current_error = { code: 4, message: 'No compatible source was found for this media.' }
311 +
312 + const player = {
313 + isDisposed: () => false,
314 + on: (eventName, callback) => {
315 + listeners[eventName] = callback
316 + },
317 + tech: () => ({}),
318 + hlsQualitySelector: vi.fn(),
319 + qualityLevels: vi.fn(() => ({ on: vi.fn() })),
320 + error: vi.fn(function (value) {
321 + if (arguments.length) {
322 + current_error = value
323 + return null
324 + }
325 + return current_error
326 + }),
327 + src: vi.fn(),
328 + load: vi.fn(),
329 + pause: vi.fn(),
330 + play: vi.fn(() => Promise.resolve())
331 + }
332 +
333 + handleVideoJsMounted({ state: {}, player })
334 +
335 + listeners.error()
336 + expect(showErrorOverlay.value).toBe(true)
337 + expect(errorMessage.value).toBe('视频格式不支持或无法加载,请检查网络连接')
338 + expect(canRetry.value).toBe(true)
339 +
340 + retryLoad()
341 + current_error = { code: 4, message: 'No compatible source was found for this media.' }
342 + vi.advanceTimersByTime(900)
343 + expect(showErrorOverlay.value).toBe(true)
344 + expect(errorMessage.value).toBe('视频格式不支持或无法加载,请检查网络连接')
345 + expect(retryCount.value).toBe(1)
346 + expect(canRetry.value).toBe(true)
347 +
348 + retryLoad()
349 + current_error = { code: 4, message: 'No compatible source was found for this media.' }
350 + vi.advanceTimersByTime(900)
351 + expect(showErrorOverlay.value).toBe(true)
352 + expect(retryCount.value).toBe(2)
353 + expect(canRetry.value).toBe(true)
354 +
355 + retryLoad()
356 + current_error = { code: 4, message: 'No compatible source was found for this media.' }
357 + vi.advanceTimersByTime(900)
358 + expect(showErrorOverlay.value).toBe(true)
359 + expect(retryCount.value).toBe(3)
360 + expect(canRetry.value).toBe(false)
361 + })
362 +})
......
...@@ -50,6 +50,7 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) { ...@@ -50,6 +50,7 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
50 const errorMessage = ref(''); 50 const errorMessage = ref('');
51 const retryCount = ref(0); 51 const retryCount = ref(0);
52 const maxRetries = 3; 52 const maxRetries = 3;
53 + const canRetry = computed(() => retryCount.value < maxRetries);
53 54
54 const hasEverPlayed = ref(false); 55 const hasEverPlayed = ref(false);
55 const hasStartedPlayback = ref(false); 56 const hasStartedPlayback = ref(false);
...@@ -57,6 +58,7 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) { ...@@ -57,6 +58,7 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
57 // 原生播放器状态 58 // 原生播放器状态
58 const nativeReady = ref(false); 59 const nativeReady = ref(false);
59 let nativeListeners = null; 60 let nativeListeners = null;
61 + let retry_error_check_timer = null;
60 62
61 // 1. 环境判断与播放器选择 63 // 1. 环境判断与播放器选择
62 const useNativePlayer = computed(() => { 64 const useNativePlayer = computed(() => {
...@@ -144,9 +146,9 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) { ...@@ -144,9 +146,9 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
144 case 4: // MEDIA_ERR_SRC_NOT_SUPPORTED 146 case 4: // MEDIA_ERR_SRC_NOT_SUPPORTED
145 errorMessage.value = '视频格式不支持或无法加载,请检查网络连接' + getErrorHint(); 147 errorMessage.value = '视频格式不支持或无法加载,请检查网络连接' + getErrorHint();
146 // 旧机型/弱网下可能出现短暂的“无法加载”,这里做有限次数重试 148 // 旧机型/弱网下可能出现短暂的“无法加载”,这里做有限次数重试
147 - if (retryCount.value < maxRetries) { 149 + // if (retryCount.value < maxRetries) {
148 - setTimeout(retryLoad, 1000); 150 + // setTimeout(retryLoad, 1000);
149 - } 151 + // }
150 break; 152 break;
151 case 3: // MEDIA_ERR_DECODE 153 case 3: // MEDIA_ERR_DECODE
152 errorMessage.value = '视频解码失败,可能是文件损坏'; 154 errorMessage.value = '视频解码失败,可能是文件损坏';
...@@ -293,7 +295,6 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) { ...@@ -293,7 +295,6 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
293 withCredentials: false 295 withCredentials: false
294 } 296 }
295 }, 297 },
296 - errorDisplay: true,
297 techOrder: ['html5'], 298 techOrder: ['html5'],
298 userActions: { 299 userActions: {
299 hotkeys: true, 300 hotkeys: true,
...@@ -309,6 +310,7 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) { ...@@ -309,6 +310,7 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
309 }, 310 },
310 }, 311 },
311 ...props.options, 312 ...props.options,
313 + errorDisplay: false,
312 })); 314 }));
313 315
314 // 8. Video.js 挂载处理 316 // 8. Video.js 挂载处理
...@@ -422,8 +424,8 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) { ...@@ -422,8 +424,8 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
422 424
423 // 6. 重试逻辑 425 // 6. 重试逻辑
424 const retryLoad = () => { 426 const retryLoad = () => {
425 - if (retryCount.value >= maxRetries) { 427 + if (!canRetry.value) {
426 - errorMessage.value = '重试次数已达上限,请稍后再试'; 428 + showErrorOverlay.value = true;
427 return; 429 return;
428 } 430 }
429 431
...@@ -432,6 +434,11 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) { ...@@ -432,6 +434,11 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
432 hideNetworkSpeed(); 434 hideNetworkSpeed();
433 stopHlsDownloadSpeed('retry'); 435 stopHlsDownloadSpeed('retry');
434 436
437 + if (retry_error_check_timer) {
438 + clearTimeout(retry_error_check_timer);
439 + retry_error_check_timer = null;
440 + }
441 +
435 if (useNativePlayer.value) { 442 if (useNativePlayer.value) {
436 // 原生 video 需要手动重置 src/load 443 // 原生 video 需要手动重置 src/load
437 const videoEl = nativeVideoRef.value; 444 const videoEl = nativeVideoRef.value;
...@@ -444,11 +451,50 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) { ...@@ -444,11 +451,50 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
444 videoEl.src = currentSrc || videoUrlValue.value; 451 videoEl.src = currentSrc || videoUrlValue.value;
445 videoEl.load(); 452 videoEl.load();
446 tryNativePlay(); 453 tryNativePlay();
454 +
455 + retry_error_check_timer = setTimeout(() => {
456 + const err_code = videoEl?.error?.code;
457 + if (err_code) {
458 + handleError(err_code);
459 + }
460 + }, 800);
447 } 461 }
448 } else { 462 } else {
449 // video.js 走自身 load 刷新 463 // video.js 走自身 load 刷新
450 if (player.value && !player.value.isDisposed()) { 464 if (player.value && !player.value.isDisposed()) {
451 - player.value.load(); 465 + const p = player.value;
466 + try {
467 + p.pause?.();
468 + } catch (e) {
469 + void e;
470 + }
471 + try {
472 + p.error?.(null);
473 + } catch (e) {
474 + void e;
475 + }
476 + try {
477 + p.src?.(videoSources.value);
478 + } catch (e) {
479 + void e;
480 + }
481 + try {
482 + p.load?.();
483 + } catch (e) {
484 + void e;
485 + }
486 + try {
487 + p.play?.()?.catch?.(() => {});
488 + } catch (e) {
489 + void e;
490 + }
491 +
492 + retry_error_check_timer = setTimeout(() => {
493 + const err = p?.error?.();
494 + if (err?.code) {
495 + handleError(err.code, err.message);
496 + }
497 + }, 800);
452 } 498 }
453 } 499 }
454 }; 500 };
...@@ -479,6 +525,11 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) { ...@@ -479,6 +525,11 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
479 }); 525 });
480 526
481 onBeforeUnmount(() => { 527 onBeforeUnmount(() => {
528 + if (retry_error_check_timer) {
529 + clearTimeout(retry_error_check_timer);
530 + retry_error_check_timer = null;
531 + }
532 +
482 if (nativeListeners?.videoEl) { 533 if (nativeListeners?.videoEl) {
483 nativeListeners.videoEl.removeEventListener("loadstart", nativeListeners.onLoadStart); 534 nativeListeners.videoEl.removeEventListener("loadstart", nativeListeners.onLoadStart);
484 nativeListeners.videoEl.removeEventListener("canplay", nativeListeners.onCanPlay); 535 nativeListeners.videoEl.removeEventListener("canplay", nativeListeners.onCanPlay);
...@@ -504,6 +555,9 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) { ...@@ -504,6 +555,9 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
504 videoOptions, 555 videoOptions,
505 showErrorOverlay, 556 showErrorOverlay,
506 errorMessage, 557 errorMessage,
558 + canRetry,
559 + retryCount,
560 + maxRetries,
507 showNetworkSpeedOverlay, 561 showNetworkSpeedOverlay,
508 networkSpeedText, 562 networkSpeedText,
509 hlsDownloadSpeedText, 563 hlsDownloadSpeedText,
......