hookehuyr

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

将视频播放器核心逻辑拆分为多个组合式函数,包括视频源处理、资源探测和播放叠层管理
提取视频类型判断和HLS支持检测到独立模块
优化错误处理和弱网提示的显示逻辑
import { ref } from "vue";
/**
* @description 播放器叠层逻辑:弱网提示、HLS 下载速率(基于 VHS 带宽)与调试信息。
* @param {{
* props: any,
* player: import("vue").Ref<any>,
* is_m3u8: import("vue").Ref<boolean> | import("vue").ComputedRef<boolean>,
* use_native_player: import("vue").Ref<boolean> | import("vue").ComputedRef<boolean>,
* show_error_overlay: import("vue").Ref<boolean>,
* has_started_playback: import("vue").Ref<boolean>
* }} params
* @returns {{
* showNetworkSpeedOverlay: import("vue").Ref<boolean>,
* networkSpeedText: import("vue").Ref<string>,
* hlsDownloadSpeedText: import("vue").Ref<string>,
* hlsSpeedDebugText: import("vue").Ref<string>,
* setHlsDebug: (event_name: string, extra?: string) => void,
* showNetworkSpeed: () => void,
* hideNetworkSpeed: () => void,
* startHlsDownloadSpeed: () => void,
* stopHlsDownloadSpeed: (reason?: string) => void,
* disposeOverlays: () => void
* }}
*/
export const useVideoPlaybackOverlays = ({
props,
player,
is_m3u8,
use_native_player,
show_error_overlay,
has_started_playback,
}) => {
const show_network_speed_overlay = ref(false);
const network_speed_text = ref("");
let network_speed_timer = null;
let last_weixin_network_type_at = 0;
const hls_download_speed_text = ref("");
let hls_speed_timer = null;
const hls_speed_debug_text = ref("");
const set_hls_debug = (event_name, extra) => {
if (!props || props.debug !== true) return;
// tech(true) 在部分版本可能抛错,这里必须兜底,避免影响正常播放流程
const p = player.value;
const has_player = !!p && !p.isDisposed?.();
let tech = null;
try {
tech = has_player && typeof p.tech === "function" ? p.tech(true) : null;
} catch (e) {
tech = null;
}
const stable_tech = tech || (has_player ? p.tech_ : null);
const tech_name = has_player
? (p.techName_ || (stable_tech && stable_tech.name_) || (stable_tech && stable_tech.constructor && stable_tech.constructor.name) || "unknown")
: "none";
const mode = use_native_player.value ? "native" : "videojs";
const vhs = stable_tech && stable_tech.vhs ? stable_tech.vhs : null;
const hls = stable_tech && stable_tech.hls ? stable_tech.hls : null;
const bw_kbps = vhs && typeof vhs.bandwidth === "number" ? Math.round(vhs.bandwidth / 1000) : 0;
const hls_bw_kbps = hls && typeof hls.bandwidth === "number" ? Math.round(hls.bandwidth / 1000) : 0;
const extra_text = extra ? ` ${extra}` : "";
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`;
};
const update_network_speed = () => {
if (typeof navigator === "undefined") {
network_speed_text.value = "未知";
return false;
}
// 优先使用 Network Information API(部分浏览器不支持)
const connection = navigator.connection || navigator.mozConnection || navigator.webkitConnection;
const downlink = connection && typeof connection.downlink === "number" ? connection.downlink : null;
if (downlink && downlink > 0) {
network_speed_text.value = `${downlink.toFixed(1)} Mbps`;
return true;
}
const effective_type = connection && typeof connection.effectiveType === "string" ? connection.effectiveType : "";
network_speed_text.value = effective_type ? `${effective_type}` : "未知";
return effective_type ? true : false;
};
const update_weixin_network_type = () => {
if (typeof window === "undefined") return;
if (!window.WeixinJSBridge || typeof window.WeixinJSBridge.invoke !== "function") return;
const now = Date.now();
// 微信接口调用频率过高会卡顿/被限流,这里做节流
if (now - last_weixin_network_type_at < 3000) return;
last_weixin_network_type_at = now;
window.WeixinJSBridge.invoke("getNetworkType", {}, (res) => {
const type = (res && (res.networkType || res.network_type)) ? String(res.networkType || res.network_type) : "";
if (type) network_speed_text.value = type;
});
};
const show_network_speed = () => {
// 没有进入过播放阶段时不显示,避免一加载就出现“弱网提示”造成误导
if (!has_started_playback.value) return;
if (show_error_overlay.value) return;
if (show_network_speed_overlay.value) return;
show_network_speed_overlay.value = true;
const ok = update_network_speed();
if (!ok) update_weixin_network_type();
if (network_speed_timer) clearInterval(network_speed_timer);
network_speed_timer = setInterval(() => {
const ok2 = update_network_speed();
if (!ok2) update_weixin_network_type();
}, 800);
};
const hide_network_speed = () => {
show_network_speed_overlay.value = false;
if (network_speed_timer) {
clearInterval(network_speed_timer);
network_speed_timer = null;
}
};
const format_speed = (bytes_per_second) => {
const size = Number(bytes_per_second) || 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 update_hls_download_speed = () => {
if (!player.value || player.value.isDisposed()) {
hls_download_speed_text.value = "";
set_hls_debug("update", "player:empty");
return;
}
if (!is_m3u8.value || use_native_player.value) {
// 非 m3u8 或原生播放器走系统内核,这里拿不到 VHS 带宽,直接隐藏
hls_download_speed_text.value = "";
set_hls_debug("update", "skip");
return;
}
let tech = null;
try {
tech = typeof player.value.tech === "function" ? player.value.tech(true) : null;
} catch (e) {
tech = null;
}
const stable_tech = tech || player.value.tech_ || null;
const vhs = stable_tech && stable_tech.vhs ? stable_tech.vhs : null;
const hls = stable_tech && stable_tech.hls ? stable_tech.hls : null;
const bandwidth_bits_per_second = (vhs && typeof vhs.bandwidth === "number" ? vhs.bandwidth : null)
|| (hls && typeof hls.bandwidth === "number" ? hls.bandwidth : null);
if (!bandwidth_bits_per_second || bandwidth_bits_per_second <= 0) {
hls_download_speed_text.value = "";
set_hls_debug("update", "bw:0");
return;
}
// VHS bandwidth 单位是 bits/s,这里换算成 bytes/s 再格式化展示
hls_download_speed_text.value = format_speed(bandwidth_bits_per_second / 8);
set_hls_debug("update", `speed:${hls_download_speed_text.value}`);
};
const start_hls_download_speed = () => {
if (hls_speed_timer) return;
if (!is_m3u8.value || use_native_player.value) return;
set_hls_debug("start");
update_hls_download_speed();
hls_speed_timer = setInterval(() => {
update_hls_download_speed();
}, 1000);
};
const stop_hls_download_speed = (reason) => {
if (hls_speed_timer) {
clearInterval(hls_speed_timer);
hls_speed_timer = null;
}
hls_download_speed_text.value = "";
set_hls_debug("stop", reason || "");
};
const dispose_overlays = () => {
hide_network_speed();
stop_hls_download_speed("dispose");
};
return {
showNetworkSpeedOverlay: show_network_speed_overlay,
networkSpeedText: network_speed_text,
hlsDownloadSpeedText: hls_download_speed_text,
hlsSpeedDebugText: hls_speed_debug_text,
setHlsDebug: set_hls_debug,
showNetworkSpeed: show_network_speed,
hideNetworkSpeed: hide_network_speed,
startHlsDownloadSpeed: start_hls_download_speed,
stopHlsDownloadSpeed: stop_hls_download_speed,
disposeOverlays: dispose_overlays,
};
};
This diff is collapsed. Click to expand it.
import { ref } from "vue";
/**
* @description 视频资源探测(同源才探测)。用于在播放器初始化前尽可能拿到 content-type、文件大小等信息。
* @param {import("vue").Ref<string>} video_url_value_ref 视频地址的 ref(会读取 .value)
* @returns {{
* probeInfo: import("vue").Ref<{ok: boolean|null, status: number|null, content_type: string, content_length: number|null, accept_ranges: string}>,
* probeLoading: import("vue").Ref<boolean>,
* probeVideo: () => Promise<void>
* }}
*/
export const useVideoProbe = (video_url_value_ref) => {
const probe_info = ref({
ok: null,
status: null,
content_type: "",
content_length: null,
accept_ranges: "",
});
const probe_loading = ref(false);
const can_probe_video_url = (url) => {
const url_text = (url || "").trim();
if (!url_text) return false;
if (typeof window === "undefined" || typeof window.location === "undefined") return false;
if (typeof fetch === "undefined") return false;
// blob/data 资源无法通过 fetch 拿到 headers,直接跳过
if (/^(blob:|data:)/i.test(url_text)) return false;
try {
const u = new URL(url_text, window.location.href);
if (!u.protocol || !u.origin) return false;
// 只允许同源探测,避免跨域触发 CORS 失败导致控制台噪声/影响体验
if (u.origin !== window.location.origin) return false;
return true;
} catch (e) {
return false;
}
};
const probe_video = async () => {
const url = video_url_value_ref.value;
if (!can_probe_video_url(url)) return;
if (probe_loading.value) return;
probe_loading.value = true;
// 单次探测设置超时,避免弱网下长时间卡住
const controller = typeof AbortController !== "undefined" ? new AbortController() : null;
const timeout_id = setTimeout(() => controller?.abort?.(), 8000);
let timeout_id_2 = null;
try {
try {
// 先 HEAD,理论上成本最低,能直接拿到 content-length / content-type
const head_res = await fetch(url, {
method: "HEAD",
mode: "cors",
cache: "no-store",
signal: controller?.signal,
});
const content_length = head_res.headers.get("content-length");
probe_info.value = {
ok: head_res.ok,
status: head_res.status,
content_type: head_res.headers.get("content-type") || "",
content_length: content_length ? Number(content_length) || null : null,
accept_ranges: head_res.headers.get("accept-ranges") || "",
};
if (head_res.ok && probe_info.value.content_length) return;
} catch (e) {
void e;
}
// 部分服务器不支持 HEAD 或不返回 content-length,这里用 Range GET 兜底
const controller2 = typeof AbortController !== "undefined" ? new AbortController() : null;
timeout_id_2 = setTimeout(() => controller2?.abort?.(), 8000);
try {
const range_res = await fetch(url, {
method: "GET",
mode: "cors",
cache: "no-store",
// 只取前 2 字节,尽量减少流量;通过 content-range 推断总大小
headers: { Range: "bytes=0-1" },
signal: controller2?.signal,
});
const content_range = range_res.headers.get("content-range") || "";
const match = content_range.match(/\/(\d+)\s*$/);
const total = match ? Number(match[1]) || null : null;
const content_length = range_res.headers.get("content-length");
probe_info.value = {
ok: range_res.ok,
status: range_res.status,
content_type: range_res.headers.get("content-type") || "",
content_length: total || (content_length ? Number(content_length) || null : null),
accept_ranges: range_res.headers.get("accept-ranges") || "",
};
} catch (e) {
void e;
}
} finally {
if (timeout_id_2) clearTimeout(timeout_id_2);
clearTimeout(timeout_id);
probe_loading.value = false;
}
};
return {
probeInfo: probe_info,
probeLoading: probe_loading,
probeVideo: probe_video,
};
};
/*
* @Date: 2026-01-20 16:53:31
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2026-01-20 17:06:44
* @FilePath: /mlaj/src/composables/videoPlayerSource.js
* @Description: 文件描述
*/
/**
* @description 从 url(或文件名)中提取扩展名(小写,不含点)。支持 base_url 作为 URL 解析基准。
* @param {string} url 可能是完整 URL/相对路径/文件名
* @param {string=} base_url URL 解析基准(默认取 window.location.href)
* @returns {string}
*/
const getUrlPathExtension = (url, base_url) => {
const url_text = (url || "").trim();
if (!url_text) return "";
try {
const base = base_url || (typeof window !== "undefined" ? window.location?.href : undefined);
const u = base ? new URL(url_text, base) : new URL(url_text);
const pathname = u ? u.pathname : url_text;
const last_dot = pathname.lastIndexOf(".");
if (last_dot < 0) return "";
return pathname.slice(last_dot + 1).toLowerCase();
} catch (e) {
// URL 构造失败时,用字符串兜底(移除 query/hash 再取扩展名)
const without_query = url_text.split("?")[0].split("#")[0];
const last_dot = without_query.lastIndexOf(".");
if (last_dot < 0) return "";
return without_query.slice(last_dot + 1).toLowerCase();
}
};
/**
* @description 根据 url / video_id 推断 video MIME。用于 blob 地址等无法从 url 取扩展名的场景。
* @param {{url: string, video_id?: string, base_url?: string}} params
* @returns {string}
*/
const getVideoMimeType = ({ url, video_id, base_url }) => {
const url_text = (url || "").toLowerCase();
if (url_text.includes(".m3u8")) return "application/x-mpegURL";
// 1) 优先 url 扩展名;2) blob:xxx 这种取不到扩展名时,用 video_id 兜底(通常带 .mp4/.mov 等)
const ext = getUrlPathExtension(url_text, base_url) || getUrlPathExtension(video_id, base_url);
if (ext === "m3u8") return "application/x-mpegURL";
if (ext === "mp4" || ext === "m4v") return "video/mp4";
if (ext === "mov") return "video/quicktime";
if (ext === "webm") return "video/webm";
if (ext === "ogv" || ext === "ogg") return "video/ogg";
return "";
};
/**
* @description 从资源探测得到的 content-type 推断 video MIME。
* @param {string} content_type
* @returns {string}
*/
const inferVideoMimeTypeFromContentType = (content_type) => {
const probe_type = (content_type || "").toLowerCase();
return (probe_type.includes("application/vnd.apple.mpegurl") || probe_type.includes("application/x-mpegurl") ? "application/x-mpegURL" : "")
|| (probe_type.includes("video/mp4") ? "video/mp4" : "")
|| (probe_type.includes("video/quicktime") ? "video/quicktime" : "")
|| (probe_type.includes("video/webm") ? "video/webm" : "")
|| (probe_type.includes("video/ogg") ? "video/ogg" : "");
};
/**
* @description 构造 video.js sources。尽可能给出 type,避免旧设备/部分内核出现“无法识别资源”的报错。
* @param {{url: string, video_id?: string, probe_content_type?: string, base_url?: string}} params
* @returns {Array<{src: string, type?: string}>}
*/
const buildVideoSources = ({ url, video_id, probe_content_type, base_url }) => {
const inferred_type = getVideoMimeType({ url, video_id, base_url });
const type = inferred_type || inferVideoMimeTypeFromContentType(probe_content_type);
if (type) return [{ src: url, type }];
return [{ src: url }];
};
/**
* @description 判断当前浏览器是否原生支持 HLS(m3u8)。
* @returns {boolean}
*/
const canPlayHlsNatively = () => {
if (typeof document === "undefined") return false;
const el = document.createElement("video");
if (!el || typeof el.canPlayType !== "function") return false;
const r1 = el.canPlayType("application/vnd.apple.mpegurl");
const r2 = el.canPlayType("application/x-mpegURL");
return r1 === "probably" || r1 === "maybe" || r2 === "probably" || r2 === "maybe";
};
export {
getUrlPathExtension,
getVideoMimeType,
inferVideoMimeTypeFromContentType,
buildVideoSources,
canPlayHlsNatively,
};