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,
};
};
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue';
import { wxInfo } from "@/utils/tools";
import Hls from 'hls.js';
import videojs from "video.js";
import { buildVideoSources, canPlayHlsNatively } from "./videoPlayerSource";
import { useVideoProbe } from "./useVideoProbe";
import { useVideoPlaybackOverlays } from "./useVideoPlaybackOverlays";
// 新增:引入多码率切换插件
import 'videojs-contrib-quality-levels'; // 用于读取 m3u8 中的多码率信息
import 'videojs-hls-quality-selector'; // 用于在播放器控制条显示“清晰度”切换菜单(支持 Auto/720p/480p 等)。
......@@ -16,12 +18,32 @@ import 'videojs-hls-quality-selector/dist/videojs-hls-quality-selector.css';
/**
* 视频播放核心逻辑 Hook
* 处理不同环境下的播放器选择、HLS支持、自动播放策略等
* @description 根据环境选择原生 video 或 video.js,并处理弱网提示、错误重试与清晰度选择等逻辑。
* @param {any} props 组件 props(需要包含 videoUrl/autoplay/useNativeOnIos/options/debug/videoId 等字段)
* @param {(event: string, ...args: any[]) => void} emit 组件 emit
* @param {import("vue").Ref<any>} videoRef videojs-player 组件 ref(用于 dispose)
* @param {import("vue").Ref<HTMLVideoElement|null>} nativeVideoRef 原生 video 元素 ref(iOS 微信)
* @returns {{
* player: import("vue").Ref<any>,
* state: import("vue").Ref<any>,
* useNativePlayer: import("vue").ComputedRef<boolean>,
* videoUrlValue: import("vue").ComputedRef<string>,
* videoOptions: import("vue").ComputedRef<any>,
* showErrorOverlay: import("vue").Ref<boolean>,
* errorMessage: import("vue").Ref<string>,
* showNetworkSpeedOverlay: import("vue").Ref<boolean>,
* networkSpeedText: import("vue").Ref<string>,
* hlsDownloadSpeedText: import("vue").Ref<string>,
* hlsSpeedDebugText: import("vue").Ref<string>,
* retryLoad: () => void,
* handleVideoJsMounted: (payload: {player: any, state: any}) => void,
* tryNativePlay: () => void
* }}
*/
export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
// 播放器实例
const player = ref(null);
const state = ref(null);
const hlsInstance = ref(null);
// 错误处理相关
const showErrorOverlay = ref(false);
......@@ -29,55 +51,13 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
const retryCount = ref(0);
const maxRetries = 3;
const showNetworkSpeedOverlay = ref(false);
const networkSpeedText = ref('');
const hasEverPlayed = ref(false);
let networkSpeedTimer = null;
let lastWeixinNetworkTypeAt = 0;
const hlsDownloadSpeedText = ref('');
let hlsSpeedTimer = null;
const hasStartedPlayback = ref(false);
const hlsSpeedDebugText = ref('');
const setHlsDebug = (eventName, extra) => {
if (!props || props.debug !== true) return;
const p = player.value;
const hasPlayer = !!p && !p.isDisposed?.();
let tech = null;
try {
tech = hasPlayer && typeof p.tech === 'function' ? p.tech(true) : null;
} catch (e) {
tech = null;
}
const stableTech = tech || (hasPlayer ? p.tech_ : null);
const techName = hasPlayer
? (p.techName_ || (stableTech && stableTech.name_) || (stableTech && stableTech.constructor && stableTech.constructor.name) || 'unknown')
: 'none';
const mode = useNativePlayer.value ? 'native' : 'videojs';
const vhs = stableTech && stableTech.vhs ? stableTech.vhs : null;
const hls = stableTech && stableTech.hls ? stableTech.hls : null;
const bwKbps = vhs && typeof vhs.bandwidth === 'number' ? Math.round(vhs.bandwidth / 1000) : 0;
const hlsBwKbps = hls && typeof hls.bandwidth === 'number' ? Math.round(hls.bandwidth / 1000) : 0;
const extraText = extra ? ` ${extra}` : '';
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`;
};
// 原生播放器状态
const nativeReady = ref(false);
let nativeListeners = null;
// 资源探测信息
const probeInfo = ref({
ok: null,
status: null,
content_type: "",
content_length: null,
accept_ranges: "",
});
const probeLoading = ref(false);
// 1. 环境判断与播放器选择
const useNativePlayer = computed(() => {
// 如果 props 强制关闭原生播放器,则返回 false (使用 Video.js)
......@@ -99,50 +79,35 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
return url.includes('.m3u8');
});
// 4. 视频类型判断
const getUrlPathExtension = (url) => {
const urlText = (url || "").trim();
if (!urlText) return "";
try {
const u = typeof window !== "undefined" ? new URL(urlText, window.location?.href) : null;
const pathname = u ? u.pathname : urlText;
const lastDot = pathname.lastIndexOf(".");
if (lastDot < 0) return "";
return pathname.slice(lastDot + 1).toLowerCase();
} catch (e) {
const withoutQuery = urlText.split("?")[0].split("#")[0];
const lastDot = withoutQuery.lastIndexOf(".");
if (lastDot < 0) return "";
return withoutQuery.slice(lastDot + 1).toLowerCase();
}
};
// 资源探测:只在“同源可探测”时执行,避免跨域 CORS 报错影响体验
const { probeInfo, probeVideo } = useVideoProbe(videoUrlValue);
const getVideoMimeType = (url) => {
const urlText = (url || "").toLowerCase();
if (urlText.includes(".m3u8")) return "application/x-mpegURL";
const ext = getUrlPathExtension(urlText) || getUrlPathExtension(props?.videoId);
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 "";
};
// 视频源构造:尽可能带上 type,老设备/部分内核对 blob/部分后缀会更稳定
const videoSources = computed(() => buildVideoSources({
url: videoUrlValue.value,
video_id: props?.videoId,
probe_content_type: probeInfo.value.content_type,
}));
// 5. 视频源配置
const videoSources = computed(() => {
const inferredType = getVideoMimeType(videoUrlValue.value);
const probeType = (probeInfo.value.content_type || "").toLowerCase();
const type = inferredType
|| (probeType.includes("application/vnd.apple.mpegurl") || probeType.includes("application/x-mpegurl") ? "application/x-mpegURL" : "")
|| (probeType.includes("video/mp4") ? "video/mp4" : "")
|| (probeType.includes("video/quicktime") ? "video/quicktime" : "")
|| (probeType.includes("video/webm") ? "video/webm" : "")
|| (probeType.includes("video/ogg") ? "video/ogg" : "");
if (type) {
return [{ src: videoUrlValue.value, type }];
}
return [{ src: videoUrlValue.value }];
// 播放叠层:弱网提示 + HLS 速度展示(仅 video.js + m3u8)
const {
showNetworkSpeedOverlay,
networkSpeedText,
hlsDownloadSpeedText,
hlsSpeedDebugText,
setHlsDebug,
showNetworkSpeed,
hideNetworkSpeed,
startHlsDownloadSpeed,
stopHlsDownloadSpeed,
disposeOverlays,
} = useVideoPlaybackOverlays({
props,
player,
is_m3u8: isM3U8,
use_native_player: useNativePlayer,
show_error_overlay: showErrorOverlay,
has_started_playback: hasStartedPlayback,
});
// 6. 错误处理逻辑
......@@ -171,100 +136,14 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
return "";
};
const canProbeVideoUrl = (url) => {
const urlText = (url || "").trim();
if (!urlText) return false;
if (typeof window === "undefined" || typeof window.location === "undefined") return false;
if (typeof fetch === "undefined") return false;
if (/^(blob:|data:)/i.test(urlText)) return false;
try {
const u = new URL(urlText, window.location.href);
if (!u.protocol || !u.origin) return false;
if (u.origin !== window.location.origin) return false;
return true;
} catch (e) {
return false;
}
};
// 资源探测
const probeVideo = async () => {
const url = videoUrlValue.value;
if (!canProbeVideoUrl(url)) return;
if (probeLoading.value) return;
probeLoading.value = true;
const controller = typeof AbortController !== "undefined" ? new AbortController() : null;
const timeoutId = setTimeout(() => controller?.abort?.(), 8000);
let controller2 = null;
let timeoutId2 = null;
try {
try {
const headRes = await fetch(url, {
method: "HEAD",
mode: "cors",
cache: "no-store",
signal: controller?.signal,
});
const contentLength = headRes.headers.get("content-length");
probeInfo.value = {
ok: headRes.ok,
status: headRes.status,
content_type: headRes.headers.get("content-type") || "",
content_length: contentLength ? Number(contentLength) || null : null,
accept_ranges: headRes.headers.get("accept-ranges") || "",
};
if (headRes.ok && probeInfo.value.content_length) return;
} catch (e) {
// 忽略 HEAD 请求失败
}
controller2 = typeof AbortController !== "undefined" ? new AbortController() : null;
timeoutId2 = setTimeout(() => controller2?.abort?.(), 8000);
try {
const rangeRes = await fetch(url, {
method: "GET",
mode: "cors",
cache: "no-store",
headers: { Range: "bytes=0-1" },
signal: controller2?.signal,
});
const contentRange = rangeRes.headers.get("content-range") || "";
const match = contentRange.match(/\/(\d+)\s*$/);
const total = match ? Number(match[1]) || null : null;
const contentLength = rangeRes.headers.get("content-length");
probeInfo.value = {
ok: rangeRes.ok,
status: rangeRes.status,
content_type: rangeRes.headers.get("content-type") || "",
content_length: total || (contentLength ? Number(contentLength) || null : null),
accept_ranges: rangeRes.headers.get("accept-ranges") || "",
};
} catch (e) {
// 忽略错误
}
} finally {
if (timeoutId2) clearTimeout(timeoutId2);
clearTimeout(timeoutId);
probeLoading.value = false;
}
};
// 7. 错误处理逻辑
const handleError = (code, message = '') => {
showErrorOverlay.value = true;
showNetworkSpeedOverlay.value = false;
if (networkSpeedTimer) {
clearInterval(networkSpeedTimer);
networkSpeedTimer = null;
}
hideNetworkSpeed();
switch (code) {
case 4: // MEDIA_ERR_SRC_NOT_SUPPORTED
errorMessage.value = '视频格式不支持或无法加载,请检查网络连接' + getErrorHint();
// 旧机型/弱网下可能出现短暂的“无法加载”,这里做有限次数重试
if (retryCount.value < maxRetries) {
setTimeout(retryLoad, 1000);
}
......@@ -286,126 +165,6 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
}
};
const updateNetworkSpeed = () => {
if (typeof navigator === 'undefined') {
networkSpeedText.value = '未知';
return false;
}
const connection = navigator.connection || navigator.mozConnection || navigator.webkitConnection;
const downlink = connection && typeof connection.downlink === 'number' ? connection.downlink : null;
if (downlink && downlink > 0) {
networkSpeedText.value = `${downlink.toFixed(1)} Mbps`;
return true;
}
const effectiveType = connection && typeof connection.effectiveType === 'string' ? connection.effectiveType : '';
networkSpeedText.value = effectiveType ? `${effectiveType}` : '未知';
return effectiveType ? true : false;
};
const updateWeixinNetworkType = () => {
if (typeof window === 'undefined') return;
if (!window.WeixinJSBridge || typeof window.WeixinJSBridge.invoke !== 'function') return;
const now = Date.now();
if (now - lastWeixinNetworkTypeAt < 3000) return;
lastWeixinNetworkTypeAt = now;
window.WeixinJSBridge.invoke('getNetworkType', {}, (res) => {
const type = (res && (res.networkType || res.network_type)) ? String(res.networkType || res.network_type) : '';
if (type) networkSpeedText.value = type;
});
};
const showNetworkSpeed = () => {
if (!hasStartedPlayback.value) return;
if (showErrorOverlay.value) return;
if (showNetworkSpeedOverlay.value) return;
showNetworkSpeedOverlay.value = true;
const ok = updateNetworkSpeed();
if (!ok) updateWeixinNetworkType();
if (networkSpeedTimer) clearInterval(networkSpeedTimer);
networkSpeedTimer = setInterval(() => {
const ok2 = updateNetworkSpeed();
if (!ok2) updateWeixinNetworkType();
}, 800);
};
const hideNetworkSpeed = () => {
showNetworkSpeedOverlay.value = false;
if (networkSpeedTimer) {
clearInterval(networkSpeedTimer);
networkSpeedTimer = null;
}
};
const formatSpeed = (bytesPerSecond) => {
const size = Number(bytesPerSecond) || 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 updateHlsDownloadSpeed = () => {
if (!player.value || player.value.isDisposed()) {
hlsDownloadSpeedText.value = '';
setHlsDebug('update', 'player:empty');
return;
}
if (!isM3U8.value || useNativePlayer.value) {
hlsDownloadSpeedText.value = '';
setHlsDebug('update', 'skip');
return;
}
let tech = null;
try {
tech = typeof player.value.tech === 'function' ? player.value.tech(true) : null;
} catch (e) {
tech = null;
}
const stableTech = tech || player.value.tech_ || null;
const vhs = stableTech && stableTech.vhs ? stableTech.vhs : null;
const hls = stableTech && stableTech.hls ? stableTech.hls : null;
const bandwidthBitsPerSecond = (vhs && typeof vhs.bandwidth === 'number' ? vhs.bandwidth : null)
|| (hls && typeof hls.bandwidth === 'number' ? hls.bandwidth : null);
if (!bandwidthBitsPerSecond || bandwidthBitsPerSecond <= 0) {
hlsDownloadSpeedText.value = '';
setHlsDebug('update', 'bw:0');
return;
}
hlsDownloadSpeedText.value = formatSpeed(bandwidthBitsPerSecond / 8);
setHlsDebug('update', `speed:${hlsDownloadSpeedText.value}`);
};
const startHlsDownloadSpeed = () => {
if (hlsSpeedTimer) return;
if (!isM3U8.value || useNativePlayer.value) return;
setHlsDebug('start');
updateHlsDownloadSpeed();
hlsSpeedTimer = setInterval(() => {
updateHlsDownloadSpeed();
}, 1000);
};
const stopHlsDownloadSpeed = (reason) => {
if (hlsSpeedTimer) {
clearInterval(hlsSpeedTimer);
hlsSpeedTimer = null;
}
hlsDownloadSpeedText.value = '';
setHlsDebug('stop', reason || '');
};
// 4. 原生播放器逻辑 (iOS微信)
const initNativePlayer = () => {
const videoEl = nativeVideoRef.value;
......@@ -413,14 +172,7 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
setHlsDebug('native:init');
// HLS 处理
if (isM3U8.value && Hls.isSupported()) {
// 如果原生支持 HLS (iOS Safari),直接用 src 即可,不需要 hls.js
// 但如果是安卓微信等不支持原生 HLS 的环境,才需要 hls.js
// 由于 useNativePlayer 仅针对 iOS 微信,而 iOS 原生支持 HLS,所以这里直接赋值 src 即可
// 无需额外操作
}
// 原生播放器走系统内核:事件主要用于控制弱网提示与错误覆盖层
const onLoadStart = () => {
showErrorOverlay.value = false;
nativeReady.value = false;
......@@ -486,6 +238,7 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
};
if (props.autoplay) {
// iOS 微信 autoplay 需要用户手势/桥接事件配合,先尝试一次,再在 WeixinJSBridgeReady 时再试
tryNativePlay();
if (typeof document !== "undefined") {
document.addEventListener(
......@@ -514,18 +267,10 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
};
// 5. Video.js 播放器逻辑 (PC/Android)
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";
};
const shouldOverrideNativeHls = computed(() => {
if (!isM3U8.value) return false;
if (videojs.browser.IS_SAFARI) return false;
// 非 Safari 且不具备原生 HLS 时,强制 video.js 的 VHS 来解 m3u8
return !canPlayHlsNatively();
});
......@@ -590,6 +335,7 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
tech = null;
}
if (!tech) return;
// videojs-hls-quality-selector 旧版本依赖 tech.hls,而 video.js 7 默认是 tech.vhs,这里做兼容别名
if (!tech.hls && tech.vhs) {
try {
tech.hls = tech.vhs;
......@@ -642,6 +388,7 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
player.value.on('waiting', () => {
if (!hasEverPlayed.value) return;
if (player.value?.paused?.()) return;
// 已经播放过且当前未暂停,才认为是“卡顿等待”,显示弱网提示
showNetworkSpeed();
startHlsDownloadSpeed();
setHlsDebug('waiting');
......@@ -686,6 +433,7 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
stopHlsDownloadSpeed('retry');
if (useNativePlayer.value) {
// 原生 video 需要手动重置 src/load
const videoEl = nativeVideoRef.value;
if (videoEl) {
nativeReady.value = false;
......@@ -698,6 +446,7 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
tryNativePlay();
}
} else {
// video.js 走自身 load 刷新
if (player.value && !player.value.isDisposed()) {
player.value.load();
}
......@@ -712,6 +461,7 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
stopHlsDownloadSpeed('url_change');
hasEverPlayed.value = false;
hasStartedPlayback.value = false;
// 地址变更后刷新探测信息,错误提示会基于 probeInfo 补充更准确的原因
void probeVideo();
// 如果是原生播放器且 URL 变化,需要手动处理 HLS (如果是非 iOS Safari 环境)
......@@ -740,12 +490,7 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
nativeListeners.videoEl.removeEventListener("playing", nativeListeners.onPlaying);
}
if (hlsInstance.value) {
hlsInstance.value.destroy();
}
hideNetworkSpeed();
stopHlsDownloadSpeed('unmount');
disposeOverlays();
if (videoRef.value?.$player) {
videoRef.value.$player.dispose();
}
......
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,
};