hookehuyr

feat(视频播放器): 重构视频播放器逻辑并添加hls.js支持

将视频播放器核心逻辑抽离为useVideoPlayer组合式函数,优化错误处理和重试机制
添加hls.js依赖以支持HLS视频格式播放
简化VideoPlayer组件代码,提高可维护性
......@@ -37,6 +37,7 @@
"@vue-office/pptx": "^1.0.1",
"browser-md5-file": "^1.1.1",
"dayjs": "^1.11.13",
"hls.js": "^1.6.15",
"html-to-image": "^1.11.13",
"html2canvas": "^1.4.1",
"lodash": "^4.17.21",
......
......@@ -50,6 +50,9 @@ importers:
dayjs:
specifier: ^1.11.13
version: 1.11.19
hls.js:
specifier: ^1.6.15
version: 1.6.15
html-to-image:
specifier: ^1.11.13
version: 1.11.13
......@@ -1246,6 +1249,9 @@ packages:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'}
hls.js@1.6.15:
resolution: {integrity: sha512-E3a5VwgXimGHwpRGV+WxRTKeSp2DW5DI5MWv34ulL3t5UNmyJWCQ1KmLEHbYzcfThfXG8amBL+fCYPneGHC4VA==}
html-to-image@1.11.13:
resolution: {integrity: sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==}
......@@ -3266,6 +3272,8 @@ snapshots:
dependencies:
function-bind: 1.1.2
hls.js@1.6.15: {}
html-to-image@1.11.13: {}
html2canvas@1.4.1:
......
<template>
<div class="video-player-container">
<!-- 原生播放器 (iOS 微信等环境) -->
<video
v-if="useNativePlayer"
ref="nativeVideoRef"
......@@ -17,16 +18,19 @@
@play="handleNativePlay"
@pause="handleNativePause"
/>
<!-- Video.js 播放器 (PC/Android/其他环境) -->
<VideoPlayer
v-else
ref="videoRef"
:options="videoOptions"
playsinline
:class="['video-player', 'vjs-big-play-centered', { loading: !state }]"
@mounted="handleMounted"
@mounted="handleVideoJsMounted"
@play="handlePlay"
@pause="handlePause"
/>
<!-- 错误提示覆盖层 -->
<div v-if="showErrorOverlay" class="error-overlay">
<div class="error-content">
......@@ -39,11 +43,10 @@
</template>
<script setup>
import { ref, computed, onMounted, onBeforeUnmount, watch } from "vue";
import { ref } from "vue";
import { VideoPlayer } from "@videojs-player/vue";
import videojs from "video.js";
import "video.js/dist/video-js.css";
import { wxInfo } from "@/utils/tools"
import { useVideoPlayer } from "@/composables/useVideoPlayer";
const props = defineProps({
options: {
......@@ -67,449 +70,30 @@ const props = defineProps({
});
const emit = defineEmits(["onPlay", "onPause"]);
const videoRef = ref(null);
const nativeVideoRef = ref(null);
const player = ref(null);
const state = ref(null);
const showErrorOverlay = ref(false);
const errorMessage = ref('');
const retryCount = ref(0);
const maxRetries = 3;
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);
const useNativePlayer = computed(() => {
return wxInfo().isIOSWeChat;
});
const videoUrlValue = computed(() => {
return (props.videoUrl || "").trim();
});
const formatBytes = (bytes) => {
const size = Number(bytes) || 0;
if (!size) return "";
const kb = 1024;
const mb = kb * 1024;
const gb = mb * 1024;
if (size >= gb) return (size / gb).toFixed(2) + "GB";
if (size >= mb) return (size / mb).toFixed(2) + "MB";
if (size >= kb) return (size / kb).toFixed(2) + "KB";
return String(size) + "B";
};
const getErrorHint = () => {
if (probeInfo.value.status === 403) return "(403:无权限或已过期)";
if (probeInfo.value.status === 404) return "(404:资源不存在)";
if (probeInfo.value.status && probeInfo.value.status >= 500) return `(${probeInfo.value.status}:服务器异常)`;
const len = probeInfo.value.content_length;
if (len && len >= 1024 * 1024 * 1024) {
const text = formatBytes(len);
return text ? `(文件约${text},建议 WiFi)` : "(文件较大,建议 WiFi)";
}
return "";
};
const probeVideo = async () => {
const url = videoUrlValue.value;
if (!url || typeof fetch === "undefined") return;
if (probeLoading.value) return;
probeLoading.value = true;
const controller = typeof AbortController !== "undefined" ? new AbortController() : null;
const timeoutId = setTimeout(() => controller?.abort?.(), 8000);
const setBaseInfo = (res) => {
const contentLength = res.headers.get("content-length");
probeInfo.value = {
ok: res.ok,
status: res.status,
content_type: res.headers.get("content-type") || "",
content_length: contentLength ? Number(contentLength) || null : null,
accept_ranges: res.headers.get("accept-ranges") || "",
};
};
try {
const headRes = await fetch(url, {
method: "HEAD",
mode: "cors",
cache: "no-store",
signal: controller?.signal,
});
setBaseInfo(headRes);
if (headRes.ok && probeInfo.value.content_length) return;
} catch (e) {
probeInfo.value = {
ok: null,
status: null,
content_type: "",
content_length: null,
accept_ranges: "",
};
} finally {
clearTimeout(timeoutId);
probeLoading.value = false;
}
const controller2 = typeof AbortController !== "undefined" ? new AbortController() : null;
const 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 {
clearTimeout(timeoutId2);
}
};
const getVideoMimeType = (url) => {
const urlText = (url || "").toLowerCase();
if (urlText.includes(".m3u8")) return "application/x-mpegURL";
if (urlText.includes(".mp4")) return "video/mp4";
if (urlText.includes(".mov")) return "video/quicktime";
return "";
};
const videoSources = computed(() => {
const type = getVideoMimeType(videoUrlValue.value);
if (type) {
return [{ src: videoUrlValue.value, type }];
}
return [{ src: videoUrlValue.value }];
});
const videoOptions = computed(() => ({
controls: true,
preload: "metadata", // 改为metadata以减少初始加载
responsive: true,
autoplay: props.autoplay,
playsinline: true,
// 启用倍速播放功能
playbackRates: [0.5, 0.75, 1, 1.25, 1.5, 2],
sources: videoSources.value,
// HTML5配置优化
html5: {
vhs: {
overrideNative: !videojs.browser.IS_SAFARI,
},
nativeVideoTracks: false,
nativeAudioTracks: false,
nativeTextTracks: false,
},
// 错误处理配置
errorDisplay: true,
// 网络和加载配置
techOrder: ['html5'],
// onPlay: () => emit("onPlay"),
// onPause: () => emit("onPause"),
userActions: {
hotkeys: true,
doubleClick: true,
},
controlBar: {
progressControl: {
seekBar: {
mouseTimeDisplay: {
keepTooltipsInside: true,
},
},
},
},
...props.options,
}));
const applyNativeError = (mediaError) => {
if (!mediaError) return;
showErrorOverlay.value = true;
switch (mediaError.code) {
case 4:
errorMessage.value = '视频格式不支持或无法加载,请检查网络连接' + getErrorHint();
if (retryCount.value < maxRetries) {
setTimeout(() => {
retryLoad();
}, 1000);
}
break;
case 3:
errorMessage.value = '视频解码失败,可能是文件损坏';
break;
case 2:
errorMessage.value = '网络连接错误,请检查网络后重试' + getErrorHint();
if (retryCount.value < maxRetries) {
setTimeout(() => {
retryLoad();
}, 2000);
}
break;
case 1:
errorMessage.value = '视频加载被中止';
break;
default:
errorMessage.value = '视频播放出现未知错误';
}
};
const handleMounted = (payload) => {
console.log('VideoPlayer: handleMounted 被调用');
console.log('VideoPlayer: payload.player:', payload.player);
state.value = payload.state;
player.value = payload.player;
if (player.value) {
// 添加错误处理监听器
player.value.on('error', (error) => {
console.error('VideoJS播放错误:', error);
const errorCode = player.value.error();
if (errorCode) {
console.error('错误代码:', errorCode.code, '错误信息:', errorCode.message);
// 显示用户友好的错误信息
showErrorOverlay.value = true;
// 根据错误类型进行处理
switch (errorCode.code) {
case 4: // MEDIA_ERR_SRC_NOT_SUPPORTED
errorMessage.value = '视频格式不支持或无法加载,请检查网络连接' + getErrorHint();
console.warn('视频格式不支持,尝试重新加载...');
// 自动重试(如果重试次数未超限)
if (retryCount.value < maxRetries) {
setTimeout(() => {
retryLoad();
}, 1000);
}
break;
case 3: // MEDIA_ERR_DECODE
errorMessage.value = '视频解码失败,可能是文件损坏';
console.warn('视频解码错误');
break;
case 2: // MEDIA_ERR_NETWORK
errorMessage.value = '网络连接错误,请检查网络后重试' + getErrorHint();
console.warn('网络错误,尝试重新加载...');
if (retryCount.value < maxRetries) {
setTimeout(() => {
retryLoad();
}, 2000);
}
break;
case 1: // MEDIA_ERR_ABORTED
errorMessage.value = '视频加载被中止';
console.warn('视频加载被中止');
break;
default:
errorMessage.value = '视频播放出现未知错误';
}
}
});
// 添加加载状态监听
player.value.on('loadstart', () => {
console.log('开始加载视频');
showErrorOverlay.value = false; // 隐藏错误提示
});
player.value.on('canplay', () => {
console.log('视频可以播放');
showErrorOverlay.value = false; // 隐藏错误提示
retryCount.value = 0; // 重置重试计数
});
player.value.on('loadedmetadata', () => {
console.log('视频元数据加载完成');
});
// TAG: 自动播放
if (props.autoplay) {
player.value.play().catch(error => {
console.warn('自动播放失败:', error);
});
}
// if (!wxInfo().isPc && !wxInfo().isWeiXinDesktop) { // 非PC端,且非微信PC端
// // 监听视频播放状态
// player.value.on('play', () => {
// // 播放时隐藏controls
// // player.value.controlBar.hide();
// });
// player.value.on('pause', () => {
// })
// // 添加touchstart事件监听
// player.value.on('touchstart', (event) => {
// // 阻止事件冒泡,避免触发controls的默认行为
// event.preventDefault();
// event.stopPropagation();
// // 检查点击位置是否在controls区域
// const controlBar = player.value.getChild('ControlBar');
// const controlBarEl = controlBar && controlBar.el();
// if (controlBarEl && controlBarEl.contains(event.target)) {
// return; // 如果点击在controls区域,不执行自定义行为
// }
// if (player.value.paused()) {
// player.value.play();
// } else {
// player.value.pause();
// }
// });
// }
}
};
const handlePlay = (payload) => {
emit("onPlay", payload)
};
const handlePause = (payload) => {
emit("onPause", payload)
}
const handleNativePlay = (event) => {
emit("onPlay", event)
};
const handleNativePause = (event) => {
emit("onPause", event)
};
const tryNativePlay = () => {
const videoEl = nativeVideoRef.value;
if (!videoEl) return;
const playPromise = videoEl.play();
if (playPromise && typeof playPromise.catch === "function") {
playPromise.catch(() => {
if (typeof window !== "undefined" && window.WeixinJSBridge) {
window.WeixinJSBridge.invoke("getNetworkType", {}, () => {
videoEl.play().catch(() => { });
});
}
});
}
};
/**
* 重试加载视频
*/
const retryLoad = () => {
if (retryCount.value >= maxRetries) {
errorMessage.value = '重试次数已达上限,请稍后再试';
return;
}
retryCount.value++;
showErrorOverlay.value = false;
if (useNativePlayer.value) {
const videoEl = nativeVideoRef.value;
if (videoEl) {
console.log(`第${retryCount.value}次重试加载视频`);
nativeReady.value = false;
const currentSrc = videoEl.currentSrc || videoEl.src;
videoEl.pause();
videoEl.removeAttribute("src");
videoEl.load();
videoEl.src = currentSrc || videoUrlValue.value;
videoEl.load();
tryNativePlay();
}
return;
}
if (player.value && !player.value.isDisposed()) {
console.log(`第${retryCount.value}次重试加载视频`);
player.value.load();
}
};
onMounted(() => {
void probeVideo();
if (!useNativePlayer.value) return;
const videoEl = nativeVideoRef.value;
if (!videoEl) return;
const onLoadStart = () => {
showErrorOverlay.value = false;
nativeReady.value = false;
};
const onCanPlay = () => {
showErrorOverlay.value = false;
retryCount.value = 0;
nativeReady.value = true;
};
const onError = () => {
applyNativeError(videoEl.error);
};
videoEl.addEventListener("loadstart", onLoadStart);
videoEl.addEventListener("canplay", onCanPlay);
videoEl.addEventListener("error", onError);
nativeListeners = { videoEl, onLoadStart, onCanPlay, onError };
if (props.autoplay) {
tryNativePlay();
if (typeof document !== "undefined") {
document.addEventListener(
"WeixinJSBridgeReady",
() => {
tryNativePlay();
},
{ once: true }
);
}
}
});
onBeforeUnmount(() => {
if (nativeListeners?.videoEl) {
nativeListeners.videoEl.removeEventListener("loadstart", nativeListeners.onLoadStart);
nativeListeners.videoEl.removeEventListener("canplay", nativeListeners.onCanPlay);
nativeListeners.videoEl.removeEventListener("error", nativeListeners.onError);
nativeListeners = null;
}
if (videoRef.value?.$player) {
videoRef.value.$player.dispose();
}
});
watch(
() => videoUrlValue.value,
() => {
retryCount.value = 0;
showErrorOverlay.value = false;
void probeVideo();
}
);
const {
player,
state,
useNativePlayer,
videoUrlValue,
videoOptions,
showErrorOverlay,
errorMessage,
retryLoad,
handleVideoJsMounted,
tryNativePlay
} = useVideoPlayer(props, emit, videoRef, nativeVideoRef);
// 事件处理
const handlePlay = (payload) => emit("onPlay", payload);
const handlePause = (payload) => emit("onPause", payload);
const handleNativePlay = (event) => emit("onPlay", event);
const handleNativePause = (event) => emit("onPause", event);
// 暴露方法给父组件
defineExpose({
pause() {
if (useNativePlayer.value) {
......@@ -522,7 +106,7 @@ defineExpose({
return;
}
if (player.value && !player.value.isDisposed && typeof player.value.isDisposed === 'function' && !player.value.isDisposed() && typeof player.value.pause === 'function') {
if (player.value && !player.value.isDisposed()) {
try {
player.value.pause();
emit('onPause', player.value);
......@@ -536,61 +120,7 @@ defineExpose({
tryNativePlay();
return;
}
console.log('VideoPlayer: play() 被调用');
console.log('VideoPlayer: player.value:', player.value);
console.log('VideoPlayer: player.value?.isDisposed:', player.value?.isDisposed);
if (!player.value) {
console.error('VideoPlayer: player.value 不存在,播放器可能还没初始化');
return;
}
if (!player.value.isDisposed || typeof player.value.isDisposed !== 'function') {
console.error('VideoPlayer: isDisposed 方法不存在');
return;
}
if (player.value.isDisposed()) {
console.error('VideoPlayer: 播放器已被销毁');
return;
}
console.log('VideoPlayer: 尝试播放视频');
// 检查视频元素状态
try {
const tech = player.value.tech(true);
if (tech && tech.el) {
const videoEl = tech.el();
console.log('VideoPlayer: videoEl.readyState:', videoEl?.readyState, '(0=HAVE_NOTHING, 1=HAVE_METADATA, 2=HAVE_CURRENT_DATA, 3=HAVE_FUTURE_DATA, 4=HAVE_ENOUGH_DATA)');
console.log('VideoPlayer: videoEl.paused:', videoEl?.paused);
console.log('VideoPlayer: videoEl.duration:', videoEl?.duration);
console.log('VideoPlayer: videoEl.src:', videoEl?.src);
}
} catch (e) {
console.warn('VideoPlayer: 无法获取video元素:', e);
}
player.value.play()
.then(() => {
console.log('VideoPlayer: play() 成功');
})
.catch(error => {
console.error('VideoPlayer: play() 失败:', error.name, error.message);
// 如果是因为自动播放策略失败,可以静音重试
if (error.name === 'NotAllowedError') {
console.log('VideoPlayer: 浏览器阻止自动播放,尝试静音播放');
player.value.muted(true);
player.value.play()
.then(() => {
console.log('VideoPlayer: 静音播放成功');
})
.catch(err => {
console.error('VideoPlayer: 静音播放也失败:', err);
});
}
});
player.value?.play()?.catch(console.warn);
},
getPlayer() {
return useNativePlayer.value ? nativeVideoRef.value : player.value;
......
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue';
import { wxInfo } from "@/utils/tools";
import Hls from 'hls.js';
import videojs from "video.js";
/**
* 视频播放核心逻辑 Hook
* 处理不同环境下的播放器选择、HLS支持、自动播放策略等
*/
export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
// 播放器实例
const player = ref(null);
const state = ref(null);
const hlsInstance = ref(null);
// 错误处理相关
const showErrorOverlay = ref(false);
const errorMessage = ref('');
const retryCount = ref(0);
const maxRetries = 3;
// 原生播放器状态
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(() => {
// iOS 微信环境下强制使用原生播放器,因为 video.js 在此环境有兼容性问题
return wxInfo().isIOSWeChat;
});
// 2. 视频源处理
const videoUrlValue = computed(() => {
return (props.videoUrl || "").trim();
});
const isM3U8 = computed(() => {
const url = videoUrlValue.value.toLowerCase();
return url.includes('.m3u8');
});
const getVideoMimeType = (url) => {
const urlText = (url || "").toLowerCase();
if (urlText.includes(".m3u8")) return "application/x-mpegURL";
if (urlText.includes(".mp4")) return "video/mp4";
if (urlText.includes(".mov")) return "video/quicktime";
return "";
};
const videoSources = computed(() => {
const type = getVideoMimeType(videoUrlValue.value);
if (type) {
return [{ src: videoUrlValue.value, type }];
}
return [{ src: videoUrlValue.value }];
});
// 3. 错误处理逻辑
const formatBytes = (bytes) => {
const size = Number(bytes) || 0;
if (!size) return "";
const kb = 1024;
const mb = kb * 1024;
const gb = mb * 1024;
if (size >= gb) return (size / gb).toFixed(2) + "GB";
if (size >= mb) return (size / mb).toFixed(2) + "MB";
if (size >= kb) return (size / kb).toFixed(2) + "KB";
return String(size) + "B";
};
const getErrorHint = () => {
if (probeInfo.value.status === 403) return "(403:无权限或已过期)";
if (probeInfo.value.status === 404) return "(404:资源不存在)";
if (probeInfo.value.status && probeInfo.value.status >= 500) return `(${probeInfo.value.status}:服务器异常)`;
const len = probeInfo.value.content_length;
if (len && len >= 1024 * 1024 * 1024) {
const text = formatBytes(len);
return text ? `(文件约${text},建议 WiFi)` : "(文件较大,建议 WiFi)";
}
return "";
};
// 资源探测
const probeVideo = async () => {
const url = videoUrlValue.value;
if (!url || typeof fetch === "undefined") return;
if (probeLoading.value) return;
probeLoading.value = true;
const controller = typeof AbortController !== "undefined" ? new AbortController() : null;
const timeoutId = setTimeout(() => controller?.abort?.(), 8000);
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 请求失败
} finally {
clearTimeout(timeoutId);
probeLoading.value = false;
}
// 如果 HEAD 失败,尝试 GET Range 0-1
const controller2 = typeof AbortController !== "undefined" ? new AbortController() : null;
const 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 {
clearTimeout(timeoutId2);
}
};
const handleError = (code, message = '') => {
showErrorOverlay.value = true;
switch (code) {
case 4: // MEDIA_ERR_SRC_NOT_SUPPORTED
errorMessage.value = '视频格式不支持或无法加载,请检查网络连接' + getErrorHint();
if (retryCount.value < maxRetries) {
setTimeout(retryLoad, 1000);
}
break;
case 3: // MEDIA_ERR_DECODE
errorMessage.value = '视频解码失败,可能是文件损坏';
break;
case 2: // MEDIA_ERR_NETWORK
errorMessage.value = '网络连接错误,请检查网络后重试' + getErrorHint();
if (retryCount.value < maxRetries) {
setTimeout(retryLoad, 2000);
}
break;
case 1: // MEDIA_ERR_ABORTED
errorMessage.value = '视频加载被中止';
break;
default:
errorMessage.value = message || '视频播放出现未知错误';
}
};
// 4. 原生播放器逻辑 (iOS微信)
const initNativePlayer = () => {
const videoEl = nativeVideoRef.value;
if (!videoEl) return;
// 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;
};
const onCanPlay = () => {
showErrorOverlay.value = false;
retryCount.value = 0;
nativeReady.value = true;
};
const onError = () => {
handleError(videoEl.error?.code);
};
videoEl.addEventListener("loadstart", onLoadStart);
videoEl.addEventListener("canplay", onCanPlay);
videoEl.addEventListener("error", onError);
nativeListeners = { videoEl, onLoadStart, onCanPlay, onError };
if (props.autoplay) {
tryNativePlay();
if (typeof document !== "undefined") {
document.addEventListener(
"WeixinJSBridgeReady",
() => tryNativePlay(),
{ once: true }
);
}
}
};
const tryNativePlay = () => {
const videoEl = nativeVideoRef.value;
if (!videoEl) return;
const playPromise = videoEl.play();
if (playPromise && typeof playPromise.catch === "function") {
playPromise.catch(() => {
if (typeof window !== "undefined" && window.WeixinJSBridge) {
window.WeixinJSBridge.invoke("getNetworkType", {}, () => {
videoEl.play().catch(() => {});
});
}
});
}
};
// 5. Video.js 播放器逻辑 (PC/Android)
const videoOptions = computed(() => ({
controls: true,
preload: "metadata",
responsive: true,
autoplay: props.autoplay,
playsinline: true,
playbackRates: [0.5, 0.75, 1, 1.25, 1.5, 2],
sources: videoSources.value,
html5: {
vhs: {
overrideNative: !videojs.browser.IS_SAFARI, // 非 Safari 下使用 VHS 解析 HLS
},
nativeVideoTracks: false,
nativeAudioTracks: false,
nativeTextTracks: false,
hls: {
withCredentials: false
}
},
errorDisplay: true,
techOrder: ['html5'],
userActions: {
hotkeys: true,
doubleClick: true,
},
controlBar: {
progressControl: {
seekBar: {
mouseTimeDisplay: {
keepTooltipsInside: true,
},
},
},
},
...props.options,
}));
const handleVideoJsMounted = (payload) => {
state.value = payload.state;
player.value = payload.player;
if (player.value) {
player.value.on('error', () => {
const err = player.value.error();
handleError(err?.code, err?.message);
});
player.value.on('loadstart', () => {
showErrorOverlay.value = false;
});
player.value.on('canplay', () => {
showErrorOverlay.value = false;
retryCount.value = 0;
});
if (props.autoplay) {
player.value.play().catch(console.warn);
}
}
};
// 6. 重试逻辑
const retryLoad = () => {
if (retryCount.value >= maxRetries) {
errorMessage.value = '重试次数已达上限,请稍后再试';
return;
}
retryCount.value++;
showErrorOverlay.value = false;
if (useNativePlayer.value) {
const videoEl = nativeVideoRef.value;
if (videoEl) {
nativeReady.value = false;
const currentSrc = videoEl.currentSrc || videoEl.src;
videoEl.pause();
videoEl.removeAttribute("src");
videoEl.load();
videoEl.src = currentSrc || videoUrlValue.value;
videoEl.load();
tryNativePlay();
}
} else {
if (player.value && !player.value.isDisposed()) {
player.value.load();
}
}
};
// 7. 生命周期与监听
watch(() => videoUrlValue.value, () => {
retryCount.value = 0;
showErrorOverlay.value = false;
void probeVideo();
// 如果是原生播放器且 URL 变化,需要手动处理 HLS (如果是非 iOS Safari 环境)
if (useNativePlayer.value && isM3U8.value) {
// iOS 原生支持,不需要额外操作
// 如果未来支持 Android 原生播放器且不支持 HLS,需在此处初始化 hls.js
}
});
onMounted(() => {
void probeVideo();
if (useNativePlayer.value) {
initNativePlayer();
}
});
onBeforeUnmount(() => {
if (nativeListeners?.videoEl) {
nativeListeners.videoEl.removeEventListener("loadstart", nativeListeners.onLoadStart);
nativeListeners.videoEl.removeEventListener("canplay", nativeListeners.onCanPlay);
nativeListeners.videoEl.removeEventListener("error", nativeListeners.onError);
}
if (hlsInstance.value) {
hlsInstance.value.destroy();
}
if (videoRef.value?.$player) {
videoRef.value.$player.dispose();
}
});
return {
player,
state,
useNativePlayer,
videoUrlValue,
videoOptions,
showErrorOverlay,
errorMessage,
retryLoad,
handleVideoJsMounted,
tryNativePlay
};
}