ow
This commit is contained in:
1 parent
eae4296980
commit
de09fc04ac
3 files changed
+208
-9
No files matched your search
Binary file not shown.
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user