fix(视频播放器): 改进重试逻辑并优化错误处理样式
添加重试次数限制判断条件canRetry 移除自动重试逻辑改为手动触发 优化错误提示UI样式和间距 添加重试后的错误状态检查定时器
Showing
3 changed files
with
174 additions
and
17 deletions
| ... | @@ -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, | ... | ... |
-
Please register or login to post a comment