1266 lines
39 KiB
Vue
1266 lines
39 KiB
Vue
<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> |