hookehuyr

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

添加重试次数限制判断条件canRetry
移除自动重试逻辑改为手动触发
优化错误提示UI样式和间距
添加重试后的错误状态检查定时器
......@@ -55,7 +55,7 @@
<div class="error-content">
<div class="error-icon">⚠️</div>
<div class="error-message">{{ errorMessage }}</div>
<button @click="retryLoad" class="retry-button">重试</button>
<button v-if="canRetry" @click="retryLoad" class="retry-button">重试</button>
</div>
</div>
</div>
......@@ -113,6 +113,7 @@ const {
videoOptions,
showErrorOverlay,
errorMessage,
canRetry,
showNetworkSpeedOverlay,
networkSpeedText,
hlsDownloadSpeedText,
......@@ -218,18 +219,19 @@ defineExpose({
.error-content {
text-align: center;
color: white;
padding: 20px;
padding: 14px;
max-width: 84%;
}
.error-icon {
font-size: 48px;
margin-bottom: 16px;
font-size: 32px;
margin-bottom: 8px;
}
.error-message {
font-size: 16px;
margin-bottom: 20px;
line-height: 1.5;
font-size: 14px;
margin-bottom: 12px;
line-height: 1.35;
}
.speed-overlay {
......@@ -307,10 +309,10 @@ defineExpose({
background: #007bff;
color: white;
border: none;
padding: 10px 20px;
border-radius: 4px;
padding: 8px 14px;
border-radius: 8px;
cursor: pointer;
font-size: 14px;
font-size: 13px;
transition: background-color 0.3s;
}
......@@ -322,6 +324,14 @@ defineExpose({
display: none !important;
}
:deep(.vjs-error-display) {
display: none !important;
}
:deep(.vjs-modal-dialog) {
display: none !important;
}
/* 修复 Video.js 在移动端拖动进度条时的报错 */
:deep(.vjs-progress-control) {
touch-action: none;
......
......@@ -267,3 +267,96 @@ describe('useVideoPlayer blob URL 兜底类型识别', () => {
expect(videoOptions.value.sources[0].type).toBe('video/mp4')
})
})
describe('useVideoPlayer 重试上限与错误回退', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.clearAllTimers()
vi.useRealTimers()
})
it('重试失败时恢复错误提示,并在达到上限后不可再重试', () => {
const props = {
options: {},
videoUrl: 'https://example.com/test.mp4',
videoId: 'v1.mp4',
autoplay: false,
debug: false,
useNativeOnIos: true
}
const emit = vi.fn()
const videoRef = ref(null)
const nativeVideoRef = ref(null)
const {
handleVideoJsMounted,
retryLoad,
showErrorOverlay,
errorMessage,
canRetry,
retryCount
} = useVideoPlayer(
props,
emit,
videoRef,
nativeVideoRef
)
const listeners = {}
let current_error = { code: 4, message: 'No compatible source was found for this media.' }
const player = {
isDisposed: () => false,
on: (eventName, callback) => {
listeners[eventName] = callback
},
tech: () => ({}),
hlsQualitySelector: vi.fn(),
qualityLevels: vi.fn(() => ({ on: vi.fn() })),
error: vi.fn(function (value) {
if (arguments.length) {
current_error = value
return null
}
return current_error
}),
src: vi.fn(),
load: vi.fn(),
pause: vi.fn(),
play: vi.fn(() => Promise.resolve())
}
handleVideoJsMounted({ state: {}, player })
listeners.error()
expect(showErrorOverlay.value).toBe(true)
expect(errorMessage.value).toBe('视频格式不支持或无法加载,请检查网络连接')
expect(canRetry.value).toBe(true)
retryLoad()
current_error = { code: 4, message: 'No compatible source was found for this media.' }
vi.advanceTimersByTime(900)
expect(showErrorOverlay.value).toBe(true)
expect(errorMessage.value).toBe('视频格式不支持或无法加载,请检查网络连接')
expect(retryCount.value).toBe(1)
expect(canRetry.value).toBe(true)
retryLoad()
current_error = { code: 4, message: 'No compatible source was found for this media.' }
vi.advanceTimersByTime(900)
expect(showErrorOverlay.value).toBe(true)
expect(retryCount.value).toBe(2)
expect(canRetry.value).toBe(true)
retryLoad()
current_error = { code: 4, message: 'No compatible source was found for this media.' }
vi.advanceTimersByTime(900)
expect(showErrorOverlay.value).toBe(true)
expect(retryCount.value).toBe(3)
expect(canRetry.value).toBe(false)
})
})
......
......@@ -50,6 +50,7 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
const errorMessage = ref('');
const retryCount = ref(0);
const maxRetries = 3;
const canRetry = computed(() => retryCount.value < maxRetries);
const hasEverPlayed = ref(false);
const hasStartedPlayback = ref(false);
......@@ -57,6 +58,7 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
// 原生播放器状态
const nativeReady = ref(false);
let nativeListeners = null;
let retry_error_check_timer = null;
// 1. 环境判断与播放器选择
const useNativePlayer = computed(() => {
......@@ -144,9 +146,9 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
case 4: // MEDIA_ERR_SRC_NOT_SUPPORTED
errorMessage.value = '视频格式不支持或无法加载,请检查网络连接' + getErrorHint();
// 旧机型/弱网下可能出现短暂的“无法加载”,这里做有限次数重试
if (retryCount.value < maxRetries) {
setTimeout(retryLoad, 1000);
}
// if (retryCount.value < maxRetries) {
// setTimeout(retryLoad, 1000);
// }
break;
case 3: // MEDIA_ERR_DECODE
errorMessage.value = '视频解码失败,可能是文件损坏';
......@@ -293,7 +295,6 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
withCredentials: false
}
},
errorDisplay: true,
techOrder: ['html5'],
userActions: {
hotkeys: true,
......@@ -309,6 +310,7 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
},
},
...props.options,
errorDisplay: false,
}));
// 8. Video.js 挂载处理
......@@ -422,8 +424,8 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
// 6. 重试逻辑
const retryLoad = () => {
if (retryCount.value >= maxRetries) {
errorMessage.value = '重试次数已达上限,请稍后再试';
if (!canRetry.value) {
showErrorOverlay.value = true;
return;
}
......@@ -432,6 +434,11 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
hideNetworkSpeed();
stopHlsDownloadSpeed('retry');
if (retry_error_check_timer) {
clearTimeout(retry_error_check_timer);
retry_error_check_timer = null;
}
if (useNativePlayer.value) {
// 原生 video 需要手动重置 src/load
const videoEl = nativeVideoRef.value;
......@@ -444,11 +451,50 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
videoEl.src = currentSrc || videoUrlValue.value;
videoEl.load();
tryNativePlay();
retry_error_check_timer = setTimeout(() => {
const err_code = videoEl?.error?.code;
if (err_code) {
handleError(err_code);
}
}, 800);
}
} else {
// video.js 走自身 load 刷新
if (player.value && !player.value.isDisposed()) {
player.value.load();
const p = player.value;
try {
p.pause?.();
} catch (e) {
void e;
}
try {
p.error?.(null);
} catch (e) {
void e;
}
try {
p.src?.(videoSources.value);
} catch (e) {
void e;
}
try {
p.load?.();
} catch (e) {
void e;
}
try {
p.play?.()?.catch?.(() => {});
} catch (e) {
void e;
}
retry_error_check_timer = setTimeout(() => {
const err = p?.error?.();
if (err?.code) {
handleError(err.code, err.message);
}
}, 800);
}
}
};
......@@ -479,6 +525,11 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
});
onBeforeUnmount(() => {
if (retry_error_check_timer) {
clearTimeout(retry_error_check_timer);
retry_error_check_timer = null;
}
if (nativeListeners?.videoEl) {
nativeListeners.videoEl.removeEventListener("loadstart", nativeListeners.onLoadStart);
nativeListeners.videoEl.removeEventListener("canplay", nativeListeners.onCanPlay);
......@@ -504,6 +555,9 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
videoOptions,
showErrorOverlay,
errorMessage,
canRetry,
retryCount,
maxRetries,
showNetworkSpeedOverlay,
networkSpeedText,
hlsDownloadSpeedText,
......