hookehuyr

refactor(video-player): 重构视频播放器逻辑,拆分功能模块

将视频播放器核心逻辑拆分为多个组合式函数,包括视频源处理、资源探测和播放叠层管理
提取视频类型判断和HLS支持检测到独立模块
优化错误处理和弱网提示的显示逻辑
1 +import { ref } from "vue";
2 +
3 +/**
4 + * @description 播放器叠层逻辑:弱网提示、HLS 下载速率(基于 VHS 带宽)与调试信息。
5 + * @param {{
6 + * props: any,
7 + * player: import("vue").Ref<any>,
8 + * is_m3u8: import("vue").Ref<boolean> | import("vue").ComputedRef<boolean>,
9 + * use_native_player: import("vue").Ref<boolean> | import("vue").ComputedRef<boolean>,
10 + * show_error_overlay: import("vue").Ref<boolean>,
11 + * has_started_playback: import("vue").Ref<boolean>
12 + * }} params
13 + * @returns {{
14 + * showNetworkSpeedOverlay: import("vue").Ref<boolean>,
15 + * networkSpeedText: import("vue").Ref<string>,
16 + * hlsDownloadSpeedText: import("vue").Ref<string>,
17 + * hlsSpeedDebugText: import("vue").Ref<string>,
18 + * setHlsDebug: (event_name: string, extra?: string) => void,
19 + * showNetworkSpeed: () => void,
20 + * hideNetworkSpeed: () => void,
21 + * startHlsDownloadSpeed: () => void,
22 + * stopHlsDownloadSpeed: (reason?: string) => void,
23 + * disposeOverlays: () => void
24 + * }}
25 + */
26 +export const useVideoPlaybackOverlays = ({
27 + props,
28 + player,
29 + is_m3u8,
30 + use_native_player,
31 + show_error_overlay,
32 + has_started_playback,
33 +}) => {
34 + const show_network_speed_overlay = ref(false);
35 + const network_speed_text = ref("");
36 + let network_speed_timer = null;
37 + let last_weixin_network_type_at = 0;
38 +
39 + const hls_download_speed_text = ref("");
40 + let hls_speed_timer = null;
41 + const hls_speed_debug_text = ref("");
42 +
43 + const set_hls_debug = (event_name, extra) => {
44 + if (!props || props.debug !== true) return;
45 +
46 + // tech(true) 在部分版本可能抛错,这里必须兜底,避免影响正常播放流程
47 + const p = player.value;
48 + const has_player = !!p && !p.isDisposed?.();
49 + let tech = null;
50 + try {
51 + tech = has_player && typeof p.tech === "function" ? p.tech(true) : null;
52 + } catch (e) {
53 + tech = null;
54 + }
55 + const stable_tech = tech || (has_player ? p.tech_ : null);
56 + const tech_name = has_player
57 + ? (p.techName_ || (stable_tech && stable_tech.name_) || (stable_tech && stable_tech.constructor && stable_tech.constructor.name) || "unknown")
58 + : "none";
59 + const mode = use_native_player.value ? "native" : "videojs";
60 + const vhs = stable_tech && stable_tech.vhs ? stable_tech.vhs : null;
61 + const hls = stable_tech && stable_tech.hls ? stable_tech.hls : null;
62 + const bw_kbps = vhs && typeof vhs.bandwidth === "number" ? Math.round(vhs.bandwidth / 1000) : 0;
63 + const hls_bw_kbps = hls && typeof hls.bandwidth === "number" ? Math.round(hls.bandwidth / 1000) : 0;
64 +
65 + const extra_text = extra ? ` ${extra}` : "";
66 + hls_speed_debug_text.value = `${event_name}${extra_text}\nmode:${mode} m3u8:${is_m3u8.value ? "1" : "0"} native:${use_native_player.value ? "1" : "0"}\ntech:${tech_name} vhs:${vhs ? "1" : "0"} bw:${bw_kbps}kbps hls_bw:${hls_bw_kbps}kbps`;
67 + };
68 +
69 + const update_network_speed = () => {
70 + if (typeof navigator === "undefined") {
71 + network_speed_text.value = "未知";
72 + return false;
73 + }
74 +
75 + // 优先使用 Network Information API(部分浏览器不支持)
76 + const connection = navigator.connection || navigator.mozConnection || navigator.webkitConnection;
77 + const downlink = connection && typeof connection.downlink === "number" ? connection.downlink : null;
78 + if (downlink && downlink > 0) {
79 + network_speed_text.value = `${downlink.toFixed(1)} Mbps`;
80 + return true;
81 + }
82 +
83 + const effective_type = connection && typeof connection.effectiveType === "string" ? connection.effectiveType : "";
84 + network_speed_text.value = effective_type ? `${effective_type}` : "未知";
85 + return effective_type ? true : false;
86 + };
87 +
88 + const update_weixin_network_type = () => {
89 + if (typeof window === "undefined") return;
90 + if (!window.WeixinJSBridge || typeof window.WeixinJSBridge.invoke !== "function") return;
91 +
92 + const now = Date.now();
93 + // 微信接口调用频率过高会卡顿/被限流,这里做节流
94 + if (now - last_weixin_network_type_at < 3000) return;
95 + last_weixin_network_type_at = now;
96 +
97 + window.WeixinJSBridge.invoke("getNetworkType", {}, (res) => {
98 + const type = (res && (res.networkType || res.network_type)) ? String(res.networkType || res.network_type) : "";
99 + if (type) network_speed_text.value = type;
100 + });
101 + };
102 +
103 + const show_network_speed = () => {
104 + // 没有进入过播放阶段时不显示,避免一加载就出现“弱网提示”造成误导
105 + if (!has_started_playback.value) return;
106 + if (show_error_overlay.value) return;
107 + if (show_network_speed_overlay.value) return;
108 +
109 + show_network_speed_overlay.value = true;
110 + const ok = update_network_speed();
111 + if (!ok) update_weixin_network_type();
112 +
113 + if (network_speed_timer) clearInterval(network_speed_timer);
114 + network_speed_timer = setInterval(() => {
115 + const ok2 = update_network_speed();
116 + if (!ok2) update_weixin_network_type();
117 + }, 800);
118 + };
119 +
120 + const hide_network_speed = () => {
121 + show_network_speed_overlay.value = false;
122 + if (network_speed_timer) {
123 + clearInterval(network_speed_timer);
124 + network_speed_timer = null;
125 + }
126 + };
127 +
128 + const format_speed = (bytes_per_second) => {
129 + const size = Number(bytes_per_second) || 0;
130 + if (!size) return "";
131 +
132 + const kb = 1024;
133 + const mb = kb * 1024;
134 + if (size >= mb) return `${(size / mb).toFixed(1)}MB/s`;
135 + if (size >= kb) return `${Math.round(size / kb)}kB/s`;
136 + return `${Math.round(size)}B/s`;
137 + };
138 +
139 + const update_hls_download_speed = () => {
140 + if (!player.value || player.value.isDisposed()) {
141 + hls_download_speed_text.value = "";
142 + set_hls_debug("update", "player:empty");
143 + return;
144 + }
145 + if (!is_m3u8.value || use_native_player.value) {
146 + // 非 m3u8 或原生播放器走系统内核,这里拿不到 VHS 带宽,直接隐藏
147 + hls_download_speed_text.value = "";
148 + set_hls_debug("update", "skip");
149 + return;
150 + }
151 +
152 + let tech = null;
153 + try {
154 + tech = typeof player.value.tech === "function" ? player.value.tech(true) : null;
155 + } catch (e) {
156 + tech = null;
157 + }
158 + const stable_tech = tech || player.value.tech_ || null;
159 + const vhs = stable_tech && stable_tech.vhs ? stable_tech.vhs : null;
160 + const hls = stable_tech && stable_tech.hls ? stable_tech.hls : null;
161 + const bandwidth_bits_per_second = (vhs && typeof vhs.bandwidth === "number" ? vhs.bandwidth : null)
162 + || (hls && typeof hls.bandwidth === "number" ? hls.bandwidth : null);
163 + if (!bandwidth_bits_per_second || bandwidth_bits_per_second <= 0) {
164 + hls_download_speed_text.value = "";
165 + set_hls_debug("update", "bw:0");
166 + return;
167 + }
168 +
169 + // VHS bandwidth 单位是 bits/s,这里换算成 bytes/s 再格式化展示
170 + hls_download_speed_text.value = format_speed(bandwidth_bits_per_second / 8);
171 + set_hls_debug("update", `speed:${hls_download_speed_text.value}`);
172 + };
173 +
174 + const start_hls_download_speed = () => {
175 + if (hls_speed_timer) return;
176 + if (!is_m3u8.value || use_native_player.value) return;
177 +
178 + set_hls_debug("start");
179 + update_hls_download_speed();
180 + hls_speed_timer = setInterval(() => {
181 + update_hls_download_speed();
182 + }, 1000);
183 + };
184 +
185 + const stop_hls_download_speed = (reason) => {
186 + if (hls_speed_timer) {
187 + clearInterval(hls_speed_timer);
188 + hls_speed_timer = null;
189 + }
190 + hls_download_speed_text.value = "";
191 + set_hls_debug("stop", reason || "");
192 + };
193 +
194 + const dispose_overlays = () => {
195 + hide_network_speed();
196 + stop_hls_download_speed("dispose");
197 + };
198 +
199 + return {
200 + showNetworkSpeedOverlay: show_network_speed_overlay,
201 + networkSpeedText: network_speed_text,
202 + hlsDownloadSpeedText: hls_download_speed_text,
203 + hlsSpeedDebugText: hls_speed_debug_text,
204 + setHlsDebug: set_hls_debug,
205 + showNetworkSpeed: show_network_speed,
206 + hideNetworkSpeed: hide_network_speed,
207 + startHlsDownloadSpeed: start_hls_download_speed,
208 + stopHlsDownloadSpeed: stop_hls_download_speed,
209 + disposeOverlays: dispose_overlays,
210 + };
211 +};
1 import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'; 1 import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue';
2 import { wxInfo } from "@/utils/tools"; 2 import { wxInfo } from "@/utils/tools";
3 -import Hls from 'hls.js';
4 import videojs from "video.js"; 3 import videojs from "video.js";
4 +import { buildVideoSources, canPlayHlsNatively } from "./videoPlayerSource";
5 +import { useVideoProbe } from "./useVideoProbe";
6 +import { useVideoPlaybackOverlays } from "./useVideoPlaybackOverlays";
5 // 新增:引入多码率切换插件 7 // 新增:引入多码率切换插件
6 import 'videojs-contrib-quality-levels'; // 用于读取 m3u8 中的多码率信息 8 import 'videojs-contrib-quality-levels'; // 用于读取 m3u8 中的多码率信息
7 import 'videojs-hls-quality-selector'; // 用于在播放器控制条显示“清晰度”切换菜单(支持 Auto/720p/480p 等)。 9 import 'videojs-hls-quality-selector'; // 用于在播放器控制条显示“清晰度”切换菜单(支持 Auto/720p/480p 等)。
...@@ -16,12 +18,32 @@ import 'videojs-hls-quality-selector/dist/videojs-hls-quality-selector.css'; ...@@ -16,12 +18,32 @@ import 'videojs-hls-quality-selector/dist/videojs-hls-quality-selector.css';
16 /** 18 /**
17 * 视频播放核心逻辑 Hook 19 * 视频播放核心逻辑 Hook
18 * 处理不同环境下的播放器选择、HLS支持、自动播放策略等 20 * 处理不同环境下的播放器选择、HLS支持、自动播放策略等
21 + * @description 根据环境选择原生 video 或 video.js,并处理弱网提示、错误重试与清晰度选择等逻辑。
22 + * @param {any} props 组件 props(需要包含 videoUrl/autoplay/useNativeOnIos/options/debug/videoId 等字段)
23 + * @param {(event: string, ...args: any[]) => void} emit 组件 emit
24 + * @param {import("vue").Ref<any>} videoRef videojs-player 组件 ref(用于 dispose)
25 + * @param {import("vue").Ref<HTMLVideoElement|null>} nativeVideoRef 原生 video 元素 ref(iOS 微信)
26 + * @returns {{
27 + * player: import("vue").Ref<any>,
28 + * state: import("vue").Ref<any>,
29 + * useNativePlayer: import("vue").ComputedRef<boolean>,
30 + * videoUrlValue: import("vue").ComputedRef<string>,
31 + * videoOptions: import("vue").ComputedRef<any>,
32 + * showErrorOverlay: import("vue").Ref<boolean>,
33 + * errorMessage: import("vue").Ref<string>,
34 + * showNetworkSpeedOverlay: import("vue").Ref<boolean>,
35 + * networkSpeedText: import("vue").Ref<string>,
36 + * hlsDownloadSpeedText: import("vue").Ref<string>,
37 + * hlsSpeedDebugText: import("vue").Ref<string>,
38 + * retryLoad: () => void,
39 + * handleVideoJsMounted: (payload: {player: any, state: any}) => void,
40 + * tryNativePlay: () => void
41 + * }}
19 */ 42 */
20 export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) { 43 export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
21 // 播放器实例 44 // 播放器实例
22 const player = ref(null); 45 const player = ref(null);
23 const state = ref(null); 46 const state = ref(null);
24 - const hlsInstance = ref(null);
25 47
26 // 错误处理相关 48 // 错误处理相关
27 const showErrorOverlay = ref(false); 49 const showErrorOverlay = ref(false);
...@@ -29,55 +51,13 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) { ...@@ -29,55 +51,13 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
29 const retryCount = ref(0); 51 const retryCount = ref(0);
30 const maxRetries = 3; 52 const maxRetries = 3;
31 53
32 - const showNetworkSpeedOverlay = ref(false);
33 - const networkSpeedText = ref('');
34 const hasEverPlayed = ref(false); 54 const hasEverPlayed = ref(false);
35 - let networkSpeedTimer = null;
36 - let lastWeixinNetworkTypeAt = 0;
37 -
38 - const hlsDownloadSpeedText = ref('');
39 - let hlsSpeedTimer = null;
40 const hasStartedPlayback = ref(false); 55 const hasStartedPlayback = ref(false);
41 - const hlsSpeedDebugText = ref('');
42 - const setHlsDebug = (eventName, extra) => {
43 - if (!props || props.debug !== true) return;
44 -
45 - const p = player.value;
46 - const hasPlayer = !!p && !p.isDisposed?.();
47 - let tech = null;
48 - try {
49 - tech = hasPlayer && typeof p.tech === 'function' ? p.tech(true) : null;
50 - } catch (e) {
51 - tech = null;
52 - }
53 - const stableTech = tech || (hasPlayer ? p.tech_ : null);
54 - const techName = hasPlayer
55 - ? (p.techName_ || (stableTech && stableTech.name_) || (stableTech && stableTech.constructor && stableTech.constructor.name) || 'unknown')
56 - : 'none';
57 - const mode = useNativePlayer.value ? 'native' : 'videojs';
58 - const vhs = stableTech && stableTech.vhs ? stableTech.vhs : null;
59 - const hls = stableTech && stableTech.hls ? stableTech.hls : null;
60 - const bwKbps = vhs && typeof vhs.bandwidth === 'number' ? Math.round(vhs.bandwidth / 1000) : 0;
61 - const hlsBwKbps = hls && typeof hls.bandwidth === 'number' ? Math.round(hls.bandwidth / 1000) : 0;
62 -
63 - const extraText = extra ? ` ${extra}` : '';
64 - 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`;
65 - };
66 56
67 // 原生播放器状态 57 // 原生播放器状态
68 const nativeReady = ref(false); 58 const nativeReady = ref(false);
69 let nativeListeners = null; 59 let nativeListeners = null;
70 60
71 - // 资源探测信息
72 - const probeInfo = ref({
73 - ok: null,
74 - status: null,
75 - content_type: "",
76 - content_length: null,
77 - accept_ranges: "",
78 - });
79 - const probeLoading = ref(false);
80 -
81 // 1. 环境判断与播放器选择 61 // 1. 环境判断与播放器选择
82 const useNativePlayer = computed(() => { 62 const useNativePlayer = computed(() => {
83 // 如果 props 强制关闭原生播放器,则返回 false (使用 Video.js) 63 // 如果 props 强制关闭原生播放器,则返回 false (使用 Video.js)
...@@ -99,50 +79,35 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) { ...@@ -99,50 +79,35 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
99 return url.includes('.m3u8'); 79 return url.includes('.m3u8');
100 }); 80 });
101 81
102 - // 4. 视频类型判断 82 + // 资源探测:只在“同源可探测”时执行,避免跨域 CORS 报错影响体验
103 - const getUrlPathExtension = (url) => { 83 + const { probeInfo, probeVideo } = useVideoProbe(videoUrlValue);
104 - const urlText = (url || "").trim();
105 - if (!urlText) return "";
106 - try {
107 - const u = typeof window !== "undefined" ? new URL(urlText, window.location?.href) : null;
108 - const pathname = u ? u.pathname : urlText;
109 - const lastDot = pathname.lastIndexOf(".");
110 - if (lastDot < 0) return "";
111 - return pathname.slice(lastDot + 1).toLowerCase();
112 - } catch (e) {
113 - const withoutQuery = urlText.split("?")[0].split("#")[0];
114 - const lastDot = withoutQuery.lastIndexOf(".");
115 - if (lastDot < 0) return "";
116 - return withoutQuery.slice(lastDot + 1).toLowerCase();
117 - }
118 - };
119 84
120 - const getVideoMimeType = (url) => { 85 + // 视频源构造:尽可能带上 type,老设备/部分内核对 blob/部分后缀会更稳定
121 - const urlText = (url || "").toLowerCase(); 86 + const videoSources = computed(() => buildVideoSources({
122 - if (urlText.includes(".m3u8")) return "application/x-mpegURL"; 87 + url: videoUrlValue.value,
123 - const ext = getUrlPathExtension(urlText) || getUrlPathExtension(props?.videoId); 88 + video_id: props?.videoId,
124 - if (ext === "m3u8") return "application/x-mpegURL"; 89 + probe_content_type: probeInfo.value.content_type,
125 - if (ext === "mp4" || ext === "m4v") return "video/mp4"; 90 + }));
126 - if (ext === "mov") return "video/quicktime";
127 - if (ext === "webm") return "video/webm";
128 - if (ext === "ogv" || ext === "ogg") return "video/ogg";
129 - return "";
130 - };
131 91
132 - // 5. 视频源配置 92 + // 播放叠层:弱网提示 + HLS 速度展示(仅 video.js + m3u8)
133 - const videoSources = computed(() => { 93 + const {
134 - const inferredType = getVideoMimeType(videoUrlValue.value); 94 + showNetworkSpeedOverlay,
135 - const probeType = (probeInfo.value.content_type || "").toLowerCase(); 95 + networkSpeedText,
136 - const type = inferredType 96 + hlsDownloadSpeedText,
137 - || (probeType.includes("application/vnd.apple.mpegurl") || probeType.includes("application/x-mpegurl") ? "application/x-mpegURL" : "") 97 + hlsSpeedDebugText,
138 - || (probeType.includes("video/mp4") ? "video/mp4" : "") 98 + setHlsDebug,
139 - || (probeType.includes("video/quicktime") ? "video/quicktime" : "") 99 + showNetworkSpeed,
140 - || (probeType.includes("video/webm") ? "video/webm" : "") 100 + hideNetworkSpeed,
141 - || (probeType.includes("video/ogg") ? "video/ogg" : ""); 101 + startHlsDownloadSpeed,
142 - if (type) { 102 + stopHlsDownloadSpeed,
143 - return [{ src: videoUrlValue.value, type }]; 103 + disposeOverlays,
144 - } 104 + } = useVideoPlaybackOverlays({
145 - return [{ src: videoUrlValue.value }]; 105 + props,
106 + player,
107 + is_m3u8: isM3U8,
108 + use_native_player: useNativePlayer,
109 + show_error_overlay: showErrorOverlay,
110 + has_started_playback: hasStartedPlayback,
146 }); 111 });
147 112
148 // 6. 错误处理逻辑 113 // 6. 错误处理逻辑
...@@ -171,100 +136,14 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) { ...@@ -171,100 +136,14 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
171 return ""; 136 return "";
172 }; 137 };
173 138
174 - const canProbeVideoUrl = (url) => {
175 - const urlText = (url || "").trim();
176 - if (!urlText) return false;
177 - if (typeof window === "undefined" || typeof window.location === "undefined") return false;
178 - if (typeof fetch === "undefined") return false;
179 - if (/^(blob:|data:)/i.test(urlText)) return false;
180 - try {
181 - const u = new URL(urlText, window.location.href);
182 - if (!u.protocol || !u.origin) return false;
183 - if (u.origin !== window.location.origin) return false;
184 - return true;
185 - } catch (e) {
186 - return false;
187 - }
188 - };
189 -
190 - // 资源探测
191 - const probeVideo = async () => {
192 - const url = videoUrlValue.value;
193 - if (!canProbeVideoUrl(url)) return;
194 - if (probeLoading.value) return;
195 -
196 - probeLoading.value = true;
197 - const controller = typeof AbortController !== "undefined" ? new AbortController() : null;
198 - const timeoutId = setTimeout(() => controller?.abort?.(), 8000);
199 - let controller2 = null;
200 - let timeoutId2 = null;
201 -
202 - try {
203 - try {
204 - const headRes = await fetch(url, {
205 - method: "HEAD",
206 - mode: "cors",
207 - cache: "no-store",
208 - signal: controller?.signal,
209 - });
210 -
211 - const contentLength = headRes.headers.get("content-length");
212 - probeInfo.value = {
213 - ok: headRes.ok,
214 - status: headRes.status,
215 - content_type: headRes.headers.get("content-type") || "",
216 - content_length: contentLength ? Number(contentLength) || null : null,
217 - accept_ranges: headRes.headers.get("accept-ranges") || "",
218 - };
219 -
220 - if (headRes.ok && probeInfo.value.content_length) return;
221 - } catch (e) {
222 - // 忽略 HEAD 请求失败
223 - }
224 -
225 - controller2 = typeof AbortController !== "undefined" ? new AbortController() : null;
226 - timeoutId2 = setTimeout(() => controller2?.abort?.(), 8000);
227 - try {
228 - const rangeRes = await fetch(url, {
229 - method: "GET",
230 - mode: "cors",
231 - cache: "no-store",
232 - headers: { Range: "bytes=0-1" },
233 - signal: controller2?.signal,
234 - });
235 - const contentRange = rangeRes.headers.get("content-range") || "";
236 - const match = contentRange.match(/\/(\d+)\s*$/);
237 - const total = match ? Number(match[1]) || null : null;
238 - const contentLength = rangeRes.headers.get("content-length");
239 -
240 - probeInfo.value = {
241 - ok: rangeRes.ok,
242 - status: rangeRes.status,
243 - content_type: rangeRes.headers.get("content-type") || "",
244 - content_length: total || (contentLength ? Number(contentLength) || null : null),
245 - accept_ranges: rangeRes.headers.get("accept-ranges") || "",
246 - };
247 - } catch (e) {
248 - // 忽略错误
249 - }
250 - } finally {
251 - if (timeoutId2) clearTimeout(timeoutId2);
252 - clearTimeout(timeoutId);
253 - probeLoading.value = false;
254 - }
255 - };
256 -
257 // 7. 错误处理逻辑 139 // 7. 错误处理逻辑
258 const handleError = (code, message = '') => { 140 const handleError = (code, message = '') => {
259 showErrorOverlay.value = true; 141 showErrorOverlay.value = true;
260 - showNetworkSpeedOverlay.value = false; 142 + hideNetworkSpeed();
261 - if (networkSpeedTimer) {
262 - clearInterval(networkSpeedTimer);
263 - networkSpeedTimer = null;
264 - }
265 switch (code) { 143 switch (code) {
266 case 4: // MEDIA_ERR_SRC_NOT_SUPPORTED 144 case 4: // MEDIA_ERR_SRC_NOT_SUPPORTED
267 errorMessage.value = '视频格式不支持或无法加载,请检查网络连接' + getErrorHint(); 145 errorMessage.value = '视频格式不支持或无法加载,请检查网络连接' + getErrorHint();
146 + // 旧机型/弱网下可能出现短暂的“无法加载”,这里做有限次数重试
268 if (retryCount.value < maxRetries) { 147 if (retryCount.value < maxRetries) {
269 setTimeout(retryLoad, 1000); 148 setTimeout(retryLoad, 1000);
270 } 149 }
...@@ -286,126 +165,6 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) { ...@@ -286,126 +165,6 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
286 } 165 }
287 }; 166 };
288 167
289 - const updateNetworkSpeed = () => {
290 - if (typeof navigator === 'undefined') {
291 - networkSpeedText.value = '未知';
292 - return false;
293 - }
294 -
295 - const connection = navigator.connection || navigator.mozConnection || navigator.webkitConnection;
296 - const downlink = connection && typeof connection.downlink === 'number' ? connection.downlink : null;
297 - if (downlink && downlink > 0) {
298 - networkSpeedText.value = `${downlink.toFixed(1)} Mbps`;
299 - return true;
300 - }
301 -
302 - const effectiveType = connection && typeof connection.effectiveType === 'string' ? connection.effectiveType : '';
303 - networkSpeedText.value = effectiveType ? `${effectiveType}` : '未知';
304 - return effectiveType ? true : false;
305 - };
306 -
307 - const updateWeixinNetworkType = () => {
308 - if (typeof window === 'undefined') return;
309 - if (!window.WeixinJSBridge || typeof window.WeixinJSBridge.invoke !== 'function') return;
310 -
311 - const now = Date.now();
312 - if (now - lastWeixinNetworkTypeAt < 3000) return;
313 - lastWeixinNetworkTypeAt = now;
314 -
315 - window.WeixinJSBridge.invoke('getNetworkType', {}, (res) => {
316 - const type = (res && (res.networkType || res.network_type)) ? String(res.networkType || res.network_type) : '';
317 - if (type) networkSpeedText.value = type;
318 - });
319 - };
320 -
321 - const showNetworkSpeed = () => {
322 - if (!hasStartedPlayback.value) return;
323 - if (showErrorOverlay.value) return;
324 - if (showNetworkSpeedOverlay.value) return;
325 -
326 - showNetworkSpeedOverlay.value = true;
327 - const ok = updateNetworkSpeed();
328 - if (!ok) updateWeixinNetworkType();
329 -
330 - if (networkSpeedTimer) clearInterval(networkSpeedTimer);
331 - networkSpeedTimer = setInterval(() => {
332 - const ok2 = updateNetworkSpeed();
333 - if (!ok2) updateWeixinNetworkType();
334 - }, 800);
335 - };
336 -
337 - const hideNetworkSpeed = () => {
338 - showNetworkSpeedOverlay.value = false;
339 - if (networkSpeedTimer) {
340 - clearInterval(networkSpeedTimer);
341 - networkSpeedTimer = null;
342 - }
343 - };
344 -
345 - const formatSpeed = (bytesPerSecond) => {
346 - const size = Number(bytesPerSecond) || 0;
347 - if (!size) return '';
348 -
349 - const kb = 1024;
350 - const mb = kb * 1024;
351 - if (size >= mb) return `${(size / mb).toFixed(1)}MB/s`;
352 - if (size >= kb) return `${Math.round(size / kb)}kB/s`;
353 - return `${Math.round(size)}B/s`;
354 - };
355 -
356 - const updateHlsDownloadSpeed = () => {
357 - if (!player.value || player.value.isDisposed()) {
358 - hlsDownloadSpeedText.value = '';
359 - setHlsDebug('update', 'player:empty');
360 - return;
361 - }
362 - if (!isM3U8.value || useNativePlayer.value) {
363 - hlsDownloadSpeedText.value = '';
364 - setHlsDebug('update', 'skip');
365 - return;
366 - }
367 -
368 - let tech = null;
369 - try {
370 - tech = typeof player.value.tech === 'function' ? player.value.tech(true) : null;
371 - } catch (e) {
372 - tech = null;
373 - }
374 - const stableTech = tech || player.value.tech_ || null;
375 - const vhs = stableTech && stableTech.vhs ? stableTech.vhs : null;
376 - const hls = stableTech && stableTech.hls ? stableTech.hls : null;
377 - const bandwidthBitsPerSecond = (vhs && typeof vhs.bandwidth === 'number' ? vhs.bandwidth : null)
378 - || (hls && typeof hls.bandwidth === 'number' ? hls.bandwidth : null);
379 - if (!bandwidthBitsPerSecond || bandwidthBitsPerSecond <= 0) {
380 - hlsDownloadSpeedText.value = '';
381 - setHlsDebug('update', 'bw:0');
382 - return;
383 - }
384 -
385 - hlsDownloadSpeedText.value = formatSpeed(bandwidthBitsPerSecond / 8);
386 - setHlsDebug('update', `speed:${hlsDownloadSpeedText.value}`);
387 - };
388 -
389 - const startHlsDownloadSpeed = () => {
390 - if (hlsSpeedTimer) return;
391 - if (!isM3U8.value || useNativePlayer.value) return;
392 -
393 - setHlsDebug('start');
394 - updateHlsDownloadSpeed();
395 - hlsSpeedTimer = setInterval(() => {
396 - updateHlsDownloadSpeed();
397 - }, 1000);
398 - };
399 -
400 - const stopHlsDownloadSpeed = (reason) => {
401 - if (hlsSpeedTimer) {
402 - clearInterval(hlsSpeedTimer);
403 - hlsSpeedTimer = null;
404 - }
405 - hlsDownloadSpeedText.value = '';
406 - setHlsDebug('stop', reason || '');
407 - };
408 -
409 // 4. 原生播放器逻辑 (iOS微信) 168 // 4. 原生播放器逻辑 (iOS微信)
410 const initNativePlayer = () => { 169 const initNativePlayer = () => {
411 const videoEl = nativeVideoRef.value; 170 const videoEl = nativeVideoRef.value;
...@@ -413,14 +172,7 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) { ...@@ -413,14 +172,7 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
413 172
414 setHlsDebug('native:init'); 173 setHlsDebug('native:init');
415 174
416 - // HLS 处理 175 + // 原生播放器走系统内核:事件主要用于控制弱网提示与错误覆盖层
417 - if (isM3U8.value && Hls.isSupported()) {
418 - // 如果原生支持 HLS (iOS Safari),直接用 src 即可,不需要 hls.js
419 - // 但如果是安卓微信等不支持原生 HLS 的环境,才需要 hls.js
420 - // 由于 useNativePlayer 仅针对 iOS 微信,而 iOS 原生支持 HLS,所以这里直接赋值 src 即可
421 - // 无需额外操作
422 - }
423 -
424 const onLoadStart = () => { 176 const onLoadStart = () => {
425 showErrorOverlay.value = false; 177 showErrorOverlay.value = false;
426 nativeReady.value = false; 178 nativeReady.value = false;
...@@ -486,6 +238,7 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) { ...@@ -486,6 +238,7 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
486 }; 238 };
487 239
488 if (props.autoplay) { 240 if (props.autoplay) {
241 + // iOS 微信 autoplay 需要用户手势/桥接事件配合,先尝试一次,再在 WeixinJSBridgeReady 时再试
489 tryNativePlay(); 242 tryNativePlay();
490 if (typeof document !== "undefined") { 243 if (typeof document !== "undefined") {
491 document.addEventListener( 244 document.addEventListener(
...@@ -514,18 +267,10 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) { ...@@ -514,18 +267,10 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
514 }; 267 };
515 268
516 // 5. Video.js 播放器逻辑 (PC/Android) 269 // 5. Video.js 播放器逻辑 (PC/Android)
517 - const canPlayHlsNatively = () => {
518 - if (typeof document === "undefined") return false;
519 - const el = document.createElement("video");
520 - if (!el || typeof el.canPlayType !== "function") return false;
521 - const r1 = el.canPlayType("application/vnd.apple.mpegurl");
522 - const r2 = el.canPlayType("application/x-mpegURL");
523 - return r1 === "probably" || r1 === "maybe" || r2 === "probably" || r2 === "maybe";
524 - };
525 -
526 const shouldOverrideNativeHls = computed(() => { 270 const shouldOverrideNativeHls = computed(() => {
527 if (!isM3U8.value) return false; 271 if (!isM3U8.value) return false;
528 if (videojs.browser.IS_SAFARI) return false; 272 if (videojs.browser.IS_SAFARI) return false;
273 + // 非 Safari 且不具备原生 HLS 时,强制 video.js 的 VHS 来解 m3u8
529 return !canPlayHlsNatively(); 274 return !canPlayHlsNatively();
530 }); 275 });
531 276
...@@ -590,6 +335,7 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) { ...@@ -590,6 +335,7 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
590 tech = null; 335 tech = null;
591 } 336 }
592 if (!tech) return; 337 if (!tech) return;
338 + // videojs-hls-quality-selector 旧版本依赖 tech.hls,而 video.js 7 默认是 tech.vhs,这里做兼容别名
593 if (!tech.hls && tech.vhs) { 339 if (!tech.hls && tech.vhs) {
594 try { 340 try {
595 tech.hls = tech.vhs; 341 tech.hls = tech.vhs;
...@@ -642,6 +388,7 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) { ...@@ -642,6 +388,7 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
642 player.value.on('waiting', () => { 388 player.value.on('waiting', () => {
643 if (!hasEverPlayed.value) return; 389 if (!hasEverPlayed.value) return;
644 if (player.value?.paused?.()) return; 390 if (player.value?.paused?.()) return;
391 + // 已经播放过且当前未暂停,才认为是“卡顿等待”,显示弱网提示
645 showNetworkSpeed(); 392 showNetworkSpeed();
646 startHlsDownloadSpeed(); 393 startHlsDownloadSpeed();
647 setHlsDebug('waiting'); 394 setHlsDebug('waiting');
...@@ -686,6 +433,7 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) { ...@@ -686,6 +433,7 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
686 stopHlsDownloadSpeed('retry'); 433 stopHlsDownloadSpeed('retry');
687 434
688 if (useNativePlayer.value) { 435 if (useNativePlayer.value) {
436 + // 原生 video 需要手动重置 src/load
689 const videoEl = nativeVideoRef.value; 437 const videoEl = nativeVideoRef.value;
690 if (videoEl) { 438 if (videoEl) {
691 nativeReady.value = false; 439 nativeReady.value = false;
...@@ -698,6 +446,7 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) { ...@@ -698,6 +446,7 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
698 tryNativePlay(); 446 tryNativePlay();
699 } 447 }
700 } else { 448 } else {
449 + // video.js 走自身 load 刷新
701 if (player.value && !player.value.isDisposed()) { 450 if (player.value && !player.value.isDisposed()) {
702 player.value.load(); 451 player.value.load();
703 } 452 }
...@@ -712,6 +461,7 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) { ...@@ -712,6 +461,7 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
712 stopHlsDownloadSpeed('url_change'); 461 stopHlsDownloadSpeed('url_change');
713 hasEverPlayed.value = false; 462 hasEverPlayed.value = false;
714 hasStartedPlayback.value = false; 463 hasStartedPlayback.value = false;
464 + // 地址变更后刷新探测信息,错误提示会基于 probeInfo 补充更准确的原因
715 void probeVideo(); 465 void probeVideo();
716 466
717 // 如果是原生播放器且 URL 变化,需要手动处理 HLS (如果是非 iOS Safari 环境) 467 // 如果是原生播放器且 URL 变化,需要手动处理 HLS (如果是非 iOS Safari 环境)
...@@ -740,12 +490,7 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) { ...@@ -740,12 +490,7 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
740 nativeListeners.videoEl.removeEventListener("playing", nativeListeners.onPlaying); 490 nativeListeners.videoEl.removeEventListener("playing", nativeListeners.onPlaying);
741 } 491 }
742 492
743 - if (hlsInstance.value) { 493 + disposeOverlays();
744 - hlsInstance.value.destroy();
745 - }
746 -
747 - hideNetworkSpeed();
748 - stopHlsDownloadSpeed('unmount');
749 if (videoRef.value?.$player) { 494 if (videoRef.value?.$player) {
750 videoRef.value.$player.dispose(); 495 videoRef.value.$player.dispose();
751 } 496 }
......
1 +import { ref } from "vue";
2 +
3 +/**
4 + * @description 视频资源探测(同源才探测)。用于在播放器初始化前尽可能拿到 content-type、文件大小等信息。
5 + * @param {import("vue").Ref<string>} video_url_value_ref 视频地址的 ref(会读取 .value)
6 + * @returns {{
7 + * probeInfo: import("vue").Ref<{ok: boolean|null, status: number|null, content_type: string, content_length: number|null, accept_ranges: string}>,
8 + * probeLoading: import("vue").Ref<boolean>,
9 + * probeVideo: () => Promise<void>
10 + * }}
11 + */
12 +export const useVideoProbe = (video_url_value_ref) => {
13 + const probe_info = ref({
14 + ok: null,
15 + status: null,
16 + content_type: "",
17 + content_length: null,
18 + accept_ranges: "",
19 + });
20 + const probe_loading = ref(false);
21 +
22 + const can_probe_video_url = (url) => {
23 + const url_text = (url || "").trim();
24 + if (!url_text) return false;
25 + if (typeof window === "undefined" || typeof window.location === "undefined") return false;
26 + if (typeof fetch === "undefined") return false;
27 + // blob/data 资源无法通过 fetch 拿到 headers,直接跳过
28 + if (/^(blob:|data:)/i.test(url_text)) return false;
29 + try {
30 + const u = new URL(url_text, window.location.href);
31 + if (!u.protocol || !u.origin) return false;
32 + // 只允许同源探测,避免跨域触发 CORS 失败导致控制台噪声/影响体验
33 + if (u.origin !== window.location.origin) return false;
34 + return true;
35 + } catch (e) {
36 + return false;
37 + }
38 + };
39 +
40 + const probe_video = async () => {
41 + const url = video_url_value_ref.value;
42 + if (!can_probe_video_url(url)) return;
43 + if (probe_loading.value) return;
44 +
45 + probe_loading.value = true;
46 + // 单次探测设置超时,避免弱网下长时间卡住
47 + const controller = typeof AbortController !== "undefined" ? new AbortController() : null;
48 + const timeout_id = setTimeout(() => controller?.abort?.(), 8000);
49 + let timeout_id_2 = null;
50 +
51 + try {
52 + try {
53 + // 先 HEAD,理论上成本最低,能直接拿到 content-length / content-type
54 + const head_res = await fetch(url, {
55 + method: "HEAD",
56 + mode: "cors",
57 + cache: "no-store",
58 + signal: controller?.signal,
59 + });
60 +
61 + const content_length = head_res.headers.get("content-length");
62 + probe_info.value = {
63 + ok: head_res.ok,
64 + status: head_res.status,
65 + content_type: head_res.headers.get("content-type") || "",
66 + content_length: content_length ? Number(content_length) || null : null,
67 + accept_ranges: head_res.headers.get("accept-ranges") || "",
68 + };
69 +
70 + if (head_res.ok && probe_info.value.content_length) return;
71 + } catch (e) {
72 + void e;
73 + }
74 +
75 + // 部分服务器不支持 HEAD 或不返回 content-length,这里用 Range GET 兜底
76 + const controller2 = typeof AbortController !== "undefined" ? new AbortController() : null;
77 + timeout_id_2 = setTimeout(() => controller2?.abort?.(), 8000);
78 +
79 + try {
80 + const range_res = await fetch(url, {
81 + method: "GET",
82 + mode: "cors",
83 + cache: "no-store",
84 + // 只取前 2 字节,尽量减少流量;通过 content-range 推断总大小
85 + headers: { Range: "bytes=0-1" },
86 + signal: controller2?.signal,
87 + });
88 + const content_range = range_res.headers.get("content-range") || "";
89 + const match = content_range.match(/\/(\d+)\s*$/);
90 + const total = match ? Number(match[1]) || null : null;
91 + const content_length = range_res.headers.get("content-length");
92 +
93 + probe_info.value = {
94 + ok: range_res.ok,
95 + status: range_res.status,
96 + content_type: range_res.headers.get("content-type") || "",
97 + content_length: total || (content_length ? Number(content_length) || null : null),
98 + accept_ranges: range_res.headers.get("accept-ranges") || "",
99 + };
100 + } catch (e) {
101 + void e;
102 + }
103 + } finally {
104 + if (timeout_id_2) clearTimeout(timeout_id_2);
105 + clearTimeout(timeout_id);
106 + probe_loading.value = false;
107 + }
108 + };
109 +
110 + return {
111 + probeInfo: probe_info,
112 + probeLoading: probe_loading,
113 + probeVideo: probe_video,
114 + };
115 +};
1 +/*
2 + * @Date: 2026-01-20 16:53:31
3 + * @LastEditors: hookehuyr hookehuyr@gmail.com
4 + * @LastEditTime: 2026-01-20 17:06:44
5 + * @FilePath: /mlaj/src/composables/videoPlayerSource.js
6 + * @Description: 文件描述
7 + */
8 +/**
9 + * @description 从 url(或文件名)中提取扩展名(小写,不含点)。支持 base_url 作为 URL 解析基准。
10 + * @param {string} url 可能是完整 URL/相对路径/文件名
11 + * @param {string=} base_url URL 解析基准(默认取 window.location.href)
12 + * @returns {string}
13 + */
14 +const getUrlPathExtension = (url, base_url) => {
15 + const url_text = (url || "").trim();
16 + if (!url_text) return "";
17 + try {
18 + const base = base_url || (typeof window !== "undefined" ? window.location?.href : undefined);
19 + const u = base ? new URL(url_text, base) : new URL(url_text);
20 + const pathname = u ? u.pathname : url_text;
21 + const last_dot = pathname.lastIndexOf(".");
22 + if (last_dot < 0) return "";
23 + return pathname.slice(last_dot + 1).toLowerCase();
24 + } catch (e) {
25 + // URL 构造失败时,用字符串兜底(移除 query/hash 再取扩展名)
26 + const without_query = url_text.split("?")[0].split("#")[0];
27 + const last_dot = without_query.lastIndexOf(".");
28 + if (last_dot < 0) return "";
29 + return without_query.slice(last_dot + 1).toLowerCase();
30 + }
31 +};
32 +
33 +/**
34 + * @description 根据 url / video_id 推断 video MIME。用于 blob 地址等无法从 url 取扩展名的场景。
35 + * @param {{url: string, video_id?: string, base_url?: string}} params
36 + * @returns {string}
37 + */
38 +const getVideoMimeType = ({ url, video_id, base_url }) => {
39 + const url_text = (url || "").toLowerCase();
40 + if (url_text.includes(".m3u8")) return "application/x-mpegURL";
41 + // 1) 优先 url 扩展名;2) blob:xxx 这种取不到扩展名时,用 video_id 兜底(通常带 .mp4/.mov 等)
42 + const ext = getUrlPathExtension(url_text, base_url) || getUrlPathExtension(video_id, base_url);
43 + if (ext === "m3u8") return "application/x-mpegURL";
44 + if (ext === "mp4" || ext === "m4v") return "video/mp4";
45 + if (ext === "mov") return "video/quicktime";
46 + if (ext === "webm") return "video/webm";
47 + if (ext === "ogv" || ext === "ogg") return "video/ogg";
48 + return "";
49 +};
50 +
51 +/**
52 + * @description 从资源探测得到的 content-type 推断 video MIME。
53 + * @param {string} content_type
54 + * @returns {string}
55 + */
56 +const inferVideoMimeTypeFromContentType = (content_type) => {
57 + const probe_type = (content_type || "").toLowerCase();
58 + return (probe_type.includes("application/vnd.apple.mpegurl") || probe_type.includes("application/x-mpegurl") ? "application/x-mpegURL" : "")
59 + || (probe_type.includes("video/mp4") ? "video/mp4" : "")
60 + || (probe_type.includes("video/quicktime") ? "video/quicktime" : "")
61 + || (probe_type.includes("video/webm") ? "video/webm" : "")
62 + || (probe_type.includes("video/ogg") ? "video/ogg" : "");
63 +};
64 +
65 +/**
66 + * @description 构造 video.js sources。尽可能给出 type,避免旧设备/部分内核出现“无法识别资源”的报错。
67 + * @param {{url: string, video_id?: string, probe_content_type?: string, base_url?: string}} params
68 + * @returns {Array<{src: string, type?: string}>}
69 + */
70 +const buildVideoSources = ({ url, video_id, probe_content_type, base_url }) => {
71 + const inferred_type = getVideoMimeType({ url, video_id, base_url });
72 + const type = inferred_type || inferVideoMimeTypeFromContentType(probe_content_type);
73 + if (type) return [{ src: url, type }];
74 + return [{ src: url }];
75 +};
76 +
77 +/**
78 + * @description 判断当前浏览器是否原生支持 HLS(m3u8)。
79 + * @returns {boolean}
80 + */
81 +const canPlayHlsNatively = () => {
82 + if (typeof document === "undefined") return false;
83 + const el = document.createElement("video");
84 + if (!el || typeof el.canPlayType !== "function") return false;
85 + const r1 = el.canPlayType("application/vnd.apple.mpegurl");
86 + const r2 = el.canPlayType("application/x-mpegURL");
87 + return r1 === "probably" || r1 === "maybe" || r2 === "probably" || r2 === "maybe";
88 +};
89 +
90 +export {
91 + getUrlPathExtension,
92 + getVideoMimeType,
93 + inferVideoMimeTypeFromContentType,
94 + buildVideoSources,
95 + canPlayHlsNatively,
96 +};