owe
This commit is contained in:
1 parent
9b9f07dea5
commit
2e1974c35d
15 files changed
+406
-169
No files matched your search
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user