ow
This commit is contained in:
1 parent
501c92d87c
commit
f1a88acb89
9 files changed
+1040
-165
No files matched your search
@@ -0,0 +1,967 @@
|
||||
<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>
|
||||
Reference in new issue
Block a user