Files
Docker/WebVue/src/components/basic/VideoPlayer.vue
T
2026-09-22 10:13:49 +08:00

1266 lines
39 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="vp">
<!-- 空列表 -->
<div v-if="!current" class="vp-empty">
<VideoCameraOutlined />
<p>暂无视频</p>
</div>
<!-- 画面区:原生 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"
: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" aria-label="播放"
@click.stop="toggle">
<CaretRightFilled />
</button>
<!-- 控制区收起后画面上留一个恢复入口:影视厅这种整页用法没有弹窗右上角按钮,
否则收起后就再也点不回来了 -->
<button v-if="!controlsVisible" class="vp-restore" type="button" title="显示控制区"
@click.stop="toggleControls">
<EyeOutlined />
</button>
</div>
<!-- 控制区:单击画面区可隐藏,隐藏后画面区吃掉这部分空间 -->
<div v-if="current && controlsVisible" class="vp-body">
<!-- 功能图标行 -->
<div class="vp-actions">
<!-- 原码/转码切换:默认播转码(FormattedM3u8),点这里切到原码(Url) -->
<button class="vp-act" :class="{ 'is-on': useOriginal }" type="button" :title="sourceTitle"
@click="toggleSource">
<VideoCameraOutlined />
<span class="vp-badge" :class="{ 'is-on': useOriginal }">{{ useOriginal ? "原码" : "转码" }}</span>
</button>
<button class="vp-act" type="button" title="下载" @click="onDownload">
<DownloadOutlined />
<span class="vp-badge" :class="{ 'is-on': downloaded }">{{ downloaded ? "ON" : "OFF" }}</span>
</button>
<button class="vp-act" :class="{ 'is-on': isFullscreen }" type="button" title="全屏" @click="onFullscreen">
<FullscreenExitOutlined v-if="isFullscreen" />
<FullscreenOutlined v-else />
</button>
<!-- 隐藏/显示控制区:收起后画面区吃掉这部分空间(弹窗右上角的「页面全屏」也是同一个动作) -->
<button class="vp-act" :class="{ 'is-on': !controlsVisible }" type="button"
:title="controlsVisible ? '隐藏控制区' : '显示控制区'" @click="toggleControls">
<EyeInvisibleOutlined v-if="controlsVisible" />
<EyeOutlined v-else />
</button>
<button class="vp-act" :class="{ 'is-on': rotated }" type="button" title="横竖屏切换"
@click="cycleRot">
<RotateRightOutlined />
<span v-if="rotMode === 'auto'" class="vp-rot-auto">A</span>
</button>
<button class="vp-act" :class="{ 'is-on': sleepAt > 0 || stopAfterEnd }" type="button" title="定时关闭"
@click.stop="toggleMenu('timer')">
<FieldTimeOutlined />
</button>
<button class="vp-act" :class="{ 'is-muted': isMuted }" type="button" title="音量"
@click.stop="toggleMenu('volume')">
<AudioMutedOutlined v-if="isMuted" />
<AudioOutlined v-else />
</button>
<button class="vp-act" type="button" title="更多" @click.stop="toggleMenu('more')">
<EllipsisOutlined class="vp-rot90" />
</button>
<!-- 弹层:定时关闭 -->
<div v-if="menu === 'timer'" class="vp-pop">
<button v-for="o in timerOptions" :key="o.value" type="button"
:class="{ 'is-on': sleepMinutes === o.value }" @click="onSleep(o.value)">
{{ o.label }}<template v-if="sleepMinutes === o.value && sleepLeft">({{ sleepLeft }})</template>
</button>
</div>
<!-- 弹层:音量 -->
<div v-if="menu === 'volume'" class="vp-pop vp-pop-vol">
<span class="vp-vol-num">{{ Math.round(volume * 100) }}</span>
<div class="vp-vol-track" ref="volRef" @pointerdown="onVolDown" @pointermove="onVolMove"
@pointerup="onVolUp" @pointercancel="onVolUp">
<span class="vp-vol-fill" :style="{ height: volume * 100 + '%' }"></span>
</div>
</div>
<!-- 弹层:更多(倍速) -->
<div v-if="menu === 'more'" class="vp-pop">
<button v-for="r in rateOptions" :key="r" type="button" :class="{ 'is-on': rate === r }"
@click="rate = r">倍速 {{ r }}x</button>
</div>
</div>
<!-- 进度 -->
<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>
<div class="vp-times">
<span>{{ fmt(displayTime) }}</span>
<span>{{ fmt(duration) }}</span>
</div>
</div>
<!-- 主控制 -->
<div class="vp-controls">
<button class="vp-ctl" type="button" :title="modeTitle" @click="cycleMode">
<RetweetOutlined v-if="mode !== 'random'" />
<SwapOutlined v-else />
<span v-if="mode === 'one'" class="vp-mode-one">1</span>
</button>
<button class="vp-ctl" type="button" title="上一个" @click="goPrev">
<StepBackwardFilled />
</button>
<button class="vp-play" type="button" :aria-label="isPlaying ? '暂停' : '播放'" @click="toggle">
<PauseOutlined v-if="isPlaying" />
<CaretRightFilled v-else />
</button>
<button class="vp-ctl" type="button" title="下一个" @click="goNext(false)">
<StepForwardFilled />
</button>
<button class="vp-ctl" type="button" title="播放列表" @click="showList = !showList">
<UnorderedListOutlined />
</button>
</div>
</div>
<!-- 播放列表 -->
<transition name="vp-sheet">
<section v-if="showList && videos.length" class="vp-list">
<header class="vp-list-head">
<span>播放列表({{ videos.length }})</span>
<button type="button" title="关闭" @click="showList = false">
<CloseOutlined />
</button>
</header>
<ul class="vp-list-body">
<li v-for="(v, i) in videos" :key="i" :class="{ 'is-active': i === index }" @click="playAt(i)">
<span class="vp-list-idx">
<span v-if="i === index && isPlaying" class="vp-eq is-on"><i></i><i></i><i></i></span>
<template v-else>{{ i + 1 }}</template>
</span>
<span class="vp-list-name">{{ v.Title }}</span>
<span class="vp-list-author">{{ v.Author }}</span>
</li>
</ul>
</section>
</transition>
</div>
</template>
<script lang="ts" setup>
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from "vue";
import { useVModel } from "@vueuse/core";
import { message } from "ant-design-vue";
import Hls from "hls.js";
import { request } from "@/utils/request";
import type { PublishVideoDetailDisplayData } from "@/data/auto/publishVideoDetailDisplayData";
import {
DownloadOutlined, FieldTimeOutlined,
AudioOutlined, AudioMutedOutlined, EllipsisOutlined, RotateRightOutlined,
RetweetOutlined, SwapOutlined, StepBackwardFilled, StepForwardFilled,
CaretRightFilled, PauseOutlined, UnorderedListOutlined, CloseOutlined,
FullscreenOutlined, FullscreenExitOutlined, VideoCameraOutlined,
EyeOutlined, EyeInvisibleOutlined,
} from "@ant-design/icons-vue";
/** 一个视频 */
export type VideoItem = PublishVideoDetailDisplayData;
const props = withDefaults(
defineProps<{
videos?: VideoItem[];
/** 打开时从第几个开始播(点列表里的某一个进来时用) */
startIndex?: number;
/** 挂上播放源后是否立即播放(弹窗里默认自动播) */
autoPlay?: boolean;
/** 底部控制区是否显示,可用 v-model:controls 从外部控制(单击画面区也会切换) */
controls?: boolean;
}>(),
{ videos: () => [], startIndex: 0, autoPlay: false, controls: true },
);
const emit = defineEmits<{ (e: "update:controls", value: boolean): void }>();
/* ---------------- 基础状态 ---------------- */
const videoRef = ref<HTMLVideoElement | null>(null);
const index = ref(props.startIndex);
const isPlaying = ref(false);
const currentTime = ref(0);
const duration = ref(0);
const volume = ref(0.9);
const muted = ref(false);
const rate = ref(1);
const mode = ref<"list" | "one" | "random">("list");
const showList = ref(false);
/**
* 底部控制区是否显示:由控制栏的眼睛图标 / 弹窗右上角的「页面全屏」按钮切换。
*
* passive: true 是关键:不传的话 useVModel 直接读写 props,
* 父组件没绑 v-model:controls 时(例如影视厅 PreviewVideo 页面)
* 内部改了值也只会 emit 出去、props 不变,按钮就"点了没反应"。
* passive 会保留本地状态,父组件绑了 v-model 时依然双向同步。
*/
const controlsVisible = useVModel(props, "controls", emit, { passive: true });
const menu = ref<"" | "timer" | "volume" | "more">("");
const downloaded = ref(false);
/** 是否正在缓冲:数据不够时会卡住画面,必须让用户看得见 */
const buffering = ref(false);
/** 已缓冲到的时间点(秒),用于进度条上那一层浅色 */
const bufferedEnd = ref(0);
/** 切换后是否接着播(点上一个/下一个/列表点视频时为 true) */
let resumeAfterSwitch = false;
const videos = computed(() => props.videos || []);
const current = computed<VideoItem | null>(() => videos.value[index.value] ?? null);
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 = () => {
// 自动播放被浏览器拦截时静默失败,等用户点播放键
videoRef.value?.play().catch(() => { });
};
const toggle = () => {
const v = videoRef.value;
if (!v || !current.value) return;
if (v.paused) tryPlay();
else v.pause();
};
/**
* 切换底部控制区(功能图标行/进度条/主控制)的显示。
* 收起后 .vp-stage 的 flex:1 会吃掉这部分高度,画面最大化。
* 入口:控制栏里的眼睛图标、弹窗右上角的「页面全屏」按钮。
*/
const toggleControls = () => {
menu.value = ""; // 顺手收起可能打开的弹层(定时/音量/倍速)
controlsVisible.value = !controlsVisible.value;
};
const playAt = (i: number) => {
if (i === index.value) {
toggle();
return;
}
resumeAfterSwitch = true;
index.value = i;
};
const goPrev = () => {
const n = videos.value.length;
if (!n) return;
resumeAfterSwitch = true;
index.value = (index.value - 1 + n) % n;
};
const goNext = (auto: boolean) => {
const n = videos.value.length;
if (!n) return;
resumeAfterSwitch = true;
if (auto && mode.value === "random" && n > 1) {
let r = index.value;
while (r === index.value) r = Math.floor(Math.random() * n);
index.value = r;
} else {
index.value = (index.value + 1) % n;
}
};
const cycleMode = () => {
mode.value = mode.value === "list" ? "one" : mode.value === "one" ? "random" : "list";
};
/* ---------------- video 事件 ---------------- */
const onTimeUpdate = () => {
const v = videoRef.value;
if (!v || dragRatio.value !== null) return;
currentTime.value = v.currentTime;
};
const onLoadedMetadata = () => {
const v = videoRef.value;
if (!v) return;
if (isFinite(v.duration)) duration.value = v.duration;
// 切换原码/转码后恢复播放位置
if (pendingSeek != null) {
v.currentTime = pendingSeek;
pendingSeek = null;
}
// 自动识别横竖屏:宽 > 高 视为横屏,auto 模式下旋转90°播放
landscape.value = v.videoWidth > v.videoHeight;
};
const onEnded = () => {
if (stopAfterEnd.value) {
stopAfterEnd.value = false;
sleepMinutes.value = 0;
return;
}
if (mode.value === "one") {
const v = videoRef.value;
if (v) {
v.currentTime = 0;
tryPlay();
}
return;
}
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 () => {
// 诊断当前真正在播的那个源(转码或原码),不是写死 FormattedUrl
const url = sourceUrl.value;
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("视频加载失败,请检查网络或换一个视频试试");
};
/* ---------------- 进度条(支持鼠标/触摸拖动) ---------------- */
const barRef = ref<HTMLElement | null>(null);
let draggingBar = false;
const dragRatio = ref<number | null>(null);
const ratioFromEvent = (e: PointerEvent) => {
const el = barRef.value;
if (!el) return 0;
const r = el.getBoundingClientRect();
return Math.min(1, Math.max(0, (e.clientX - r.left) / r.width));
};
const onBarDown = (e: PointerEvent) => {
if (!current.value || !duration.value) return;
draggingBar = true;
dragRatio.value = ratioFromEvent(e);
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
};
const onBarMove = (e: PointerEvent) => {
if (draggingBar) dragRatio.value = ratioFromEvent(e);
};
const onBarUp = (e: PointerEvent) => {
if (!draggingBar) return;
draggingBar = false;
const r = ratioFromEvent(e);
if (videoRef.value && duration.value > 0) videoRef.value.currentTime = r * duration.value;
dragRatio.value = null;
};
const displayRatio = computed(
() => dragRatio.value ?? (duration.value > 0 ? currentTime.value / duration.value : 0)
);
const displayTime = computed(() =>
dragRatio.value !== null && duration.value > 0 ? dragRatio.value * duration.value : currentTime.value
);
/* ---------------- 音量 ---------------- */
const volRef = ref<HTMLElement | null>(null);
let draggingVol = false;
const volFromEvent = (e: PointerEvent) => {
const el = volRef.value;
if (!el) return;
const r = el.getBoundingClientRect();
volume.value = Math.min(1, Math.max(0, 1 - (e.clientY - r.top) / r.height));
muted.value = false;
};
const onVolDown = (e: PointerEvent) => {
draggingVol = true;
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
volFromEvent(e);
};
const onVolMove = (e: PointerEvent) => {
if (draggingVol) volFromEvent(e);
};
const onVolUp = () => {
draggingVol = false;
};
/* ---------------- 定时关闭 ---------------- */
const timerOptions = [
{ label: "不开启", value: 0 },
{ label: "15 分钟", value: 15 },
{ label: "30 分钟", value: 30 },
{ label: "60 分钟", value: 60 },
{ label: "播完当前", value: -1 },
];
const sleepMinutes = ref(0);
const sleepAt = ref(0);
const stopAfterEnd = ref(false);
const nowTick = ref(Date.now());
const sleepLeft = computed(() => {
if (sleepAt.value <= nowTick.value) return "";
const s = Math.ceil((sleepAt.value - nowTick.value) / 1000);
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
});
const onSleep = (v: number) => {
sleepMinutes.value = v;
stopAfterEnd.value = v === -1;
sleepAt.value = v > 0 ? Date.now() + v * 60000 : 0;
menu.value = "";
};
/* ---------------- 原码 / 转码切换 ---------------- */
/** false = 播转码(FormattedM3u8,10 秒预览,默认);true = 播原码(Url) */
const useOriginal = ref(false);
const sourceTitle = computed(() =>
useOriginal.value ? "正在播放原码,点击切回转码" : "正在播放转码,点击切换原码"
);
/** 只有像地址(http(s)://、//、/)的才拿去播,避免把 OrderKey 这类文件名当 URL 请求 */
const isPlayableUrl = (u?: string) => !!u && /^(https?:)?\/\//i.test(u);
/** 当前应使用的播放地址:默认转码;切原码但该视频没有可用原码时回退转码 */
const sourceUrl = computed(() => {
const t = current.value;
if (!t) return "";
if (useOriginal.value && isPlayableUrl(t.Url)) return t.Url;
return t.FormattedM3u8 || t.FormattedUrl || "";
});
/** 切换播放源后要恢复到的播放位置(秒) */
let pendingSeek: number | null = null;
const toggleSource = () => {
const v = videoRef.value;
if (!current.value) return;
if (useOriginal.value && !current.value.Url) {
message.info("该视频没有原码");
return;
}
// 记住播放位置,新源的元数据加载完后再跳回去(见 onLoadedMetadata)
pendingSeek = v?.currentTime ?? 0;
const wasPlaying = isPlaying.value;
useOriginal.value = !useOriginal.value;
// 重新挂接播放源:m3u8 走 hls.js,原码 mp4 走 src(内部会销毁上一个 hls 实例)
attachSource(sourceUrl.value);
if (wasPlaying) tryPlay();
};
const onDownload = async () => {
if (!current.value) return;
// 优先下载原码;m3u8 是切片清单,下载下来没法直接看
const downloadUrl = current.value.Url || current.value.FormattedUrl || sourceUrl.value;
// 参考 DmsFileEnquiry.vue:先取回字节流,再用 Blob + <a download> 落盘
try {
const res = await request<ArrayBuffer>({
url: downloadUrl,
method: "get",
responseType: "arraybuffer",
});
const blobUrl = window.URL.createObjectURL(new Blob([res]));
const ext = (downloadUrl.match(/\.(\w{2,5})(?:\?|#|$)/) || [])[1];
// Title 通常是原始文件名、自带扩展名,先把已有扩展名剥掉再拼,
// 否则会得到「xxx.mp4.mp4」这样的双后缀
const base = (current.value.Title || "video").replace(/\.[^.]+$/, "");
const a = document.createElement("a");
a.href = blobUrl;
a.download = ext ? `${base}.${ext}` : base;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => window.URL.revokeObjectURL(blobUrl), 1000);
downloaded.value = true;
} catch {
message.error("下载失败");
}
};
/* ---------------- 旋转90° ---------------- */
/** auto:横屏视频自动转90°(默认);on:强制旋转;off:关闭 */
const rotMode = ref<"auto" | "on" | "off">("auto");
/** 当前视频是否横屏(宽 > 高),由 loadedmetadata 检测 */
const landscape = ref(false);
const rotated = computed(
() => rotMode.value === "on" ? true : rotMode.value === "off" ? false : landscape.value
);
const cycleRot = () => {
// 单击立即翻转画面方向。
// 之前是 auto→on→off 轮转,但横屏视频在 auto 和 on 下画面完全相同,
// 第一次点击看不出任何变化,用户以为失灵、要按两次才生效。
// 这里直接按「当前画面是否已旋转」取反:转着 → 关,没转 → 转。
rotMode.value = rotated.value ? "off" : "on";
};
/** 旋转后 video 宽高对调,横屏视频即可铺满竖版画面区 */
const stageRef = ref<HTMLElement | null>(null);
const stageSize = ref({ w: 0, h: 0 });
let stageRO: ResizeObserver | null = null;
const rotStyle = computed(() =>
rotated.value && stageSize.value.h > 0
? { width: stageSize.value.h + "px", height: stageSize.value.w + "px" }
: undefined
);
const syncStageSize = () => {
const el = stageRef.value;
if (el) stageSize.value = { w: el.clientWidth, h: el.clientHeight };
};
/** stage 在 v-else 分支里,数据到了才挂载,所以延后观察尺寸变化 */
const ensureStageRO = () => {
const el = stageRef.value;
if (!el || stageRO || typeof ResizeObserver === "undefined") return;
stageRO = new ResizeObserver(syncStageSize);
stageRO.observe(el);
syncStageSize();
};
/* ---------------- 全屏 ---------------- */
const isFullscreen = ref(false);
const onFullscreen = async () => {
const v = videoRef.value;
if (!v) return;
try {
// 只在「video 元素自己在全屏」时才退出;页面全屏(documentElement)时直接切到 video 全屏
if (document.fullscreenElement === v) await document.exitFullscreen();
else await v.requestFullscreen();
} catch {
message.error("当前浏览器不支持全屏");
}
};
const onFullscreenChange = () => {
// 只认 video 元素自己的全屏,否则弹窗的「页面全屏」会让这里的图标状态错乱
isFullscreen.value = document.fullscreenElement === videoRef.value;
};
/* ---------------- 弹层开合 ---------------- */
const toggleMenu = (m: "" | "timer" | "volume" | "more") => {
menu.value = menu.value === m ? "" : m;
};
const onDocClick = (e: MouseEvent) => {
const t = e.target as HTMLElement;
if (menu.value && !t.closest(".vp-pop") && !t.closest(".vp-act")) menu.value = "";
};
/* ---------------- 时间格式 ---------------- */
const fmt = (t: number) => {
const v = isFinite(t) && t > 0 ? t : 0;
return `${String(Math.floor(v / 60)).padStart(2, "0")}:${String(Math.floor(v % 60)).padStart(2, "0")}`;
};
/* ---------------- 联动 ---------------- */
/** 切到某个视频:重置状态 → 挂播放源 → 按需播放 */
const prepare = async (t: VideoItem | null, play: boolean) => {
currentTime.value = 0;
duration.value = 0;
bufferedEnd.value = 0;
downloaded.value = false;
if (!t) return;
await nextTick();
ensureStageRO();
// 重新挂接播放源:m3u8 走 hls.js,原码 mp4 走 src(内部会销毁上一个 hls 实例)
// 播放源按「转码优先」取:默认 FormattedM3u8,切了原码用 Url
attachSource(sourceUrl.value);
if (play || props.autoPlay) {
resumeAfterSwitch = false;
tryPlay();
}
};
watch(current, (t) => prepare(t, resumeAfterSwitch));
// 播放列表默认收起,由用户点击列表按钮展开
watch(
() => videos.value.length,
(n) => {
if (index.value >= n) index.value = 0;
}
);
// 外部指定了从第几个开始播(弹窗没销毁又换了视频时也要跟随)
watch(
() => props.startIndex,
(i) => {
if (i >= 0 && i < videos.value.length && i !== index.value) {
resumeAfterSwitch = true;
index.value = i;
}
}
);
watch([volume, muted], () => {
const v = videoRef.value;
if (!v) return;
v.volume = volume.value;
v.muted = muted.value || volume.value === 0;
});
watch(rate, () => {
if (videoRef.value) videoRef.value.playbackRate = rate.value;
});
let timerId = 0;
onMounted(() => {
// 列表在挂载时就已就绪的情况(例如弹窗里先备好数据再打开),watch 不会触发,这里补一次
if (current.value) prepare(current.value, false);
document.addEventListener("click", onDocClick);
document.addEventListener("fullscreenchange", onFullscreenChange);
timerId = window.setInterval(() => {
nowTick.value = Date.now();
if (sleepAt.value && nowTick.value >= sleepAt.value) {
sleepAt.value = 0;
sleepMinutes.value = 0;
videoRef.value?.pause();
}
}, 1000);
});
onBeforeUnmount(() => {
document.removeEventListener("click", onDocClick);
document.removeEventListener("fullscreenchange", onFullscreenChange);
if (timerId) clearInterval(timerId);
clearStallTimer();
stageRO?.disconnect();
// 不销毁的话,离开页面后分片还在后台下载
destroyHls();
videoRef.value?.pause();
});
</script>
<style scoped>
.vp {
position: relative;
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
color: #fff;
border-radius: 14px;
user-select: none;
-webkit-tap-highlight-color: transparent;
background:
radial-gradient(130% 55% at 50% -8%, rgba(255, 255, 255, 0.18), transparent 60%),
linear-gradient(178deg, #9aa8a3 0%, #7f9d9a 28%, #4d9093 54%, #12818f 78%, #0b7280 100%);
}
.vp button {
font-family: inherit;
background: none;
border: 0;
padding: 0;
color: inherit;
cursor: pointer;
}
/* 原生 video 仅作解码,画面铺满画面区并按比例留边 */
.vp-media {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
background: #000;
}
/* 旋转90°:宽高对调(内联 style 提供),绕中心旋转 */
.vp-media.is-rot {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) rotate(90deg);
}
/* 全屏时浏览器自己接管布局,还原为正常方向 */
.vp-media:fullscreen {
width: 100% !important;
height: 100% !important;
position: static !important;
transform: none !important;
}
/* ---------- 空状态 ---------- */
.vp-empty {
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
font-size: 46px;
color: rgba(255, 255, 255, 0.6);
}
.vp-empty p {
margin: 0;
font-size: 14px;
}
/* ---------- 主体布局 ---------- */
.vp-body {
position: relative;
padding: 14px 24px 26px;
}
.vp-bigplay {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 2;
width: 72px;
height: 72px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 32px;
color: #fff;
background: rgba(6, 56, 62, 0.5);
border: 2px solid rgba(255, 255, 255, 0.55);
backdrop-filter: blur(6px);
box-shadow: 0 10px 26px rgba(0, 0, 0, 0.35);
transition: transform 0.15s ease;
}
.vp-bigplay:active {
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;
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
background: #000;
cursor: pointer;
}
/* 控制区收起后留在画面右下角的「显示控制区」入口 */
.vp-restore {
position: absolute;
right: 14px;
bottom: 14px;
z-index: 3;
width: 38px;
height: 38px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
color: #fff;
background: rgba(6, 56, 62, 0.5);
border: 1px solid rgba(255, 255, 255, 0.45);
backdrop-filter: blur(6px);
opacity: 0.85;
}
.vp-restore:active {
transform: scale(0.94);
}
/* ---------- 功能图标行 ---------- */
.vp-actions {
position: relative;
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 18px;
padding: 0 6px;
}
.vp-act {
position: relative;
font-size: 22px;
color: rgba(255, 255, 255, 0.88);
padding: 6px;
}
.vp-act.is-on {
color: #7ff3e8;
}
.vp-act.is-fav {
color: #ff5b7f;
}
.vp-act.is-muted {
opacity: 0.55;
}
.vp-rot90 {
transform: rotate(90deg);
}
/* 旋转按钮的 A 角标 = auto 模式 */
.vp-rot-auto {
position: absolute;
right: 0;
bottom: 2px;
font-size: 10px;
font-weight: 700;
color: #7ff3e8;
}
.vp-badge {
position: absolute;
top: -4px;
right: -16px;
font-size: 9px;
line-height: 1;
padding: 2px 3px;
border-radius: 3px;
color: rgba(255, 255, 255, 0.75);
background: rgba(0, 0, 0, 0.25);
}
.vp-badge.is-on {
color: #0b7280;
background: #7ff3e8;
}
/* 均衡器小图标 */
.vp-eq {
display: inline-flex;
align-items: flex-end;
gap: 2px;
height: 18px;
}
.vp-eq i {
width: 3px;
height: 6px;
border-radius: 1px;
background: currentColor;
}
.vp-eq i:nth-child(2) {
height: 14px;
}
.vp-eq i:nth-child(3) {
height: 9px;
}
.vp-eq.is-on i {
animation: vp-eq 0.9s ease-in-out infinite;
transform-origin: bottom;
}
.vp-eq.is-on i:nth-child(2) {
animation-delay: 0.2s;
}
.vp-eq.is-on i:nth-child(3) {
animation-delay: 0.4s;
}
@keyframes vp-eq {
0%,
100% {
transform: scaleY(0.5);
}
50% {
transform: scaleY(1.4);
}
}
/* ---------- 弹层 ---------- */
.vp-pop {
position: absolute;
bottom: calc(100% + 12px);
left: 50%;
transform: translateX(-50%);
z-index: 6;
display: flex;
flex-direction: column;
gap: 2px;
min-width: 122px;
padding: 6px;
border-radius: 12px;
background: rgba(6, 40, 44, 0.92);
backdrop-filter: blur(8px);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.35);
}
.vp-pop button {
color: rgba(255, 255, 255, 0.85);
font-size: 13px;
padding: 8px 12px;
border-radius: 8px;
text-align: left;
white-space: nowrap;
}
.vp-pop button:hover {
background: rgba(255, 255, 255, 0.1);
}
.vp-pop button.is-on {
color: #7ff3e8;
}
.vp-pop-vol {
align-items: center;
padding: 12px 14px;
gap: 8px;
}
.vp-vol-num {
font-size: 11px;
color: rgba(255, 255, 255, 0.8);
}
.vp-vol-track {
position: relative;
width: 6px;
height: 96px;
border-radius: 3px;
background: rgba(255, 255, 255, 0.25);
cursor: pointer;
touch-action: none;
}
.vp-vol-fill {
position: absolute;
left: 0;
right: 0;
bottom: 0;
border-radius: 3px;
background: #7ff3e8;
}
/* ---------- 进度 ---------- */
.vp-progress {
margin-top: 16px;
padding: 10px 2px 0;
cursor: pointer;
touch-action: none;
}
.vp-bar {
position: relative;
height: 4px;
border-radius: 2px;
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;
top: 0;
bottom: 0;
border-radius: 2px;
background: #fff;
}
.vp-bar-thumb {
position: absolute;
top: 50%;
width: 26px;
height: 10px;
border-radius: 5px;
background: #fff;
transform: translate(-50%, -50%);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
}
.vp-times {
display: flex;
justify-content: space-between;
margin-top: 10px;
font-size: 11px;
color: rgba(255, 255, 255, 0.78);
}
/* ---------- 主控制 ---------- */
.vp-controls {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 12px;
padding: 0 14px;
}
.vp-ctl {
position: relative;
font-size: 24px;
color: #fff;
padding: 8px;
}
.vp-mode-one {
position: absolute;
right: 2px;
bottom: 4px;
font-size: 10px;
font-weight: 700;
color: #7ff3e8;
}
.vp-play {
width: 68px;
height: 68px;
border-radius: 50%;
background: #fff;
color: #0e6d7a;
font-size: 30px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 10px 26px rgba(0, 0, 0, 0.35);
transition: transform 0.15s ease;
}
.vp-play:active {
transform: scale(0.94);
}
/* ---------- 播放列表 ---------- */
.vp-list {
position: absolute;
left: 0;
right: 0;
bottom: 0;
z-index: 8;
max-height: 72%;
display: flex;
flex-direction: column;
border-radius: 16px 16px 0 0;
background: rgba(7, 38, 42, 0.94);
backdrop-filter: blur(10px);
box-shadow: 0 -10px 30px rgba(0, 0, 0, 0.35);
}
.vp-list-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 18px;
font-size: 14px;
font-weight: 600;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.vp-list-head button {
color: rgba(255, 255, 255, 0.8);
font-size: 16px;
padding: 2px;
}
.vp-list-body {
margin: 0;
padding: 6px 0 14px;
list-style: none;
overflow: auto;
}
.vp-list-body li {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 18px;
cursor: pointer;
color: rgba(255, 255, 255, 0.75);
}
.vp-list-body li:hover {
background: rgba(255, 255, 255, 0.06);
}
.vp-list-body li.is-active {
color: #7ff3e8;
}
.vp-list-idx {
flex: none;
width: 22px;
font-size: 12px;
text-align: center;
color: rgba(255, 255, 255, 0.45);
}
.vp-list-name {
flex: 1 1 auto;
min-width: 0;
font-size: 14px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.vp-list-author {
flex: none;
font-size: 12px;
color: rgba(255, 255, 255, 0.45);
}
.vp-sheet-enter-active,
.vp-sheet-leave-active {
transition: transform 0.3s ease;
}
.vp-sheet-enter-from,
.vp-sheet-leave-to {
transform: translateY(100%);
}
/* ---------- 窄屏微调 ---------- */
@media (max-width: 480px) {
.vp-body {
padding: 12px 18px 22px;
}
.vp-play {
width: 60px;
height: 60px;
font-size: 26px;
}
}
</style>