hookehuyr

feat(video-player): 添加移动网络流量消耗警告功能

当检测到用户使用移动网络且视频文件较大时,显示流量消耗警告弹窗
新增网络类型检测功能,支持微信内置浏览器环境
重构播放逻辑,统一通过requestPlay处理播放请求
......@@ -38,6 +38,17 @@
</div>
</div>
<div v-if="trafficWarnVisible && !showErrorOverlay" class="traffic-overlay">
<div class="traffic-content">
<div class="traffic-title">当前为移动网络</div>
<div class="traffic-message">{{ `视频大小约:${trafficFileSizeText},注意流量消耗` }}</div>
<div class="traffic-actions">
<button class="traffic-cancel" @click="cancelTrafficWarn">取消</button>
<button class="traffic-confirm" @click="confirmTrafficWarn">继续播放</button>
</div>
</div>
</div>
<!-- 错误提示覆盖层 -->
<div v-if="showErrorOverlay" class="error-overlay">
<div class="error-content">
......@@ -99,9 +110,13 @@ const {
errorMessage,
showNetworkSpeedOverlay,
networkSpeedText,
trafficWarnVisible,
trafficFileSizeText,
confirmTrafficWarn,
cancelTrafficWarn,
requestPlay,
retryLoad,
handleVideoJsMounted,
tryNativePlay
} = useVideoPlayer(props, emit, videoRef, nativeVideoRef);
// 事件处理
......@@ -134,10 +149,10 @@ defineExpose({
},
play() {
if (useNativePlayer.value) {
tryNativePlay();
void requestPlay(false);
return;
}
player.value?.play()?.catch(console.warn);
void requestPlay(false);
},
getPlayer() {
return useNativePlayer.value ? nativeVideoRef.value : player.value;
......@@ -233,6 +248,70 @@ defineExpose({
opacity: 0.95;
}
.traffic-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
z-index: 950;
pointer-events: auto;
}
.traffic-content {
padding: 16px 18px;
border-radius: 12px;
background: rgba(0, 0, 0, 0.75);
backdrop-filter: blur(12px);
color: #fff;
text-align: center;
max-width: 86%;
}
.traffic-title {
font-size: 16px;
font-weight: 600;
line-height: 1.2;
margin-bottom: 8px;
}
.traffic-message {
font-size: 14px;
line-height: 1.4;
opacity: 0.95;
margin-bottom: 14px;
}
.traffic-actions {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
}
.traffic-cancel,
.traffic-confirm {
border: none;
padding: 10px 14px;
border-radius: 10px;
cursor: pointer;
font-size: 14px;
line-height: 1;
}
.traffic-cancel {
background: rgba(255, 255, 255, 0.15);
color: rgba(255, 255, 255, 0.95);
}
.traffic-confirm {
background: #007bff;
color: #fff;
}
.retry-button {
background: #007bff;
color: white;
......
......@@ -34,6 +34,11 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
const hasEverPlayed = ref(false);
let networkSpeedTimer = null;
const networkKind = ref('unknown');
const trafficWarnVisible = ref(false);
const trafficWarnAcknowledged = ref(false);
let trafficWarnDuringAutoplay = false;
// 原生播放器状态
const nativeReady = ref(false);
let nativeListeners = null;
......@@ -100,6 +105,12 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
return String(size) + "B";
};
const trafficFileSizeText = computed(() => {
const len = probeInfo.value.content_length;
if (!len) return "未知";
return formatBytes(len) || "未知";
});
const getErrorHint = () => {
if (probeInfo.value.status === 403) return "(403:无权限或已过期)";
if (probeInfo.value.status === 404) return "(404:资源不存在)";
......@@ -113,6 +124,52 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
return "";
};
const detectNetworkKind = async () => {
if (typeof navigator === 'undefined') {
networkKind.value = 'unknown';
return;
}
const connection = navigator.connection || navigator.mozConnection || navigator.webkitConnection;
const connectionType = connection && typeof connection.type === 'string' ? connection.type : '';
if (connectionType) {
if (connectionType === 'wifi') {
networkKind.value = 'wifi';
return;
}
if (connectionType === 'cellular') {
networkKind.value = 'cellular';
return;
}
}
const effectiveType = connection && typeof connection.effectiveType === 'string' ? connection.effectiveType : '';
if (effectiveType) {
if (effectiveType === '4g' || effectiveType === '3g' || effectiveType === '2g' || effectiveType === 'slow-2g') {
networkKind.value = 'cellular';
return;
}
}
if (typeof window !== 'undefined' && window.WeixinJSBridge) {
await new Promise((resolve) => {
try {
window.WeixinJSBridge.invoke('getNetworkType', {}, (res) => {
const wxType = (res && (res.networkType || res.err_msg || '')).toString().toLowerCase();
if (wxType.includes('wifi')) networkKind.value = 'wifi';
else if (wxType.includes('2g') || wxType.includes('3g') || wxType.includes('4g') || wxType.includes('5g')) networkKind.value = 'cellular';
resolve();
});
} catch (e) {
resolve();
}
});
return;
}
networkKind.value = 'unknown';
};
// 资源探测
const probeVideo = async () => {
const url = videoUrlValue.value;
......@@ -182,6 +239,7 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
const handleError = (code, message = '') => {
showErrorOverlay.value = true;
showNetworkSpeedOverlay.value = false;
trafficWarnVisible.value = false;
if (networkSpeedTimer) {
clearInterval(networkSpeedTimer);
networkSpeedTimer = null;
......@@ -230,6 +288,7 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
const showNetworkSpeed = () => {
if (!hasEverPlayed.value) return;
if (showErrorOverlay.value) return;
if (trafficWarnVisible.value) return;
if (showNetworkSpeedOverlay.value) return;
showNetworkSpeedOverlay.value = true;
......@@ -249,6 +308,62 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
}
};
const isOnWifi = computed(() => networkKind.value === 'wifi');
const isOnCellular = computed(() => networkKind.value === 'cellular');
const trafficWarnThresholdBytes = 200 * 1024 * 1024;
const needTrafficWarn = computed(() => {
if (isOnWifi.value) return false;
if (!isOnCellular.value) return false;
const len = probeInfo.value.content_length;
if (!len) return false;
return len >= trafficWarnThresholdBytes;
});
const openTrafficWarn = async (fromAutoplay) => {
if (trafficWarnAcknowledged.value) return false;
trafficWarnDuringAutoplay = Boolean(fromAutoplay);
if (!probeInfo.value.content_length && !probeLoading.value) {
await probeVideo();
}
if (!needTrafficWarn.value) return false;
trafficWarnVisible.value = true;
hideNetworkSpeed();
return true;
};
const closeTrafficWarn = () => {
trafficWarnVisible.value = false;
trafficWarnDuringAutoplay = false;
};
const requestPlay = async (fromAutoplay) => {
const blocked = await openTrafficWarn(fromAutoplay);
if (blocked) return;
if (useNativePlayer.value) {
tryNativePlay();
return;
}
if (player.value && !player.value.isDisposed()) {
player.value.play().catch(console.warn);
}
};
const confirmTrafficWarn = () => {
trafficWarnAcknowledged.value = true;
closeTrafficWarn();
void requestPlay(trafficWarnDuringAutoplay);
};
const cancelTrafficWarn = () => {
closeTrafficWarn();
};
// 4. 原生播放器逻辑 (iOS微信)
const initNativePlayer = () => {
const videoEl = nativeVideoRef.value;
......@@ -278,6 +393,28 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
};
const onPlay = () => {
if (!trafficWarnAcknowledged.value) {
if (!probeInfo.value.content_length && isOnCellular.value) {
try {
videoEl.pause();
} catch (e) {
void e;
}
void requestPlay(false);
return;
}
if (needTrafficWarn.value) {
try {
videoEl.pause();
} catch (e) {
void e;
}
trafficWarnVisible.value = true;
hideNetworkSpeed();
return;
}
}
hasEverPlayed.value = true;
hideNetworkSpeed();
};
......@@ -322,11 +459,11 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
};
if (props.autoplay) {
tryNativePlay();
void requestPlay(true);
if (typeof document !== "undefined") {
document.addEventListener(
"WeixinJSBridgeReady",
() => tryNativePlay(),
() => void requestPlay(true),
{ once: true }
);
}
......@@ -415,6 +552,20 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
});
player.value.on('play', () => {
if (!trafficWarnAcknowledged.value) {
if (!probeInfo.value.content_length && isOnCellular.value) {
player.value.pause();
void requestPlay(false);
return;
}
if (needTrafficWarn.value) {
player.value.pause();
trafficWarnVisible.value = true;
hideNetworkSpeed();
return;
}
}
hasEverPlayed.value = true;
hideNetworkSpeed();
});
......@@ -440,7 +591,7 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
});
if (props.autoplay) {
player.value.play().catch(console.warn);
void requestPlay(true);
}
}
};
......@@ -455,6 +606,7 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
retryCount.value++;
showErrorOverlay.value = false;
hideNetworkSpeed();
closeTrafficWarn();
if (useNativePlayer.value) {
const videoEl = nativeVideoRef.value;
......@@ -481,6 +633,8 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
showErrorOverlay.value = false;
hideNetworkSpeed();
hasEverPlayed.value = false;
trafficWarnAcknowledged.value = false;
closeTrafficWarn();
void probeVideo();
// 如果是原生播放器且 URL 变化,需要手动处理 HLS (如果是非 iOS Safari 环境)
......@@ -491,6 +645,7 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
});
onMounted(() => {
void detectNetworkKind();
void probeVideo();
if (useNativePlayer.value) {
initNativePlayer();
......@@ -514,6 +669,7 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
}
hideNetworkSpeed();
closeTrafficWarn();
if (videoRef.value?.$player) {
videoRef.value.$player.dispose();
}
......@@ -529,6 +685,13 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
errorMessage,
showNetworkSpeedOverlay,
networkSpeedText,
trafficWarnVisible,
trafficFileSizeText,
isOnWifi,
isOnCellular,
confirmTrafficWarn,
cancelTrafficWarn,
requestPlay,
retryLoad,
handleVideoJsMounted,
tryNativePlay
......