This commit is contained in:
itcsx committed 2026-09-22 10:06:41 +08:00
1 parent 9b9f07dea5
commit 2e1974c35d
15 files changed
+406 -169

No files matched your search

+103 -39
View File
@@ -7,6 +7,7 @@
</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"
@@ -25,13 +26,15 @@
</button>
</div>
<!-- 控制区 -->
<div v-if="current" class="vp-body">
<!-- 控制区:单击画面区可隐藏,隐藏后画面区吃掉这部分空间 -->
<div v-if="current && controlsVisible" class="vp-body">
<!-- 功能图标行 -->
<div class="vp-actions">
<button class="vp-act" :class="{ 'is-fav': isFaved }" type="button" title="喜欢" @click="onFav">
<HeartFilled v-if="isFaved" />
<HeartOutlined v-else />
<!-- 原码/转码切换:默认播转码(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 />
@@ -41,7 +44,13 @@
<FullscreenExitOutlined v-if="isFullscreen" />
<FullscreenOutlined v-else />
</button>
<button class="vp-act" :class="{ 'is-on': rotated }" type="button" :title="rotTitle"
<!-- 隐藏/显示控制区:收起后画面区吃掉这部分空间(弹窗右上角的「页面全屏」也是同一个动作) -->
<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>
@@ -147,16 +156,18 @@
<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 {
HeartOutlined, HeartFilled, DownloadOutlined, FieldTimeOutlined,
DownloadOutlined, FieldTimeOutlined,
AudioOutlined, AudioMutedOutlined, EllipsisOutlined, RotateRightOutlined,
RetweetOutlined, SwapOutlined, StepBackwardFilled, StepForwardFilled,
CaretRightFilled, PauseOutlined, UnorderedListOutlined, CloseOutlined,
FullscreenOutlined, FullscreenExitOutlined, VideoCameraOutlined,
EyeOutlined, EyeInvisibleOutlined,
} from "@ant-design/icons-vue";
/** 一个视频 */
@@ -169,10 +180,14 @@ const props = withDefaults(
startIndex?: number;
/** 挂上播放源后是否立即播放(弹窗里默认自动播) */
autoPlay?: boolean;
/** 底部控制区是否显示,可用 v-model:controls 从外部控制(单击画面区也会切换) */
controls?: boolean;
}>(),
{ videos: () => [], startIndex: 0, autoPlay: false },
{ 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);
@@ -184,6 +199,15 @@ 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);
/** 是否正在缓冲:数据不够时会卡住画面,必须让用户看得见 */
@@ -280,6 +304,15 @@ const toggle = () => {
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();
@@ -320,6 +353,11 @@ 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;
};
@@ -358,7 +396,8 @@ const clearStallTimer = () => {
* 第 2 个用 ffmpeg 重封装(不重新编码)即可解决,几乎不吃 CPU。
*/
const diagnoseStall = async () => {
const url = current.value?.FormattedUrl;
// 诊断当前真正在播的那个源(转码或原码),不是写死 FormattedUrl
const url = sourceUrl.value;
if (!url || isHlsSource(url)) return;
try {
const res = await fetch(url, { headers: { Range: "bytes=0-1" }, credentials: "include" });
@@ -487,39 +526,60 @@ const onSleep = (v: number) => {
menu.value = "";
};
/* ---------------- 喜欢 / 下载 / 信息 ---------------- */
const FAV_KEY = "vp-favs";
const loadFavs = (): Set<string> => {
try {
return new Set<string>(JSON.parse(localStorage.getItem(FAV_KEY) || "[]"));
} catch {
return new Set<string>();
}
};
const favs = ref<Set<string>>(loadFavs());
const isFaved = computed(() => !!current.value && favs.value.has(current.value.FormattedUrl));
const onFav = () => {
/* ---------------- 原码 / 转码切换 ---------------- */
/** 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;
const s = new Set(favs.value);
if (s.has(current.value.FormattedUrl)) s.delete(current.value.FormattedUrl);
else s.add(current.value.FormattedUrl);
favs.value = s;
localStorage.setItem(FAV_KEY, JSON.stringify([...s]));
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: current.value.FormattedUrl,
url: downloadUrl,
method: "get",
responseType: "arraybuffer",
});
const blobUrl = window.URL.createObjectURL(new Blob([res]));
const ext = (current.value.FormattedUrl.match(/\.(\w{2,5})(?:\?|#|$)/) || [])[1];
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 = `${current.value.Title || "video"}.${ext || "mp4"}`;
a.download = ext ? `${base}.${ext}` : base;
document.body.appendChild(a);
a.click();
a.remove();
@@ -537,13 +597,12 @@ const landscape = ref(false);
const rotated = computed(
() => rotMode.value === "on" ? true : rotMode.value === "off" ? false : landscape.value
);
const rotTitle = computed(() => ({
auto: "自动旋转(横屏视频转90°)",
on: "强制旋转90°",
off: "关闭旋转",
}[rotMode.value]));
const cycleRot = () => {
rotMode.value = rotMode.value === "auto" ? "on" : rotMode.value === "on" ? "off" : "auto";
// 单击立即翻转画面方向。
// 之前是 auto→on→off 轮转,但横屏视频在 auto 和 on 下画面完全相同,
// 第一次点击看不出任何变化,用户以为失灵、要按两次才生效。
// 这里直接按「当前画面是否已旋转」取反:转着 → 关,没转 → 转。
rotMode.value = rotated.value ? "off" : "on";
};
/** 旋转后 video 宽高对调,横屏视频即可铺满竖版画面区 */
const stageRef = ref<HTMLElement | null>(null);
@@ -573,14 +632,16 @@ const onFullscreen = async () => {
const v = videoRef.value;
if (!v) return;
try {
if (document.fullscreenElement) await document.exitFullscreen();
// 只在「video 元素自己在全屏」时才退出;页面全屏(documentElement)时直接切到 video 全屏
if (document.fullscreenElement === v) await document.exitFullscreen();
else await v.requestFullscreen();
} catch {
message.error("当前浏览器不支持全屏");
}
};
const onFullscreenChange = () => {
isFullscreen.value = !!document.fullscreenElement;
// 只认 video 元素自己的全屏,否则弹窗的「页面全屏」会让这里的图标状态错乱
isFullscreen.value = document.fullscreenElement === videoRef.value;
};
/* ---------------- 弹层开合 ---------------- */
@@ -608,8 +669,9 @@ const prepare = async (t: VideoItem | null, play: boolean) => {
if (!t) return;
await nextTick();
ensureStageRO();
// 重新挂接播放源:m3u8 走 hls.js,普通文件走 src(内部会销毁上一个 hls 实例)
attachSource(t.FormattedUrl);
// 重新挂接播放源:m3u8 走 hls.js,原码 mp4 走 src(内部会销毁上一个 hls 实例)
// 播放源按「转码优先」取:默认 FormattedM3u8,切了原码用 Url
attachSource(sourceUrl.value);
if (play || props.autoPlay) {
resumeAfterSwitch = false;
tryPlay();
@@ -799,6 +861,8 @@ onBeforeUnmount(() => {
cursor: pointer;
}
/* ---------- 功能图标行 ---------- */
.vp-actions {
position: relative;