This commit is contained in:
itcsx committed 2026-09-20 15:21:34 +08:00
1 parent eae4296980
commit de09fc04ac
3 files changed
+208 -9

No files matched your search

Binary file not shown.
+187 -8
View File
@@ -8,12 +8,18 @@
<!-- 画面区:原生 video 只负责解码播放,控制栏全部自绘(与 VinylAudioPlayer 一致) -->
<div v-else class="vp-stage" ref="stageRef" @click="toggle">
<!-- 不绑 :src:m3u8 要交给 hls.js 解码,播放源统一由 attachSource 挂接 -->
<video ref="videoRef" class="vp-media" :class="{ 'is-rot': rotated }" :style="rotStyle"
:src="current.FormattedUrl" :poster="current.FormattedPic"
preload="metadata" playsinline @timeupdate="onTimeUpdate" @loadedmetadata="onLoadedMetadata"
@durationchange="onLoadedMetadata" @ended="onEnded" @play="isPlaying = true"
@pause="isPlaying = false" @error="onError"></video>
<button v-if="!isPlaying" class="vp-bigplay" type="button" title="播放" @click.stop="toggle">
:poster="current.FormattedPic" preload="none" playsinline @timeupdate="onTimeUpdate"
@loadedmetadata="onLoadedMetadata" @durationchange="onLoadedMetadata" @ended="onEnded"
@play="isPlaying = true" @pause="isPlaying = false" @waiting="onWaiting" @playing="onPlaying"
@canplay="onCanPlay" @progress="onProgress" @error="onError"></video>
<!-- 缓冲提示:没有它,用户分不清「正在加载」和「已经卡死」 -->
<div v-if="buffering" class="vp-loading">
<a-spin size="large" />
<span>加载中…</span>
</div>
<button v-if="!isPlaying && !buffering" class="vp-bigplay" type="button" title="播放" @click.stop="toggle">
<CaretRightFilled />
</button>
</div>
@@ -80,6 +86,8 @@
<div class="vp-progress" ref="barRef" @pointerdown="onBarDown" @pointermove="onBarMove" @pointerup="onBarUp"
@pointercancel="onBarUp">
<div class="vp-bar">
<!-- 已缓冲到哪:大文件能看出加载进度,而不是干等着以为卡死 -->
<span class="vp-bar-buffer" :style="{ width: bufferedRatio * 100 + '%' }"></span>
<span class="vp-bar-fill" :style="{ width: displayRatio * 100 + '%' }"></span>
<span class="vp-bar-thumb" :style="{ left: displayRatio * 100 + '%' }"></span>
</div>
@@ -139,6 +147,7 @@
<script lang="ts" setup>
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from "vue";
import { message } from "ant-design-vue";
import Hls from "hls.js";
import { request } from "@/utils/request";
import type { PublishVideoDetailDisplayData } from "@/data/auto/publishVideoDetailDisplayData";
import {
@@ -167,6 +176,10 @@ const mode = ref<"list" | "one" | "random">("list");
const showList = ref(false);
const menu = ref<"" | "timer" | "volume" | "more">("");
const downloaded = ref(false);
/** 是否正在缓冲:数据不够时会卡住画面,必须让用户看得见 */
const buffering = ref(false);
/** 已缓冲到的时间点(秒),用于进度条上那一层浅色 */
const bufferedEnd = ref(0);
/** 切换后是否接着播(点上一个/下一个/列表点视频时为 true) */
let resumeAfterSwitch = false;
@@ -176,6 +189,76 @@ const isMuted = computed(() => muted.value || volume.value === 0);
const modeTitle = computed(() => ({ list: "列表循环", one: "单集循环", random: "随机播放" }[mode.value]));
const rateOptions = [0.5, 0.75, 1, 1.25, 1.5, 2];
/* ---------------- 播放源挂接(HLS) ---------------- */
/**
* 是不是 HLS 流。
* 系统里的视频大多是 .m3u8(HLS 切片),Chrome / Edge / Firefox 原生都不支持,
* 直接塞给 <video src> 会加载失败——这正是「加载不了然后卡死」的根因。
*/
const isHlsSource = (url?: string) => !!url && /\.m3u8(\?|#|$)/i.test(url);
let hls: Hls | null = null;
/** 切歌前必须销毁旧实例,否则上一集的分片还在后台下载,越播越卡 */
const destroyHls = () => {
if (!hls) return;
hls.destroy();
hls = null;
};
/**
* 把播放源挂到 video 上,分三种情况:
* 1. 普通文件(mp4 等) → 直接给 src
* 2. m3u8 + Safari(原生支持)→ 直接给 src,比 hls.js 更省电
* 3. m3u8 + 其它浏览器 → hls.js 解码
*/
const attachSource = (url?: string) => {
const v = videoRef.value;
destroyHls();
bufferedEnd.value = 0;
if (!v || !url) return;
if (!isHlsSource(url)) {
v.src = url;
v.load();
return;
}
// Safari / iOS 原生支持 HLS
if (v.canPlayType("application/vnd.apple.mpegurl")) {
v.src = url;
v.load();
return;
}
if (!Hls.isSupported()) {
message.error("当前浏览器不支持播放该视频(m3u8)");
return;
}
hls = new Hls({
// 只拉到画面尺寸真正需要的清晰度:别在小窗口里下 4K 码流
capLevelToPlayerSize: true,
// 缓冲够用就暂停下载,避免大文件一直占着带宽
maxBufferLength: 30,
maxMaxBufferLength: 120,
enableWorker: true,
});
hls.loadSource(url);
hls.attachMedia(v);
hls.on(Hls.Events.ERROR, (_evt, data) => {
if (!data.fatal) return;
// 网络抖动 → 重新加载;解码出错 → 尝试恢复;其它 → 放弃并提示
if (data.type === Hls.ErrorTypes.NETWORK_ERROR) {
hls?.startLoad();
} else if (data.type === Hls.ErrorTypes.MEDIA_ERROR) {
hls?.recoverMediaError();
} else {
message.error("视频加载失败");
}
});
};
/* ---------------- 播放控制 ---------------- */
const tryPlay = () => {
// 自动播放被浏览器拦截时静默失败,等用户点播放键
@@ -246,8 +329,73 @@ const onEnded = () => {
}
goNext(true);
};
/** 卡住超过这个时间才做诊断,正常播放不会触发 */
const STALL_MS = 3000;
let stallTimer = 0;
const clearStallTimer = () => {
if (stallTimer) {
clearTimeout(stallTimer);
stallTimer = 0;
}
};
/**
* 卡顿诊断:只在真的卡住时才跑,正常播放不产生额外请求。
*
* 高清 mp4 卡住几乎只有两个原因:
* 1. 服务器不支持分段请求(Range),浏览器只能把整个文件下完才能播
* 2. mp4 的索引(moov)在文件末尾,同样得下完才知道怎么播
* 第 2 个用 ffmpeg 重封装(不重新编码)即可解决,几乎不吃 CPU。
*/
const diagnoseStall = async () => {
const url = current.value?.FormattedUrl;
if (!url || isHlsSource(url)) return;
try {
const res = await fetch(url, { headers: { Range: "bytes=0-1" }, credentials: "include" });
// 206 = 支持分段;200 = 后端忽略了 Range,只能整份下载
if (res.status !== 206) {
message.warning("服务器未开启分段加载,高清视频需要完整下载后才能播放");
}
} catch {
// 探测失败不提示,避免误报打扰
}
};
/** 数据不够了,开始缓冲 */
const onWaiting = () => {
buffering.value = true;
clearStallTimer();
stallTimer = window.setTimeout(() => {
if (buffering.value) diagnoseStall();
}, STALL_MS);
};
/** 缓冲够了,能继续播 */
const onCanPlay = () => {
buffering.value = false;
clearStallTimer();
};
/** 画面真的出来了 */
const onPlaying = () => {
buffering.value = false;
clearStallTimer();
};
/** 记录已缓冲到哪:取当前播放位置所在的那一段缓冲的结束时间 */
const onProgress = () => {
const v = videoRef.value;
if (!v || !v.buffered.length) return;
for (let i = 0; i < v.buffered.length; i++) {
if (v.buffered.start(i) <= v.currentTime && v.currentTime <= v.buffered.end(i)) {
bufferedEnd.value = v.buffered.end(i);
return;
}
}
};
const bufferedRatio = computed(() => (duration.value > 0 ? bufferedEnd.value / duration.value : 0));
const onError = () => {
// 视频地址拉不起来时只静默处理,避免打断页面
// 静默失败会让用户以为播放器卡死,这里必须给出明确反馈
buffering.value = false;
message.error("视频加载失败,请检查网络或换一个视频试试");
};
/* ---------------- 进度条(支持鼠标/触摸拖动) ---------------- */
@@ -444,12 +592,13 @@ const fmt = (t: number) => {
watch(current, async (t) => {
currentTime.value = 0;
duration.value = 0;
bufferedEnd.value = 0;
downloaded.value = false;
if (!t) return;
await nextTick();
ensureStageRO();
// 换了 src 后主动 load,保证 poster 与时长重新计算
videoRef.value?.load();
// 重新挂接播放源:m3u8 走 hls.js,普通文件走 src(内部会销毁上一个 hls 实例)
attachSource(t.FormattedUrl);
if (resumeAfterSwitch) {
resumeAfterSwitch = false;
tryPlay();
@@ -491,7 +640,10 @@ onBeforeUnmount(() => {
document.removeEventListener("click", onDocClick);
document.removeEventListener("fullscreenchange", onFullscreenChange);
if (timerId) clearInterval(timerId);
clearStallTimer();
stageRO?.disconnect();
// 不销毁的话,离开页面后分片还在后台下载
destroyHls();
videoRef.value?.pause();
});
</script>
@@ -594,6 +746,23 @@ onBeforeUnmount(() => {
transform: translate(-50%, -50%) scale(0.94);
}
/* 缓冲提示:让「正在加载」和「卡死」区分开。
不加 pointer-events: none 会挡住画面区的点击(播放/暂停) */
.vp-loading {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 2;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
font-size: 13px;
color: rgba(255, 255, 255, 0.9);
pointer-events: none;
}
/* ---------- 画面区 ---------- */
.vp-stage {
position: relative;
@@ -792,6 +961,16 @@ onBeforeUnmount(() => {
background: rgba(255, 255, 255, 0.32);
}
/* 已缓冲区间:比已播进度浅,能看出加载走到哪了 */
.vp-bar-buffer {
position: absolute;
left: 0;
top: 0;
bottom: 0;
border-radius: 2px;
background: rgba(255, 255, 255, 0.5);
}
.vp-bar-fill {
position: absolute;
left: 0;
@@ -386,10 +386,25 @@ const seekTo = (t: number) => {
/** 两个 audio 都会触发事件,只有当前在播的那个才需要响应 */
const isActive = (el: EventTarget | null) => !!el && el === activeAudio.value;
/**
* 直接从当前在播的 audio 读时长。
*
* 不能只依赖 loadedmetadata 事件:切歌若是换到了「后台已经预载好的那一个」,
* 它的 loadedmetadata 早在预载阶段就触发过了(那时它还不是主角,被 isActive 挡掉),
* 成为主角后不会再触发第二次,duration 就永远停在 0——
* 总时长显示 00:00、进度条不动、也没法拖动。
* 这条路径只在「会交换角色的情况」下出现,比如手机连 WiFi 时预载开启。
*/
const syncDuration = () => {
const el = activeAudio.value;
if (el && isFinite(el.duration) && el.duration > 0) duration.value = el.duration;
};
const onTimeUpdate = (e: Event) => {
if (!isActive(e.target) || dragRatio.value !== null) return;
const a = e.target as HTMLAudioElement;
currentTime.value = a.currentTime;
syncDuration();
syncLyric();
};
const onLoadedMetadata = (e: Event) => {
@@ -636,6 +651,9 @@ watch(current, async (t) => {
lyricIndex.value = -1;
if (!t) return;
await nextTick();
// 换到预载好的那一个时不会再有 loadedmetadata,这里主动读一次,
// 否则总时长停在 00:00、进度条也不走
syncDuration();
loadLrc(t.lrc);
if (resumeAfterSwitch) {
resumeAfterSwitch = false;
@@ -664,6 +682,8 @@ watch([volume, muted, rate], () => applyAudioSettings());
watch(activeSlot, async () => {
await nextTick();
applyAudioSettings();
// 交换角色后补读时长:这个元素的 loadedmetadata 可能已经在预载时被丢掉了
syncDuration();
});
let timerId = 0;
@@ -920,7 +940,7 @@ onBeforeUnmount(() => {
比自然位置高一点,又不至于像之前 -160px 那样贴着唱片下沿。
用 relative + top 只挪视觉位置,不影响下面标题、按钮的排布 */
position: relative;
top: -60px;
top: -20px;
height: 102px;
margin-top: 30px;
overflow: hidden;