hookehuyr

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

当检测到用户使用移动网络且视频文件较大时,显示流量消耗警告弹窗
新增网络类型检测功能,支持微信内置浏览器环境
重构播放逻辑,统一通过requestPlay处理播放请求
...@@ -38,6 +38,17 @@ ...@@ -38,6 +38,17 @@
38 </div> 38 </div>
39 </div> 39 </div>
40 40
41 + <div v-if="trafficWarnVisible && !showErrorOverlay" class="traffic-overlay">
42 + <div class="traffic-content">
43 + <div class="traffic-title">当前为移动网络</div>
44 + <div class="traffic-message">{{ `视频大小约:${trafficFileSizeText},注意流量消耗` }}</div>
45 + <div class="traffic-actions">
46 + <button class="traffic-cancel" @click="cancelTrafficWarn">取消</button>
47 + <button class="traffic-confirm" @click="confirmTrafficWarn">继续播放</button>
48 + </div>
49 + </div>
50 + </div>
51 +
41 <!-- 错误提示覆盖层 --> 52 <!-- 错误提示覆盖层 -->
42 <div v-if="showErrorOverlay" class="error-overlay"> 53 <div v-if="showErrorOverlay" class="error-overlay">
43 <div class="error-content"> 54 <div class="error-content">
...@@ -99,9 +110,13 @@ const { ...@@ -99,9 +110,13 @@ const {
99 errorMessage, 110 errorMessage,
100 showNetworkSpeedOverlay, 111 showNetworkSpeedOverlay,
101 networkSpeedText, 112 networkSpeedText,
113 + trafficWarnVisible,
114 + trafficFileSizeText,
115 + confirmTrafficWarn,
116 + cancelTrafficWarn,
117 + requestPlay,
102 retryLoad, 118 retryLoad,
103 handleVideoJsMounted, 119 handleVideoJsMounted,
104 - tryNativePlay
105 } = useVideoPlayer(props, emit, videoRef, nativeVideoRef); 120 } = useVideoPlayer(props, emit, videoRef, nativeVideoRef);
106 121
107 // 事件处理 122 // 事件处理
...@@ -134,10 +149,10 @@ defineExpose({ ...@@ -134,10 +149,10 @@ defineExpose({
134 }, 149 },
135 play() { 150 play() {
136 if (useNativePlayer.value) { 151 if (useNativePlayer.value) {
137 - tryNativePlay(); 152 + void requestPlay(false);
138 return; 153 return;
139 } 154 }
140 - player.value?.play()?.catch(console.warn); 155 + void requestPlay(false);
141 }, 156 },
142 getPlayer() { 157 getPlayer() {
143 return useNativePlayer.value ? nativeVideoRef.value : player.value; 158 return useNativePlayer.value ? nativeVideoRef.value : player.value;
...@@ -233,6 +248,70 @@ defineExpose({ ...@@ -233,6 +248,70 @@ defineExpose({
233 opacity: 0.95; 248 opacity: 0.95;
234 } 249 }
235 250
251 +.traffic-overlay {
252 + position: absolute;
253 + top: 0;
254 + left: 0;
255 + right: 0;
256 + bottom: 0;
257 + display: flex;
258 + align-items: center;
259 + justify-content: center;
260 + z-index: 950;
261 + pointer-events: auto;
262 +}
263 +
264 +.traffic-content {
265 + padding: 16px 18px;
266 + border-radius: 12px;
267 + background: rgba(0, 0, 0, 0.75);
268 + backdrop-filter: blur(12px);
269 + color: #fff;
270 + text-align: center;
271 + max-width: 86%;
272 +}
273 +
274 +.traffic-title {
275 + font-size: 16px;
276 + font-weight: 600;
277 + line-height: 1.2;
278 + margin-bottom: 8px;
279 +}
280 +
281 +.traffic-message {
282 + font-size: 14px;
283 + line-height: 1.4;
284 + opacity: 0.95;
285 + margin-bottom: 14px;
286 +}
287 +
288 +.traffic-actions {
289 + display: flex;
290 + align-items: center;
291 + justify-content: center;
292 + gap: 12px;
293 +}
294 +
295 +.traffic-cancel,
296 +.traffic-confirm {
297 + border: none;
298 + padding: 10px 14px;
299 + border-radius: 10px;
300 + cursor: pointer;
301 + font-size: 14px;
302 + line-height: 1;
303 +}
304 +
305 +.traffic-cancel {
306 + background: rgba(255, 255, 255, 0.15);
307 + color: rgba(255, 255, 255, 0.95);
308 +}
309 +
310 +.traffic-confirm {
311 + background: #007bff;
312 + color: #fff;
313 +}
314 +
236 .retry-button { 315 .retry-button {
237 background: #007bff; 316 background: #007bff;
238 color: white; 317 color: white;
......
...@@ -34,6 +34,11 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) { ...@@ -34,6 +34,11 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
34 const hasEverPlayed = ref(false); 34 const hasEverPlayed = ref(false);
35 let networkSpeedTimer = null; 35 let networkSpeedTimer = null;
36 36
37 + const networkKind = ref('unknown');
38 + const trafficWarnVisible = ref(false);
39 + const trafficWarnAcknowledged = ref(false);
40 + let trafficWarnDuringAutoplay = false;
41 +
37 // 原生播放器状态 42 // 原生播放器状态
38 const nativeReady = ref(false); 43 const nativeReady = ref(false);
39 let nativeListeners = null; 44 let nativeListeners = null;
...@@ -100,6 +105,12 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) { ...@@ -100,6 +105,12 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
100 return String(size) + "B"; 105 return String(size) + "B";
101 }; 106 };
102 107
108 + const trafficFileSizeText = computed(() => {
109 + const len = probeInfo.value.content_length;
110 + if (!len) return "未知";
111 + return formatBytes(len) || "未知";
112 + });
113 +
103 const getErrorHint = () => { 114 const getErrorHint = () => {
104 if (probeInfo.value.status === 403) return "(403:无权限或已过期)"; 115 if (probeInfo.value.status === 403) return "(403:无权限或已过期)";
105 if (probeInfo.value.status === 404) return "(404:资源不存在)"; 116 if (probeInfo.value.status === 404) return "(404:资源不存在)";
...@@ -113,6 +124,52 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) { ...@@ -113,6 +124,52 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
113 return ""; 124 return "";
114 }; 125 };
115 126
127 + const detectNetworkKind = async () => {
128 + if (typeof navigator === 'undefined') {
129 + networkKind.value = 'unknown';
130 + return;
131 + }
132 +
133 + const connection = navigator.connection || navigator.mozConnection || navigator.webkitConnection;
134 + const connectionType = connection && typeof connection.type === 'string' ? connection.type : '';
135 + if (connectionType) {
136 + if (connectionType === 'wifi') {
137 + networkKind.value = 'wifi';
138 + return;
139 + }
140 + if (connectionType === 'cellular') {
141 + networkKind.value = 'cellular';
142 + return;
143 + }
144 + }
145 +
146 + const effectiveType = connection && typeof connection.effectiveType === 'string' ? connection.effectiveType : '';
147 + if (effectiveType) {
148 + if (effectiveType === '4g' || effectiveType === '3g' || effectiveType === '2g' || effectiveType === 'slow-2g') {
149 + networkKind.value = 'cellular';
150 + return;
151 + }
152 + }
153 +
154 + if (typeof window !== 'undefined' && window.WeixinJSBridge) {
155 + await new Promise((resolve) => {
156 + try {
157 + window.WeixinJSBridge.invoke('getNetworkType', {}, (res) => {
158 + const wxType = (res && (res.networkType || res.err_msg || '')).toString().toLowerCase();
159 + if (wxType.includes('wifi')) networkKind.value = 'wifi';
160 + else if (wxType.includes('2g') || wxType.includes('3g') || wxType.includes('4g') || wxType.includes('5g')) networkKind.value = 'cellular';
161 + resolve();
162 + });
163 + } catch (e) {
164 + resolve();
165 + }
166 + });
167 + return;
168 + }
169 +
170 + networkKind.value = 'unknown';
171 + };
172 +
116 // 资源探测 173 // 资源探测
117 const probeVideo = async () => { 174 const probeVideo = async () => {
118 const url = videoUrlValue.value; 175 const url = videoUrlValue.value;
...@@ -182,6 +239,7 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) { ...@@ -182,6 +239,7 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
182 const handleError = (code, message = '') => { 239 const handleError = (code, message = '') => {
183 showErrorOverlay.value = true; 240 showErrorOverlay.value = true;
184 showNetworkSpeedOverlay.value = false; 241 showNetworkSpeedOverlay.value = false;
242 + trafficWarnVisible.value = false;
185 if (networkSpeedTimer) { 243 if (networkSpeedTimer) {
186 clearInterval(networkSpeedTimer); 244 clearInterval(networkSpeedTimer);
187 networkSpeedTimer = null; 245 networkSpeedTimer = null;
...@@ -230,6 +288,7 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) { ...@@ -230,6 +288,7 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
230 const showNetworkSpeed = () => { 288 const showNetworkSpeed = () => {
231 if (!hasEverPlayed.value) return; 289 if (!hasEverPlayed.value) return;
232 if (showErrorOverlay.value) return; 290 if (showErrorOverlay.value) return;
291 + if (trafficWarnVisible.value) return;
233 if (showNetworkSpeedOverlay.value) return; 292 if (showNetworkSpeedOverlay.value) return;
234 293
235 showNetworkSpeedOverlay.value = true; 294 showNetworkSpeedOverlay.value = true;
...@@ -249,6 +308,62 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) { ...@@ -249,6 +308,62 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
249 } 308 }
250 }; 309 };
251 310
311 + const isOnWifi = computed(() => networkKind.value === 'wifi');
312 + const isOnCellular = computed(() => networkKind.value === 'cellular');
313 +
314 + const trafficWarnThresholdBytes = 200 * 1024 * 1024;
315 +
316 + const needTrafficWarn = computed(() => {
317 + if (isOnWifi.value) return false;
318 + if (!isOnCellular.value) return false;
319 + const len = probeInfo.value.content_length;
320 + if (!len) return false;
321 + return len >= trafficWarnThresholdBytes;
322 + });
323 +
324 + const openTrafficWarn = async (fromAutoplay) => {
325 + if (trafficWarnAcknowledged.value) return false;
326 + trafficWarnDuringAutoplay = Boolean(fromAutoplay);
327 +
328 + if (!probeInfo.value.content_length && !probeLoading.value) {
329 + await probeVideo();
330 + }
331 +
332 + if (!needTrafficWarn.value) return false;
333 + trafficWarnVisible.value = true;
334 + hideNetworkSpeed();
335 + return true;
336 + };
337 +
338 + const closeTrafficWarn = () => {
339 + trafficWarnVisible.value = false;
340 + trafficWarnDuringAutoplay = false;
341 + };
342 +
343 + const requestPlay = async (fromAutoplay) => {
344 + const blocked = await openTrafficWarn(fromAutoplay);
345 + if (blocked) return;
346 +
347 + if (useNativePlayer.value) {
348 + tryNativePlay();
349 + return;
350 + }
351 +
352 + if (player.value && !player.value.isDisposed()) {
353 + player.value.play().catch(console.warn);
354 + }
355 + };
356 +
357 + const confirmTrafficWarn = () => {
358 + trafficWarnAcknowledged.value = true;
359 + closeTrafficWarn();
360 + void requestPlay(trafficWarnDuringAutoplay);
361 + };
362 +
363 + const cancelTrafficWarn = () => {
364 + closeTrafficWarn();
365 + };
366 +
252 // 4. 原生播放器逻辑 (iOS微信) 367 // 4. 原生播放器逻辑 (iOS微信)
253 const initNativePlayer = () => { 368 const initNativePlayer = () => {
254 const videoEl = nativeVideoRef.value; 369 const videoEl = nativeVideoRef.value;
...@@ -278,6 +393,28 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) { ...@@ -278,6 +393,28 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
278 }; 393 };
279 394
280 const onPlay = () => { 395 const onPlay = () => {
396 + if (!trafficWarnAcknowledged.value) {
397 + if (!probeInfo.value.content_length && isOnCellular.value) {
398 + try {
399 + videoEl.pause();
400 + } catch (e) {
401 + void e;
402 + }
403 + void requestPlay(false);
404 + return;
405 + }
406 +
407 + if (needTrafficWarn.value) {
408 + try {
409 + videoEl.pause();
410 + } catch (e) {
411 + void e;
412 + }
413 + trafficWarnVisible.value = true;
414 + hideNetworkSpeed();
415 + return;
416 + }
417 + }
281 hasEverPlayed.value = true; 418 hasEverPlayed.value = true;
282 hideNetworkSpeed(); 419 hideNetworkSpeed();
283 }; 420 };
...@@ -322,11 +459,11 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) { ...@@ -322,11 +459,11 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
322 }; 459 };
323 460
324 if (props.autoplay) { 461 if (props.autoplay) {
325 - tryNativePlay(); 462 + void requestPlay(true);
326 if (typeof document !== "undefined") { 463 if (typeof document !== "undefined") {
327 document.addEventListener( 464 document.addEventListener(
328 "WeixinJSBridgeReady", 465 "WeixinJSBridgeReady",
329 - () => tryNativePlay(), 466 + () => void requestPlay(true),
330 { once: true } 467 { once: true }
331 ); 468 );
332 } 469 }
...@@ -415,6 +552,20 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) { ...@@ -415,6 +552,20 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
415 }); 552 });
416 553
417 player.value.on('play', () => { 554 player.value.on('play', () => {
555 + if (!trafficWarnAcknowledged.value) {
556 + if (!probeInfo.value.content_length && isOnCellular.value) {
557 + player.value.pause();
558 + void requestPlay(false);
559 + return;
560 + }
561 +
562 + if (needTrafficWarn.value) {
563 + player.value.pause();
564 + trafficWarnVisible.value = true;
565 + hideNetworkSpeed();
566 + return;
567 + }
568 + }
418 hasEverPlayed.value = true; 569 hasEverPlayed.value = true;
419 hideNetworkSpeed(); 570 hideNetworkSpeed();
420 }); 571 });
...@@ -440,7 +591,7 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) { ...@@ -440,7 +591,7 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
440 }); 591 });
441 592
442 if (props.autoplay) { 593 if (props.autoplay) {
443 - player.value.play().catch(console.warn); 594 + void requestPlay(true);
444 } 595 }
445 } 596 }
446 }; 597 };
...@@ -455,6 +606,7 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) { ...@@ -455,6 +606,7 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
455 retryCount.value++; 606 retryCount.value++;
456 showErrorOverlay.value = false; 607 showErrorOverlay.value = false;
457 hideNetworkSpeed(); 608 hideNetworkSpeed();
609 + closeTrafficWarn();
458 610
459 if (useNativePlayer.value) { 611 if (useNativePlayer.value) {
460 const videoEl = nativeVideoRef.value; 612 const videoEl = nativeVideoRef.value;
...@@ -481,6 +633,8 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) { ...@@ -481,6 +633,8 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
481 showErrorOverlay.value = false; 633 showErrorOverlay.value = false;
482 hideNetworkSpeed(); 634 hideNetworkSpeed();
483 hasEverPlayed.value = false; 635 hasEverPlayed.value = false;
636 + trafficWarnAcknowledged.value = false;
637 + closeTrafficWarn();
484 void probeVideo(); 638 void probeVideo();
485 639
486 // 如果是原生播放器且 URL 变化,需要手动处理 HLS (如果是非 iOS Safari 环境) 640 // 如果是原生播放器且 URL 变化,需要手动处理 HLS (如果是非 iOS Safari 环境)
...@@ -491,6 +645,7 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) { ...@@ -491,6 +645,7 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
491 }); 645 });
492 646
493 onMounted(() => { 647 onMounted(() => {
648 + void detectNetworkKind();
494 void probeVideo(); 649 void probeVideo();
495 if (useNativePlayer.value) { 650 if (useNativePlayer.value) {
496 initNativePlayer(); 651 initNativePlayer();
...@@ -514,6 +669,7 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) { ...@@ -514,6 +669,7 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
514 } 669 }
515 670
516 hideNetworkSpeed(); 671 hideNetworkSpeed();
672 + closeTrafficWarn();
517 if (videoRef.value?.$player) { 673 if (videoRef.value?.$player) {
518 videoRef.value.$player.dispose(); 674 videoRef.value.$player.dispose();
519 } 675 }
...@@ -529,6 +685,13 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) { ...@@ -529,6 +685,13 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
529 errorMessage, 685 errorMessage,
530 showNetworkSpeedOverlay, 686 showNetworkSpeedOverlay,
531 networkSpeedText, 687 networkSpeedText,
688 + trafficWarnVisible,
689 + trafficFileSizeText,
690 + isOnWifi,
691 + isOnCellular,
692 + confirmTrafficWarn,
693 + cancelTrafficWarn,
694 + requestPlay,
532 retryLoad, 695 retryLoad,
533 handleVideoJsMounted, 696 handleVideoJsMounted,
534 tryNativePlay 697 tryNativePlay
......