hookehuyr

feat(video-player): 添加HLS下载速度显示和调试功能

- 在视频播放器组件中添加HLS下载速度显示和调试信息展示
- 新增debug模式用于显示详细调试信息
- 优化网络状态检测逻辑,增加微信环境下的网络类型检测
- 添加相关测试用例验证功能
......@@ -33,9 +33,21 @@
<div v-if="showNetworkSpeedOverlay && !showErrorOverlay" class="speed-overlay">
<div class="speed-content">
<div class="speed-title">网络较慢</div>
<div class="speed-value">{{ `当前网速:${networkSpeedText}` }}</div>
<div
class="speed-title"
:class="{ 'speed-title-no-detail': !(networkSpeedText && networkSpeedText !== '未知') }"
>
网络较慢
</div>
<div v-if="networkSpeedText && networkSpeedText !== '未知'" class="speed-value">
{{ networkSpeedText.includes("Mbps") ? `当前网速:${networkSpeedText}` : `当前网络:${networkSpeedText}` }}
</div>
</div>
</div>
<div v-if="hlsDownloadSpeedText" class="hls-speed-badge">{{ hlsDownloadSpeedText }}</div>
<div v-if="props.debug" class="hls-debug-badge">
{{ hlsSpeedDebugText || "debug:empty" }}
</div>
<!-- 错误提示覆盖层 -->
......@@ -74,6 +86,10 @@ const props = defineProps({
required: false,
default: true,
},
debug: {
type: Boolean,
default: false
},
/**
* iOS 环境下是否强制使用原生播放器
* 默认为 true (使用原生),设为 false 则尝试使用 Video.js
......@@ -99,6 +115,8 @@ const {
errorMessage,
showNetworkSpeedOverlay,
networkSpeedText,
hlsDownloadSpeedText,
hlsSpeedDebugText,
retryLoad,
handleVideoJsMounted,
tryNativePlay
......@@ -118,7 +136,6 @@ defineExpose({
nativeVideoRef.value?.pause?.();
emit('onPause', nativeVideoRef.value);
} catch (e) {
console.warn('Video pause error:', e);
}
return;
}
......@@ -128,7 +145,6 @@ defineExpose({
player.value.pause();
emit('onPause', player.value);
} catch (e) {
console.warn('Video pause error:', e);
}
}
},
......@@ -137,7 +153,7 @@ defineExpose({
tryNativePlay();
return;
}
player.value?.play()?.catch(console.warn);
player.value?.play()?.catch(() => {});
},
getPlayer() {
return useNativePlayer.value ? nativeVideoRef.value : player.value;
......@@ -227,12 +243,47 @@ defineExpose({
margin-bottom: 8px;
}
.speed-title-no-detail {
margin-bottom: 0;
}
.speed-value {
font-size: 14px;
line-height: 1.4;
opacity: 0.95;
}
.hls-speed-badge {
position: absolute;
right: 10px;
bottom: 10px;
z-index: 850;
pointer-events: none;
padding: 4px 8px;
border-radius: 8px;
background: rgba(0, 0, 0, 0.5);
color: #fff;
font-size: 12px;
line-height: 1.2;
}
.hls-debug-badge {
position: absolute;
left: 10px;
top: 10px;
z-index: 1200;
pointer-events: none;
padding: 4px 8px;
border-radius: 8px;
background: rgba(0, 0, 0, 0.45);
color: #fff;
font-size: 11px;
line-height: 1.2;
max-width: 90%;
white-space: pre-wrap;
word-break: break-all;
}
.retry-button {
background: #007bff;
color: white;
......
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
import { ref } from 'vue'
vi.mock('@/utils/tools', () => ({
wxInfo: () => ({
isIOSWeChat: false
})
}))
vi.mock('hls.js', () => ({
default: {
isSupported: () => false
}
}))
vi.mock('video.js', () => ({
default: {
browser: {
IS_SAFARI: false
}
}
}))
vi.mock('videojs-contrib-quality-levels', () => ({}))
vi.mock('videojs-hls-quality-selector', () => ({}))
vi.mock('videojs-hls-quality-selector/dist/videojs-hls-quality-selector.css', () => ({}))
import { useVideoPlayer } from '../useVideoPlayer'
const createPlayerStub = ({ bandwidthBitsPerSecond, bandwidthOn = 'vhs' } = {}) => {
const listeners = {}
const techObject = bandwidthOn === 'hls'
? { hls: { bandwidth: bandwidthBitsPerSecond } }
: { vhs: { bandwidth: bandwidthBitsPerSecond } }
const player = {
isDisposed: () => false,
on: (eventName, callback) => {
listeners[eventName] = callback
},
tech: () => techObject,
hlsQualitySelector: vi.fn(),
error: () => null,
load: vi.fn(),
pause: vi.fn(),
play: vi.fn(() => Promise.resolve())
}
return { player, listeners }
}
describe('useVideoPlayer HLS 下载速度调试', () => {
let console_warn_spy
beforeEach(() => {
console_warn_spy = vi.spyOn(console, 'warn').mockImplementation(() => {})
vi.useFakeTimers()
})
afterEach(() => {
vi.clearAllTimers()
vi.useRealTimers()
console_warn_spy?.mockRestore?.()
})
it('m3u8 + 非原生模式下能从 vhs.bandwidth 计算速度', () => {
const props = {
options: {},
videoUrl: 'https://example.com/test.m3u8',
videoId: 'v1',
autoplay: false,
debug: true,
useNativeOnIos: true
}
const emit = vi.fn()
const videoRef = ref(null)
const nativeVideoRef = ref(null)
const { handleVideoJsMounted, hlsDownloadSpeedText, hlsSpeedDebugText } = useVideoPlayer(
props,
emit,
videoRef,
nativeVideoRef
)
const { player, listeners } = createPlayerStub({ bandwidthBitsPerSecond: 8 * 1024 * 1024, bandwidthOn: 'vhs' })
handleVideoJsMounted({ state: {}, player })
listeners.play()
vi.advanceTimersByTime(1100)
expect(hlsDownloadSpeedText.value).toBe('1.0MB/s')
expect(hlsSpeedDebugText.value).toContain('update')
expect(hlsSpeedDebugText.value).toContain('mode:videojs')
expect(hlsSpeedDebugText.value).toContain('m3u8:1')
})
it('m3u8 + 非原生模式下能从 hls.bandwidth 兜底计算速度', () => {
const props = {
options: {},
videoUrl: 'https://example.com/test.m3u8',
videoId: 'v1',
autoplay: false,
debug: true,
useNativeOnIos: true
}
const emit = vi.fn()
const videoRef = ref(null)
const nativeVideoRef = ref(null)
const { handleVideoJsMounted, hlsDownloadSpeedText, hlsSpeedDebugText } = useVideoPlayer(
props,
emit,
videoRef,
nativeVideoRef
)
const { player, listeners } = createPlayerStub({ bandwidthBitsPerSecond: 4 * 1024 * 1024, bandwidthOn: 'hls' })
handleVideoJsMounted({ state: {}, player })
listeners.play()
expect(hlsDownloadSpeedText.value).toBe('512kB/s')
expect(hlsSpeedDebugText.value).toContain('hls_bw:')
})
it('playing 事件的 debug 文本包含 tech 与模式信息', () => {
const props = {
options: {},
videoUrl: 'https://example.com/test.m3u8',
videoId: 'v1',
autoplay: false,
debug: true,
useNativeOnIos: true
}
const emit = vi.fn()
const videoRef = ref(null)
const nativeVideoRef = ref(null)
const { handleVideoJsMounted, hlsSpeedDebugText } = useVideoPlayer(
props,
emit,
videoRef,
nativeVideoRef
)
const { player, listeners } = createPlayerStub({ bandwidthBitsPerSecond: 8 * 1024 * 1024, bandwidthOn: 'vhs' })
handleVideoJsMounted({ state: {}, player })
listeners.playing()
expect(hlsSpeedDebugText.value).toContain('playing')
expect(hlsSpeedDebugText.value).toContain('mode:videojs')
expect(hlsSpeedDebugText.value).toContain('tech:')
})
})
......@@ -33,6 +33,36 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
const networkSpeedText = ref('');
const hasEverPlayed = ref(false);
let networkSpeedTimer = null;
let lastWeixinNetworkTypeAt = 0;
const hlsDownloadSpeedText = ref('');
let hlsSpeedTimer = null;
const hasStartedPlayback = ref(false);
const hlsSpeedDebugText = ref('');
const setHlsDebug = (eventName, extra) => {
if (!props || props.debug !== true) return;
const p = player.value;
const hasPlayer = !!p && !p.isDisposed?.();
let tech = null;
try {
tech = hasPlayer && typeof p.tech === 'function' ? p.tech(true) : null;
} catch (e) {
tech = null;
}
const stableTech = tech || (hasPlayer ? p.tech_ : null);
const techName = hasPlayer
? (p.techName_ || (stableTech && stableTech.name_) || (stableTech && stableTech.constructor && stableTech.constructor.name) || 'unknown')
: 'none';
const mode = useNativePlayer.value ? 'native' : 'videojs';
const vhs = stableTech && stableTech.vhs ? stableTech.vhs : null;
const hls = stableTech && stableTech.hls ? stableTech.hls : null;
const bwKbps = vhs && typeof vhs.bandwidth === 'number' ? Math.round(vhs.bandwidth / 1000) : 0;
const hlsBwKbps = hls && typeof hls.bandwidth === 'number' ? Math.round(hls.bandwidth / 1000) : 0;
const extraText = extra ? ` ${extra}` : '';
hlsSpeedDebugText.value = `${eventName}${extraText}\nmode:${mode} m3u8:${isM3U8.value ? '1' : '0'} native:${useNativePlayer.value ? '1' : '0'}\ntech:${techName} vhs:${vhs ? '1' : '0'} bw:${bwKbps}kbps hls_bw:${hlsBwKbps}kbps`;
};
// 原生播放器状态
const nativeReady = ref(false);
......@@ -213,31 +243,48 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
const updateNetworkSpeed = () => {
if (typeof navigator === 'undefined') {
networkSpeedText.value = '未知';
return;
return false;
}
const connection = navigator.connection || navigator.mozConnection || navigator.webkitConnection;
const downlink = connection && typeof connection.downlink === 'number' ? connection.downlink : null;
if (downlink && downlink > 0) {
networkSpeedText.value = `${downlink.toFixed(1)} Mbps`;
return;
return true;
}
const effectiveType = connection && typeof connection.effectiveType === 'string' ? connection.effectiveType : '';
networkSpeedText.value = effectiveType ? `${effectiveType}` : '未知';
return effectiveType ? true : false;
};
const updateWeixinNetworkType = () => {
if (typeof window === 'undefined') return;
if (!window.WeixinJSBridge || typeof window.WeixinJSBridge.invoke !== 'function') return;
const now = Date.now();
if (now - lastWeixinNetworkTypeAt < 3000) return;
lastWeixinNetworkTypeAt = now;
window.WeixinJSBridge.invoke('getNetworkType', {}, (res) => {
const type = (res && (res.networkType || res.network_type)) ? String(res.networkType || res.network_type) : '';
if (type) networkSpeedText.value = type;
});
};
const showNetworkSpeed = () => {
if (!hasEverPlayed.value) return;
if (!hasStartedPlayback.value) return;
if (showErrorOverlay.value) return;
if (showNetworkSpeedOverlay.value) return;
showNetworkSpeedOverlay.value = true;
updateNetworkSpeed();
const ok = updateNetworkSpeed();
if (!ok) updateWeixinNetworkType();
if (networkSpeedTimer) clearInterval(networkSpeedTimer);
networkSpeedTimer = setInterval(() => {
updateNetworkSpeed();
const ok2 = updateNetworkSpeed();
if (!ok2) updateWeixinNetworkType();
}, 800);
};
......@@ -249,11 +296,77 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
}
};
const formatSpeed = (bytesPerSecond) => {
const size = Number(bytesPerSecond) || 0;
if (!size) return '';
const kb = 1024;
const mb = kb * 1024;
if (size >= mb) return `${(size / mb).toFixed(1)}MB/s`;
if (size >= kb) return `${Math.round(size / kb)}kB/s`;
return `${Math.round(size)}B/s`;
};
const updateHlsDownloadSpeed = () => {
if (!player.value || player.value.isDisposed()) {
hlsDownloadSpeedText.value = '';
setHlsDebug('update', 'player:empty');
return;
}
if (!isM3U8.value || useNativePlayer.value) {
hlsDownloadSpeedText.value = '';
setHlsDebug('update', 'skip');
return;
}
let tech = null;
try {
tech = typeof player.value.tech === 'function' ? player.value.tech(true) : null;
} catch (e) {
tech = null;
}
const stableTech = tech || player.value.tech_ || null;
const vhs = stableTech && stableTech.vhs ? stableTech.vhs : null;
const hls = stableTech && stableTech.hls ? stableTech.hls : null;
const bandwidthBitsPerSecond = (vhs && typeof vhs.bandwidth === 'number' ? vhs.bandwidth : null)
|| (hls && typeof hls.bandwidth === 'number' ? hls.bandwidth : null);
if (!bandwidthBitsPerSecond || bandwidthBitsPerSecond <= 0) {
hlsDownloadSpeedText.value = '';
setHlsDebug('update', 'bw:0');
return;
}
hlsDownloadSpeedText.value = formatSpeed(bandwidthBitsPerSecond / 8);
setHlsDebug('update', `speed:${hlsDownloadSpeedText.value}`);
};
const startHlsDownloadSpeed = () => {
if (hlsSpeedTimer) return;
if (!isM3U8.value || useNativePlayer.value) return;
setHlsDebug('start');
updateHlsDownloadSpeed();
hlsSpeedTimer = setInterval(() => {
updateHlsDownloadSpeed();
}, 1000);
};
const stopHlsDownloadSpeed = (reason) => {
if (hlsSpeedTimer) {
clearInterval(hlsSpeedTimer);
hlsSpeedTimer = null;
}
hlsDownloadSpeedText.value = '';
setHlsDebug('stop', reason || '');
};
// 4. 原生播放器逻辑 (iOS微信)
const initNativePlayer = () => {
const videoEl = nativeVideoRef.value;
if (!videoEl) return;
setHlsDebug('native:init');
// HLS 处理
if (isM3U8.value && Hls.isSupported()) {
// 如果原生支持 HLS (iOS Safari),直接用 src 即可,不需要 hls.js
......@@ -278,8 +391,8 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
};
const onPlay = () => {
hasEverPlayed.value = true;
hideNetworkSpeed();
setHlsDebug('native:play');
};
const onPause = () => {
......@@ -289,15 +402,20 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
const onWaiting = () => {
if (videoEl.paused) return;
showNetworkSpeed();
setHlsDebug('native:waiting');
};
const onStalled = () => {
if (videoEl.paused) return;
showNetworkSpeed();
setHlsDebug('native:stalled');
};
const onPlaying = () => {
hasEverPlayed.value = true;
hasStartedPlayback.value = true;
hideNetworkSpeed();
setHlsDebug('native:playing');
};
videoEl.addEventListener("loadstart", onLoadStart);
......@@ -393,6 +511,8 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
player.value = payload.player;
if (player.value) {
setHlsDebug('mounted');
// 初始化多码率切换插件 (七牛云多码率支持)
if (player.value.hlsQualitySelector) {
player.value.hlsQualitySelector({
......@@ -415,32 +535,47 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
});
player.value.on('play', () => {
hasEverPlayed.value = true;
hideNetworkSpeed();
startHlsDownloadSpeed();
setHlsDebug('play');
});
player.value.on('pause', () => {
hideNetworkSpeed();
stopHlsDownloadSpeed('pause');
setHlsDebug('pause');
});
player.value.on('waiting', () => {
if (!hasEverPlayed.value) return;
if (player.value?.paused?.()) return;
showNetworkSpeed();
startHlsDownloadSpeed();
setHlsDebug('waiting');
});
player.value.on('stalled', () => {
if (!hasEverPlayed.value) return;
if (player.value?.paused?.()) return;
showNetworkSpeed();
startHlsDownloadSpeed();
setHlsDebug('stalled');
});
player.value.on('playing', () => {
hasEverPlayed.value = true;
hasStartedPlayback.value = true;
hideNetworkSpeed();
setHlsDebug('playing');
});
player.value.on('ended', () => {
stopHlsDownloadSpeed('ended');
setHlsDebug('ended');
});
if (props.autoplay) {
player.value.play().catch(console.warn);
player.value.play().catch(() => {});
}
}
};
......@@ -455,6 +590,7 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
retryCount.value++;
showErrorOverlay.value = false;
hideNetworkSpeed();
stopHlsDownloadSpeed('retry');
if (useNativePlayer.value) {
const videoEl = nativeVideoRef.value;
......@@ -480,7 +616,9 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
retryCount.value = 0;
showErrorOverlay.value = false;
hideNetworkSpeed();
stopHlsDownloadSpeed('url_change');
hasEverPlayed.value = false;
hasStartedPlayback.value = false;
void probeVideo();
// 如果是原生播放器且 URL 变化,需要手动处理 HLS (如果是非 iOS Safari 环境)
......@@ -514,6 +652,7 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
}
hideNetworkSpeed();
stopHlsDownloadSpeed('unmount');
if (videoRef.value?.$player) {
videoRef.value.$player.dispose();
}
......@@ -529,6 +668,8 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
errorMessage,
showNetworkSpeedOverlay,
networkSpeedText,
hlsDownloadSpeedText,
hlsSpeedDebugText,
retryLoad,
handleVideoJsMounted,
tryNativePlay
......