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 +};
This diff is collapsed. Click to expand it.
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 +};