hookehuyr

feat(VideoPlayer): 添加iOS微信环境下的原生视频播放支持

为iOS微信环境添加原生video元素支持,优化视频播放体验
添加视频资源探测功能,提供更好的错误提示和重试机制
重构视频源处理逻辑,支持自动识别视频格式
<template>
<div class="video-player-container">
<video
v-if="useNativePlayer"
ref="nativeVideoRef"
class="video-player"
:src="videoUrlValue"
:autoplay="props.autoplay"
:muted="props.autoplay"
controls
playsinline
webkit-playsinline="true"
x5-playsinline="true"
x5-video-player-type="h5"
x5-video-player-fullscreen="true"
preload="metadata"
@play="handleNativePlay"
@pause="handleNativePause"
/>
<VideoPlayer
v-else
ref="videoRef"
:options="videoOptions"
crossorigin="anonymous"
playsinline
:class="['video-player', 'vjs-big-play-centered', { loading: !state }]"
@mounted="handleMounted"
......@@ -22,7 +39,7 @@
</template>
<script setup>
import { ref, computed, onMounted, onBeforeUnmount } from "vue";
import { ref, computed, onMounted, onBeforeUnmount, watch } from "vue";
import { VideoPlayer } from "@videojs-player/vue";
import videojs from "video.js";
import "video.js/dist/video-js.css";
......@@ -51,36 +68,151 @@ 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: [
{
src: props.videoUrl,
type: "video/mp4",
},
// 备用源,如果主源失败则尝试其他格式
{
src: props.videoUrl,
type: "video/webm",
},
{
src: props.videoUrl,
type: "video/ogg",
},
],
sources: videoSources.value,
// HTML5配置优化
html5: {
vhs: {
......@@ -112,6 +244,37 @@ const videoOptions = computed(() => ({
...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);
......@@ -131,7 +294,7 @@ const handleMounted = (payload) => {
// 根据错误类型进行处理
switch (errorCode.code) {
case 4: // MEDIA_ERR_SRC_NOT_SUPPORTED
errorMessage.value = '视频格式不支持或无法加载,请检查网络连接';
errorMessage.value = '视频格式不支持或无法加载,请检查网络连接' + getErrorHint();
console.warn('视频格式不支持,尝试重新加载...');
// 自动重试(如果重试次数未超限)
if (retryCount.value < maxRetries) {
......@@ -145,7 +308,7 @@ const handleMounted = (payload) => {
console.warn('视频解码错误');
break;
case 2: // MEDIA_ERR_NETWORK
errorMessage.value = '网络连接错误,请检查网络后重试';
errorMessage.value = '网络连接错误,请检查网络后重试' + getErrorHint();
console.warn('网络错误,尝试重新加载...');
if (retryCount.value < maxRetries) {
setTimeout(() => {
......@@ -225,6 +388,30 @@ 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(() => { });
});
}
});
}
};
/**
* 重试加载视频
*/
......@@ -237,20 +424,104 @@ const retryLoad = () => {
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();
}
);
defineExpose({
pause() {
if (useNativePlayer.value) {
try {
nativeVideoRef.value?.pause?.();
emit('onPause', nativeVideoRef.value);
} catch (e) {
console.warn('Video pause error:', e);
}
return;
}
if (player.value && !player.value.isDisposed && typeof player.value.isDisposed === 'function' && !player.value.isDisposed() && typeof player.value.pause === 'function') {
try {
player.value.pause();
......@@ -261,6 +532,11 @@ defineExpose({
}
},
play() {
if (useNativePlayer.value) {
tryNativePlay();
return;
}
console.log('VideoPlayer: play() 被调用');
console.log('VideoPlayer: player.value:', player.value);
console.log('VideoPlayer: player.value?.isDisposed:', player.value?.isDisposed);
......@@ -317,7 +593,7 @@ defineExpose({
});
},
getPlayer() {
return player.value;
return useNativePlayer.value ? nativeVideoRef.value : player.value;
},
getId() {
return props.videoId || "meta_id";
......