Files
Docker/WebVue/src/components/basic/VideoPlayer.vue
T
2026-09-17 18:15:57 +08:00

967 lines
27 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">
<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">
<CaretRightFilled />
</button>
</div>
<!-- 控制区 -->
<div v-if="current" 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 />
</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': rotated }" type="button" :title="rotTitle"
@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-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" :title="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 { message } from "ant-design-vue";
import { request } from "@/utils/request";
import type { PublishVideoDetailDisplayData } from "@/data/auto/publishVideoDetailDisplayData";
import {
HeartOutlined, HeartFilled, DownloadOutlined, FieldTimeOutlined,
AudioOutlined, AudioMutedOutlined, EllipsisOutlined, RotateRightOutlined,
RetweetOutlined, SwapOutlined, StepBackwardFilled, StepForwardFilled,
CaretRightFilled, PauseOutlined, UnorderedListOutlined, CloseOutlined,
FullscreenOutlined, FullscreenExitOutlined, VideoCameraOutlined,
} from "@ant-design/icons-vue";
/** 一个视频 */
export type VideoItem = PublishVideoDetailDisplayData;
const props = withDefaults(defineProps<{ videos?: VideoItem[] }>(), { videos: () => [] });
/* ---------------- 基础状态 ---------------- */
const videoRef = ref<HTMLVideoElement | null>(null);
const index = ref(0);
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);
const menu = ref<"" | "timer" | "volume" | "more">("");
const downloaded = ref(false);
/** 切换后是否接着播(点上一个/下一个/列表点视频时为 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];
/* ---------------- 播放控制 ---------------- */
const tryPlay = () => {
// 自动播放被浏览器拦截时静默失败,等用户点播放键
videoRef.value?.play().catch(() => { });
};
const toggle = () => {
const v = videoRef.value;
if (!v || !current.value) return;
if (v.paused) tryPlay();
else v.pause();
};
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;
// 自动识别横竖屏:宽 > 高 视为横屏,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 onError = () => {
// 视频地址拉不起来时只静默处理,避免打断页面
};
/* ---------------- 进度条(支持鼠标/触摸拖动) ---------------- */
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 = "";
};
/* ---------------- 喜欢 / 下载 / 信息 ---------------- */
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 = () => {
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]));
};
const onDownload = async () => {
if (!current.value) return;
// 参考 DmsFileEnquiry.vue:先取回字节流,再用 Blob + <a download> 落盘
try {
const res = await request<ArrayBuffer>({
url: current.value.FormattedUrl,
method: "get",
responseType: "arraybuffer",
});
const blobUrl = window.URL.createObjectURL(new Blob([res]));
const ext = (current.value.FormattedUrl.match(/\.(\w{2,5})(?:\?|#|$)/) || [])[1];
const a = document.createElement("a");
a.href = blobUrl;
a.download = `${current.value.Title || "video"}.${ext || "mp4"}`;
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 rotTitle = computed(() => ({
auto: "自动旋转(横屏视频转90°)",
on: "强制旋转90°",
off: "关闭旋转",
}[rotMode.value]));
const cycleRot = () => {
rotMode.value = rotMode.value === "auto" ? "on" : rotMode.value === "on" ? "off" : "auto";
};
/** 旋转后 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 {
if (document.fullscreenElement) await document.exitFullscreen();
else await v.requestFullscreen();
} catch {
message.error("当前浏览器不支持全屏");
}
};
const onFullscreenChange = () => {
isFullscreen.value = !!document.fullscreenElement;
};
/* ---------------- 弹层开合 ---------------- */
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")}`;
};
/* ---------------- 联动 ---------------- */
watch(current, async (t) => {
currentTime.value = 0;
duration.value = 0;
downloaded.value = false;
if (!t) return;
await nextTick();
ensureStageRO();
// 换了 src 后主动 load,保证 poster 与时长重新计算
videoRef.value?.load();
if (resumeAfterSwitch) {
resumeAfterSwitch = false;
tryPlay();
}
});
// 播放列表默认收起,由用户点击列表按钮展开
watch(
() => videos.value.length,
(n) => {
if (index.value >= n) index.value = 0;
}
);
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(() => {
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);
stageRO?.disconnect();
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);
}
/* ---------- 画面区 ---------- */
.vp-stage {
position: relative;
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
background: #000;
cursor: pointer;
}
/* ---------- 功能图标行 ---------- */
.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-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>