hookehuyr

fix(video): 扩展原生播放器到 Android 微信环境

- 修复 Android 微信用户无法观看视频的问题
- Video.js 在微信 X5 内核下存在兼容性问题
- 移动端微信环境统一使用原生 <video> 播放器
- 权衡:移动端微信用户失去清晰度切换功能

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue';
import { wxInfo } from "@/utils/tools";
import { buildVideoSources, canPlayHlsNatively } from "./videoPlayerSource";
import { useVideoProbe } from "./useVideoProbe";
import { useVideoPlaybackOverlays } from "./useVideoPlaybackOverlays";
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import { wxInfo } from '@/utils/tools'
import { buildVideoSources, canPlayHlsNatively } from './videoPlayerSource'
import { useVideoProbe } from './useVideoProbe'
import { useVideoPlaybackOverlays } from './useVideoPlaybackOverlays'
const is_safari_browser = () => {
if (typeof navigator === 'undefined') return false;
const ua = navigator.userAgent || '';
const is_safari = /safari/i.test(ua) && !/chrome|crios|android|fxios|edg/i.test(ua);
return is_safari;
};
if (typeof navigator === 'undefined') return false
const ua = navigator.userAgent || ''
const is_safari = /safari/i.test(ua) && !/chrome|crios|android|fxios|edg/i.test(ua)
return is_safari
}
/**
* - 使用方法 :您无需修改业务代码。只要传入的视频 URL 是七牛云生成的多码率 .m3u8 地址,播放器控制条右下角会自动出现“齿轮”图标,用户点击即可切换清晰度(或选择 Auto 自动切换)。
......@@ -44,54 +44,56 @@ const is_safari_browser = () => {
*/
export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
// 播放器实例
const player = ref(null);
const state = 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 canRetry = computed(() => retryCount.value < maxRetries);
const showErrorOverlay = ref(false)
const errorMessage = ref('')
const retryCount = ref(0)
const maxRetries = 3
const canRetry = computed(() => retryCount.value < maxRetries)
const hasEverPlayed = ref(false);
const hasStartedPlayback = ref(false);
const hasEverPlayed = ref(false)
const hasStartedPlayback = ref(false)
// 原生播放器状态
const nativeReady = ref(false);
let nativeListeners = null;
let retry_error_check_timer = null;
const nativeReady = ref(false)
let nativeListeners = null
let retry_error_check_timer = null
// 1. 环境判断与播放器选择
const useNativePlayer = computed(() => {
// 如果 props 强制关闭原生播放器,则返回 false (使用 Video.js)
if (props.useNativeOnIos === false) {
return false;
return false
}
// 默认逻辑:iOS 微信环境下使用原生播放器
return wxInfo().isIOSWeChat;
});
// 扩展逻辑:iOS 微信 + Android 微信都使用原生播放器
// 理由:微信 X5 内核对原生 <video> 支持最好,避开 Video.js 在 X5 下的兼容性问题
const info = wxInfo()
return (info.isIOS && info.isWeiXin) || (info.isAndroid && info.isWeiXin)
})
// 2. 视频源处理
const videoUrlValue = computed(() => {
return (props.videoUrl || "").trim();
});
const videoUrlValue = computed(() => (props.videoUrl || '').trim())
// 3. HLS 支持判断
const isM3U8 = computed(() => {
const url = videoUrlValue.value.toLowerCase();
return url.includes('.m3u8');
});
const url = videoUrlValue.value.toLowerCase()
return url.includes('.m3u8')
})
// 资源探测:只在“同源可探测”时执行,避免跨域 CORS 报错影响体验
const { probeInfo, probeVideo } = useVideoProbe(videoUrlValue);
const { probeInfo, probeVideo } = useVideoProbe(videoUrlValue)
// 视频源构造:尽可能带上 type,老设备/部分内核对 blob/部分后缀会更稳定
const videoSources = computed(() => buildVideoSources({
const videoSources = computed(() =>
buildVideoSources({
url: videoUrlValue.value,
video_id: props?.videoId,
probe_content_type: probeInfo.value.content_type,
}));
})
)
// 播放叠层:弱网提示 + HLS 速度展示(仅 video.js + m3u8)
const {
......@@ -112,122 +114,123 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
use_native_player: useNativePlayer,
show_error_overlay: showErrorOverlay,
has_started_playback: hasStartedPlayback,
});
})
// 6. 错误处理逻辑
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 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}:服务器异常)`;
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;
const len = probeInfo.value.content_length
if (len && len >= 1024 * 1024 * 1024) {
const text = formatBytes(len);
return text ? `(文件约${text},建议 WiFi)` : "(文件较大,建议 WiFi)";
const text = formatBytes(len)
return text ? `(文件约${text},建议 WiFi)` : '(文件较大,建议 WiFi)'
}
return ''
}
return "";
};
// 7. 错误处理逻辑
const handleError = (code, message = '') => {
showErrorOverlay.value = true;
hideNetworkSpeed();
showErrorOverlay.value = true
hideNetworkSpeed()
switch (code) {
case 4: // MEDIA_ERR_SRC_NOT_SUPPORTED
errorMessage.value = '视频格式不支持或无法加载,请检查网络连接' + getErrorHint();
errorMessage.value = `视频格式不支持或无法加载,请检查网络连接${getErrorHint()}`
// 旧机型/弱网下可能出现短暂的“无法加载”,这里做有限次数重试
// if (retryCount.value < maxRetries) {
// setTimeout(retryLoad, 1000);
// }
break;
break
case 3: // MEDIA_ERR_DECODE
errorMessage.value = '视频解码失败,可能是文件损坏';
break;
errorMessage.value = '视频解码失败,可能是文件损坏'
break
case 2: // MEDIA_ERR_NETWORK
errorMessage.value = '网络连接错误,请检查网络后重试' + getErrorHint();
errorMessage.value = `网络连接错误,请检查网络后重试${getErrorHint()}`
if (retryCount.value < maxRetries) {
setTimeout(retryLoad, 2000);
setTimeout(retryLoad, 2000)
}
break;
break
case 1: // MEDIA_ERR_ABORTED
errorMessage.value = '视频加载被中止';
break;
errorMessage.value = '视频加载被中止'
break
default:
errorMessage.value = message || '视频播放出现未知错误';
errorMessage.value = message || '视频播放出现未知错误'
}
}
};
// 4. 原生播放器逻辑 (iOS微信)
const initNativePlayer = () => {
const videoEl = nativeVideoRef.value;
if (!videoEl) return;
const videoEl = nativeVideoRef.value
if (!videoEl) return
setHlsDebug('native:init');
setHlsDebug('native:init')
// 原生播放器走系统内核:事件主要用于控制弱网提示与错误覆盖层
const onLoadStart = () => {
showErrorOverlay.value = false;
nativeReady.value = false;
};
showErrorOverlay.value = false
nativeReady.value = false
}
const onCanPlay = () => {
showErrorOverlay.value = false;
retryCount.value = 0;
nativeReady.value = true;
};
showErrorOverlay.value = false
retryCount.value = 0
nativeReady.value = true
}
const onError = () => {
handleError(videoEl.error?.code);
};
handleError(videoEl.error?.code)
}
const onPlay = () => {
hideNetworkSpeed();
setHlsDebug('native:play');
};
hideNetworkSpeed()
setHlsDebug('native:play')
}
const onPause = () => {
hideNetworkSpeed();
};
hideNetworkSpeed()
}
const onWaiting = () => {
if (videoEl.paused) return;
showNetworkSpeed();
setHlsDebug('native:waiting');
};
if (videoEl.paused) return
showNetworkSpeed()
setHlsDebug('native:waiting')
}
const onStalled = () => {
if (videoEl.paused) return;
showNetworkSpeed();
setHlsDebug('native:stalled');
};
if (videoEl.paused) return
showNetworkSpeed()
setHlsDebug('native:stalled')
}
const onPlaying = () => {
hasEverPlayed.value = true;
hasStartedPlayback.value = true;
hideNetworkSpeed();
setHlsDebug('native:playing');
};
videoEl.addEventListener("loadstart", onLoadStart);
videoEl.addEventListener("canplay", onCanPlay);
videoEl.addEventListener("error", onError);
videoEl.addEventListener("play", onPlay);
videoEl.addEventListener("pause", onPause);
videoEl.addEventListener("waiting", onWaiting);
videoEl.addEventListener("stalled", onStalled);
videoEl.addEventListener("playing", onPlaying);
hasEverPlayed.value = true
hasStartedPlayback.value = true
hideNetworkSpeed()
setHlsDebug('native:playing')
}
videoEl.addEventListener('loadstart', onLoadStart)
videoEl.addEventListener('canplay', onCanPlay)
videoEl.addEventListener('error', onError)
videoEl.addEventListener('play', onPlay)
videoEl.addEventListener('pause', onPause)
videoEl.addEventListener('waiting', onWaiting)
videoEl.addEventListener('stalled', onStalled)
videoEl.addEventListener('playing', onPlaying)
nativeListeners = {
videoEl,
......@@ -239,49 +242,45 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
onWaiting,
onStalled,
onPlaying,
};
}
if (props.autoplay) {
// iOS 微信 autoplay 需要用户手势/桥接事件配合,先尝试一次,再在 WeixinJSBridgeReady 时再试
tryNativePlay();
if (typeof document !== "undefined") {
document.addEventListener(
"WeixinJSBridgeReady",
() => tryNativePlay(),
{ once: true }
);
tryNativePlay()
if (typeof document !== 'undefined') {
document.addEventListener('WeixinJSBridgeReady', () => tryNativePlay(), { once: true })
}
}
}
};
const tryNativePlay = () => {
const videoEl = nativeVideoRef.value;
if (!videoEl) return;
const videoEl = nativeVideoRef.value
if (!videoEl) return
const playPromise = videoEl.play();
if (playPromise && typeof playPromise.catch === "function") {
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(() => {});
});
if (typeof window !== 'undefined' && window.WeixinJSBridge) {
window.WeixinJSBridge.invoke('getNetworkType', {}, () => {
videoEl.play().catch(() => {})
})
}
})
}
});
}
};
// 5. Video.js 播放器逻辑 (PC/Android)
const shouldOverrideNativeHls = computed(() => {
if (!isM3U8.value) return false;
if (is_safari_browser()) return false;
if (!isM3U8.value) return false
if (is_safari_browser()) return false
// 非 Safari 且不具备原生 HLS 时,强制 video.js 的 VHS 来解 m3u8
return !canPlayHlsNatively();
});
return !canPlayHlsNatively()
})
const videoOptions = computed(() => {
const base = {
controls: true,
preload: "metadata",
preload: 'metadata',
responsive: true,
autoplay: props.autoplay,
playsinline: true,
......@@ -295,8 +294,8 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
nativeAudioTracks: false,
nativeTextTracks: false,
hls: {
withCredentials: false
}
withCredentials: false,
},
},
techOrder: ['html5'],
userActions: {
......@@ -314,246 +313,249 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
},
...props.options,
errorDisplay: false,
};
}
if (!base.poster) {
delete base.poster;
delete base.poster
}
return base;
});
return base
})
// 8. Video.js 挂载处理
const handleVideoJsMounted = (payload) => {
state.value = payload.state;
player.value = payload.player;
const handleVideoJsMounted = payload => {
state.value = payload.state
player.value = payload.player
if (player.value) {
setHlsDebug('mounted');
setHlsDebug('mounted')
const quality_selector_inited = { value: false };
const quality_selector_inited = { value: false }
const setupQualitySelector = () => {
if (quality_selector_inited.value) return;
if (!isM3U8.value) return;
const p = player.value;
if (!p || (typeof p.isDisposed === "function" && p.isDisposed())) return;
if (typeof p.hlsQualitySelector !== "function") return;
if (typeof p.qualityLevels !== "function") return;
let tech = null;
if (quality_selector_inited.value) return
if (!isM3U8.value) return
const p = player.value
if (!p || (typeof p.isDisposed === 'function' && p.isDisposed())) return
if (typeof p.hlsQualitySelector !== 'function') return
if (typeof p.qualityLevels !== 'function') return
let tech = null
try {
tech = typeof p.tech === "function" ? p.tech({ IWillNotUseThisInPlugins: true }) : null;
tech = typeof p.tech === 'function' ? p.tech({ IWillNotUseThisInPlugins: true }) : null
} catch (e) {
tech = null;
tech = null
}
if (!tech) return;
if (!tech) return
// videojs-hls-quality-selector 旧版本依赖 tech.hls,而 video.js 7 默认是 tech.vhs,这里做兼容别名
if (!tech.hls && tech.vhs) {
try {
tech.hls = tech.vhs;
tech.hls = tech.vhs
} catch (e) {
void e;
void e
}
}
if (!tech.hls) return;
if (!tech.hls) return
try {
p.hlsQualitySelector({
displayCurrentQuality: true,
});
quality_selector_inited.value = true;
})
quality_selector_inited.value = true
} catch (e) {
void e;
void e
}
}
};
setupQualitySelector();
setupQualitySelector()
player.value.on('error', () => {
const err = player.value.error();
handleError(err?.code, err?.message);
});
const err = player.value.error()
handleError(err?.code, err?.message)
})
player.value.on('loadstart', () => {
showErrorOverlay.value = false;
setupQualitySelector();
});
showErrorOverlay.value = false
setupQualitySelector()
})
player.value.on('canplay', () => {
showErrorOverlay.value = false;
retryCount.value = 0;
setupQualitySelector();
});
showErrorOverlay.value = false
retryCount.value = 0
setupQualitySelector()
})
player.value.on('play', () => {
hideNetworkSpeed();
startHlsDownloadSpeed();
setHlsDebug('play');
});
hideNetworkSpeed()
startHlsDownloadSpeed()
setHlsDebug('play')
})
player.value.on('pause', () => {
hideNetworkSpeed();
stopHlsDownloadSpeed('pause');
setHlsDebug('pause');
});
hideNetworkSpeed()
stopHlsDownloadSpeed('pause')
setHlsDebug('pause')
})
player.value.on('waiting', () => {
if (!hasEverPlayed.value) return;
if (player.value?.paused?.()) return;
if (!hasEverPlayed.value) return
if (player.value?.paused?.()) return
// 已经播放过且当前未暂停,才认为是“卡顿等待”,显示弱网提示
showNetworkSpeed();
startHlsDownloadSpeed();
setHlsDebug('waiting');
});
showNetworkSpeed()
startHlsDownloadSpeed()
setHlsDebug('waiting')
})
player.value.on('stalled', () => {
if (!hasEverPlayed.value) return;
if (player.value?.paused?.()) return;
showNetworkSpeed();
startHlsDownloadSpeed();
setHlsDebug('stalled');
});
if (!hasEverPlayed.value) return
if (player.value?.paused?.()) return
showNetworkSpeed()
startHlsDownloadSpeed()
setHlsDebug('stalled')
})
player.value.on('playing', () => {
hasEverPlayed.value = true;
hasStartedPlayback.value = true;
hideNetworkSpeed();
setHlsDebug('playing');
});
hasEverPlayed.value = true
hasStartedPlayback.value = true
hideNetworkSpeed()
setHlsDebug('playing')
})
player.value.on('ended', () => {
stopHlsDownloadSpeed('ended');
setHlsDebug('ended');
});
stopHlsDownloadSpeed('ended')
setHlsDebug('ended')
})
if (props.autoplay) {
player.value.play().catch(() => {});
player.value.play().catch(() => {})
}
}
}
};
// 6. 重试逻辑
const retryLoad = () => {
if (!canRetry.value) {
showErrorOverlay.value = true;
return;
showErrorOverlay.value = true
return
}
retryCount.value++;
showErrorOverlay.value = false;
hideNetworkSpeed();
stopHlsDownloadSpeed('retry');
retryCount.value++
showErrorOverlay.value = false
hideNetworkSpeed()
stopHlsDownloadSpeed('retry')
if (retry_error_check_timer) {
clearTimeout(retry_error_check_timer);
retry_error_check_timer = null;
clearTimeout(retry_error_check_timer)
retry_error_check_timer = null
}
if (useNativePlayer.value) {
// 原生 video 需要手动重置 src/load
const videoEl = nativeVideoRef.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();
nativeReady.value = false
const currentSrc = videoEl.currentSrc || videoEl.src
videoEl.pause()
videoEl.removeAttribute('src')
videoEl.load()
videoEl.src = currentSrc || videoUrlValue.value
videoEl.load()
tryNativePlay()
retry_error_check_timer = setTimeout(() => {
const err_code = videoEl?.error?.code;
const err_code = videoEl?.error?.code
if (err_code) {
handleError(err_code);
handleError(err_code)
}
}, 800);
}, 800)
}
} else {
// video.js 走自身 load 刷新
if (player.value && !player.value.isDisposed()) {
const p = player.value;
const p = player.value
try {
p.pause?.();
p.pause?.()
} catch (e) {
void e;
void e
}
try {
p.error?.(null);
p.error?.(null)
} catch (e) {
void e;
void e
}
try {
p.src?.(videoSources.value);
p.src?.(videoSources.value)
} catch (e) {
void e;
void e
}
try {
p.load?.();
p.load?.()
} catch (e) {
void e;
void e
}
try {
p.play?.()?.catch?.(() => {});
p.play?.()?.catch?.(() => {})
} catch (e) {
void e;
void e
}
retry_error_check_timer = setTimeout(() => {
const err = p?.error?.();
const err = p?.error?.()
if (err?.code) {
handleError(err.code, err.message);
handleError(err.code, err.message)
}
}, 800)
}
}, 800);
}
}
};
// 7. 生命周期与监听
watch(() => videoUrlValue.value, () => {
retryCount.value = 0;
showErrorOverlay.value = false;
hideNetworkSpeed();
stopHlsDownloadSpeed('url_change');
hasEverPlayed.value = false;
hasStartedPlayback.value = false;
watch(
() => videoUrlValue.value,
() => {
retryCount.value = 0
showErrorOverlay.value = false
hideNetworkSpeed()
stopHlsDownloadSpeed('url_change')
hasEverPlayed.value = false
hasStartedPlayback.value = false
// 地址变更后刷新探测信息,错误提示会基于 probeInfo 补充更准确的原因
void probeVideo();
void probeVideo()
// 如果是原生播放器且 URL 变化,需要手动处理 HLS (如果是非 iOS Safari 环境)
if (useNativePlayer.value && isM3U8.value) {
// iOS 原生支持,不需要额外操作
// 如果未来支持 Android 原生播放器且不支持 HLS,需在此处初始化 hls.js
}
});
}
)
onMounted(() => {
void probeVideo();
void probeVideo()
if (useNativePlayer.value) {
initNativePlayer();
initNativePlayer()
}
});
})
onBeforeUnmount(() => {
if (retry_error_check_timer) {
clearTimeout(retry_error_check_timer);
retry_error_check_timer = null;
clearTimeout(retry_error_check_timer)
retry_error_check_timer = null
}
if (nativeListeners?.videoEl) {
nativeListeners.videoEl.removeEventListener("loadstart", nativeListeners.onLoadStart);
nativeListeners.videoEl.removeEventListener("canplay", nativeListeners.onCanPlay);
nativeListeners.videoEl.removeEventListener("error", nativeListeners.onError);
nativeListeners.videoEl.removeEventListener("play", nativeListeners.onPlay);
nativeListeners.videoEl.removeEventListener("pause", nativeListeners.onPause);
nativeListeners.videoEl.removeEventListener("waiting", nativeListeners.onWaiting);
nativeListeners.videoEl.removeEventListener("stalled", nativeListeners.onStalled);
nativeListeners.videoEl.removeEventListener("playing", nativeListeners.onPlaying);
nativeListeners.videoEl.removeEventListener('loadstart', nativeListeners.onLoadStart)
nativeListeners.videoEl.removeEventListener('canplay', nativeListeners.onCanPlay)
nativeListeners.videoEl.removeEventListener('error', nativeListeners.onError)
nativeListeners.videoEl.removeEventListener('play', nativeListeners.onPlay)
nativeListeners.videoEl.removeEventListener('pause', nativeListeners.onPause)
nativeListeners.videoEl.removeEventListener('waiting', nativeListeners.onWaiting)
nativeListeners.videoEl.removeEventListener('stalled', nativeListeners.onStalled)
nativeListeners.videoEl.removeEventListener('playing', nativeListeners.onPlaying)
}
disposeOverlays();
disposeOverlays()
if (videoRef.value?.$player) {
videoRef.value.$player.dispose();
videoRef.value.$player.dispose()
}
});
})
return {
player,
......@@ -572,6 +574,6 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
hlsSpeedDebugText,
retryLoad,
handleVideoJsMounted,
tryNativePlay
};
tryNativePlay,
}
}
......