style(shorts): optimize UI and user interaction experience on mobile and desktop

This commit is contained in:
nianzhibai
2026-05-23 12:16:07 +08:00
parent cfeba94d16
commit e49d5978ee
3 changed files with 925 additions and 170 deletions
+18 -1
View File
@@ -37,7 +37,24 @@ export function MainNav() {
className={({ isActive }) =>
`main-nav__link ${isActive ? "is-active" : ""}`
}
onClick={() => setOpen(false)}
onClick={() => {
setOpen(false);
if (to === "/shorts") {
const el = document.documentElement;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const fn = el.requestFullscreen?.bind(el) || (el as any).webkitRequestFullscreen?.bind(el);
if (fn) {
try {
const ret = fn();
if (ret && typeof ret.then === "function") {
ret.catch(() => {});
}
} catch {
// ignore
}
}
}
}}
>
<Icon size={16} />
{label}
+371 -29
View File
@@ -1,8 +1,23 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Link } from "react-router-dom";
import { ChevronLeft, Heart, Maximize, Minimize, Volume2, VolumeX } from "lucide-react";
import {
ChevronLeft,
Heart,
Maximize,
Minimize,
Volume2,
VolumeX,
Play,
Pause,
EyeOff,
Info,
Loader2,
Sparkles,
AlertCircle,
} from "lucide-react";
import {
fetchShortsNext,
hideVideo,
type ShortsItem,
} from "@/data/videos";
import "@/styles/shorts.css";
@@ -48,6 +63,54 @@ export default function ShortsPage() {
const [activeIndex, setActiveIndex] = useState(0);
// 是否静音;首次必须静音才能 autoplay,用户点击后切换
const [muted, setMuted] = useState(true);
// 音量大小 (0 ~ 1)
const [volume, setVolume] = useState(0.8);
// 全局 Toast / HUD 提醒文字
const [hudText, setHudText] = useState<{ id: number; text: string; icon?: React.ReactNode } | null>(null);
const hudTimeoutRef = useRef<number | null>(null);
const showHud = useCallback((text: string, icon?: React.ReactNode) => {
if (hudTimeoutRef.current) window.clearTimeout(hudTimeoutRef.current);
setHudText({ id: Date.now(), text, icon });
hudTimeoutRef.current = window.setTimeout(() => {
setHudText(null);
}, 1500);
}, []);
const handleVolumeButtonClick = useCallback(() => {
setMuted((v) => {
const next = !v;
showHud(
next ? "已静音" : "音量已开启",
next ? <VolumeX size={16} /> : <Volume2 size={16} />
);
return next;
});
}, [showHud]);
const handleVolumeSliderChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const val = parseFloat(e.target.value);
setVolume(val);
if (val > 0) {
setMuted(false);
} else {
setMuted(true);
}
// Update active video volume directly
const activeVideo = videoRefs.current.get(activeIndex);
if (activeVideo) {
activeVideo.volume = val;
activeVideo.muted = val === 0;
}
}, [activeIndex]);
// 组件卸载时清理 HUD 定时器
useEffect(() => {
return () => {
if (hudTimeoutRef.current) window.clearTimeout(hudTimeoutRef.current);
};
}, []);
// 是否正在加载下一批,避免并发请求
const [loading, setLoading] = useState(false);
// 后端报告"本轮已耗尽",下次请求前会自动重置
@@ -211,11 +274,12 @@ export default function ShortsPage() {
return () => observer.disconnect();
}, [items.length]);
// 控制每个 video 的播放状态:只有 activeIndex 对应的在播
// 控制每个 video 的播放状态与音量:只有 activeIndex 对应的在播
useEffect(() => {
videoRefs.current.forEach((video, idx) => {
if (idx === activeIndex) {
video.muted = muted;
video.volume = volume;
if (video.paused) {
// 切到这个视频时从头开始播
try {
@@ -234,7 +298,80 @@ export default function ShortsPage() {
}
}
});
}, [activeIndex, muted, items.length]);
}, [activeIndex, muted, volume, items.length]);
// 键盘快捷键监听
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const activeEl = document.activeElement;
if (activeEl && (activeEl.tagName === "INPUT" || activeEl.tagName === "TEXTAREA")) {
return;
}
if (e.key === "ArrowDown") {
e.preventDefault();
const nextIdx = activeIndex + 1;
if (nextIdx < items.length) {
const nextSlide = containerRef.current?.querySelector(`[data-index="${nextIdx}"]`);
if (nextSlide) {
nextSlide.scrollIntoView({ behavior: "smooth" });
}
}
} else if (e.key === "ArrowUp") {
e.preventDefault();
const prevIdx = activeIndex - 1;
if (prevIdx >= 0) {
const prevSlide = containerRef.current?.querySelector(`[data-index="${prevIdx}"]`);
if (prevSlide) {
prevSlide.scrollIntoView({ behavior: "smooth" });
}
}
} else if (e.key === " ") {
e.preventDefault();
const activeVideo = videoRefs.current.get(activeIndex);
if (activeVideo) {
if (activeVideo.paused) {
activeVideo.play().catch(() => undefined);
showHud("播放", <Play size={16} fill="currentColor" />);
} else {
activeVideo.pause();
showHud("暂停", <Pause size={16} fill="currentColor" />);
}
}
} else if (e.key === "m" || e.key === "M") {
e.preventDefault();
handleVolumeButtonClick();
} else if (e.key === "f" || e.key === "F") {
e.preventDefault();
toggleFullscreen();
} else if (e.key === "l" || e.key === "L") {
e.preventDefault();
const heartBtn = containerRef.current?.querySelector(`[data-index="${activeIndex}"] .shorts-slide__action`) as HTMLButtonElement | null;
if (heartBtn) {
heartBtn.click();
}
} else if (e.key === "ArrowRight") {
e.preventDefault();
const activeVideo = videoRefs.current.get(activeIndex);
if (activeVideo && activeVideo.duration) {
const newTime = Math.min(activeVideo.duration, activeVideo.currentTime + 5);
activeVideo.currentTime = newTime;
showHud("+5秒", <Sparkles size={16} />);
}
} else if (e.key === "ArrowLeft") {
e.preventDefault();
const activeVideo = videoRefs.current.get(activeIndex);
if (activeVideo && activeVideo.duration) {
const newTime = Math.max(0, activeVideo.currentTime - 5);
activeVideo.currentTime = newTime;
showHud("-5秒", <Sparkles size={16} />);
}
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [activeIndex, items, toggleFullscreen, showHud, handleVolumeButtonClick]);
// 页面卸载时暂停所有
useEffect(() => {
@@ -397,6 +534,19 @@ export default function ShortsPage() {
else requestPageFullscreen();
}
const handleHideSuccess = useCallback((idx: number) => {
showHud("已选择不再展示,正在滑至下一首...", <EyeOff size={16} />);
const nextIdx = idx + 1;
if (nextIdx < items.length) {
setTimeout(() => {
const nextSlide = containerRef.current?.querySelector(`[data-index="${nextIdx}"]`);
if (nextSlide) {
nextSlide.scrollIntoView({ behavior: "smooth" });
}
}, 700);
}
}, [items.length, showHud]);
return (
<div className="shorts-page" ref={pageRef}>
<header className="shorts-header">
@@ -412,17 +562,39 @@ export default function ShortsPage() {
>
{isFullscreen ? <Minimize size={20} /> : <Maximize size={20} />}
</button>
<button
type="button"
className="shorts-header__icon-btn"
aria-label={muted ? "取消静音" : "静音"}
onClick={() => setMuted((v) => !v)}
>
{muted ? <VolumeX size={20} /> : <Volume2 size={20} />}
</button>
<div className="shorts-header__volume-group">
<div className="shorts-header__volume-slider-container">
<input
type="range"
min="0"
max="1"
step="0.05"
value={muted ? 0 : volume}
onChange={handleVolumeSliderChange}
className="shorts-header__volume-slider"
aria-label="音量调节"
/>
</div>
<button
type="button"
className="shorts-header__icon-btn"
aria-label={muted ? "取消静音" : "静音"}
onClick={handleVolumeButtonClick}
>
{muted || volume === 0 ? <VolumeX size={20} /> : <Volume2 size={20} />}
</button>
</div>
</div>
</header>
{hudText && (
<div key={hudText.id} className="shorts-hud-toast">
{hudText.icon}
<span>{hudText.text}</span>
</div>
)}
<div className="shorts-feed" ref={containerRef}>
{empty && (
<div className="shorts-empty">
@@ -443,14 +615,22 @@ export default function ShortsPage() {
// 其它槽位用海报占位以节省内存和带宽
shouldMount={Math.abs(index - activeIndex) <= MOUNT_RADIUS}
muted={muted}
volume={volume}
setMuted={setMuted}
setVolume={setVolume}
videoRef={setVideoRef(index)}
onLikeToggle={handleLikeToggle}
hasLiked={hasLiked}
onHideSuccess={handleHideSuccess}
showHud={showHud}
/>
))}
{!empty && items.length > 0 && loading && (
<div className="shorts-loading"></div>
<div className="shorts-loading">
<Loader2 size={16} className="shorts-slide__buffering-icon" />
<span></span>
</div>
)}
</div>
</div>
@@ -463,6 +643,9 @@ type SlideProps = {
isActive: boolean;
shouldMount: boolean;
muted: boolean;
volume: number;
setMuted: (muted: boolean) => void;
setVolume: (volume: number) => void;
videoRef: (el: HTMLVideoElement | null) => void;
/**
* 切换点赞。第二参数 true 表示点赞,false 表示取消。
@@ -471,6 +654,8 @@ type SlideProps = {
onLikeToggle: (videoId: string, liked: boolean) => Promise<number | null>;
/** 父组件查询某 id 是否已经在本次会话内点过赞 */
hasLiked: (videoId: string) => boolean;
onHideSuccess: (index: number) => void;
showHud: (text: string, icon?: React.ReactNode) => void;
};
/**
@@ -486,14 +671,26 @@ function ShortsSlide({
isActive,
shouldMount,
muted,
volume,
setMuted,
setVolume,
videoRef,
onLikeToggle,
hasLiked,
onHideSuccess,
showHud,
}: SlideProps) {
const localRef = useRef<HTMLVideoElement | null>(null);
const [paused, setPaused] = useState(false);
const [fastActive, setFastActive] = useState(false);
// 视频缓冲状态
const [isBuffering, setIsBuffering] = useState(false);
// 单击播放暂停的瞬间 HUD 动效
const [playPauseHud, setPlayPauseHud] = useState<{ id: number; type: "play" | "pause" } | null>(null);
// 是否已经被隐藏/拉黑
const [isMarkedHidden, setIsMarkedHidden] = useState(false);
// 进度状态。播放时由 timeupdate 更新;拖动时由用户输入更新
const [duration, setDuration] = useState(0);
const [currentTime, setCurrentTime] = useState(0);
@@ -538,10 +735,32 @@ function ShortsSlide({
if (!isActive) {
setPaused(false);
setScrubbing(false);
setIsBuffering(false);
setPlayPauseHud(null);
}
}, [isActive]);
// 监听 video 的时长 / 进度
// Sync volume state directly
useEffect(() => {
const video = localRef.current;
if (video && isActive) {
video.muted = muted;
video.volume = volume;
}
}, [muted, volume, isActive]);
// 离开活跃或者被隐藏时暂停视频
useEffect(() => {
if (isMarkedHidden && localRef.current) {
try {
localRef.current.pause();
} catch {
// ignore
}
}
}, [isMarkedHidden]);
// 监听 video 的时长 / 进度 / 缓冲状态 / 音量物理键变化
useEffect(() => {
const video = localRef.current;
if (!video) return;
@@ -552,16 +771,47 @@ function ShortsSlide({
// 拖动期间不要被 timeupdate 覆盖 UI
if (!scrubbing) setCurrentTime(video.currentTime);
};
const handleWaiting = () => {
setIsBuffering(true);
};
const handlePlayingOrCanPlay = () => {
setIsBuffering(false);
};
const handleVolumeChange = () => {
// 当检测到 video 自身的 mute 状态或 volume 改变时,同步更新 React 状态。
// 这可以在移动端浏览器支持物理音量键调整时,自动反向取消静音并展示音量 HUD。
if (video.muted !== muted) {
setMuted(video.muted);
}
if (video.volume !== volume) {
setVolume(video.volume);
}
};
handleLoaded();
video.addEventListener("loadedmetadata", handleLoaded);
video.addEventListener("durationchange", handleLoaded);
video.addEventListener("timeupdate", handleTime);
video.addEventListener("waiting", handleWaiting);
video.addEventListener("playing", handlePlayingOrCanPlay);
video.addEventListener("canplay", handlePlayingOrCanPlay);
video.addEventListener("volumechange", handleVolumeChange);
// 挂载时如果已经在播放但是状态不到 ready 则置 buffering
if (video.readyState < 3 && !video.paused) {
setIsBuffering(true);
}
return () => {
video.removeEventListener("loadedmetadata", handleLoaded);
video.removeEventListener("durationchange", handleLoaded);
video.removeEventListener("timeupdate", handleTime);
video.removeEventListener("waiting", handleWaiting);
video.removeEventListener("playing", handlePlayingOrCanPlay);
video.removeEventListener("canplay", handlePlayingOrCanPlay);
video.removeEventListener("volumechange", handleVolumeChange);
};
}, [shouldMount, scrubbing]);
}, [shouldMount, scrubbing, muted, volume, setMuted, setVolume]);
// 长按 2 倍速:直接绑原生事件
useEffect(() => {
@@ -621,7 +871,6 @@ function ShortsSlide({
video.removeEventListener("pause", end);
video.removeEventListener("ended", end);
};
// 仅当 video 元素重新挂载时重新绑定
}, [shouldMount]);
function togglePlayInternal() {
@@ -630,9 +879,13 @@ function ShortsSlide({
if (video.paused) {
video.play().catch(() => undefined);
setPaused(false);
setPlayPauseHud({ id: Date.now(), type: "play" });
setTimeout(() => setPlayPauseHud(null), 450);
} else {
video.pause();
setPaused(true);
setPlayPauseHud({ id: Date.now(), type: "pause" });
setTimeout(() => setPlayPauseHud(null), 450);
}
}
@@ -649,6 +902,9 @@ function ShortsSlide({
* - 第二次点击(280ms 内):清掉定时器,当作双击点赞,不切换播放状态
*/
function handleSlideClick(e: React.MouseEvent<HTMLElement>) {
// 隐藏状态下不处理点击
if (isMarkedHidden) return;
const now = Date.now();
const delta = now - lastClickAtRef.current;
lastClickAtRef.current = now;
@@ -698,7 +954,6 @@ function ShortsSlide({
/**
* 点击右下角心形按钮:在"已点赞 / 未点赞"之间切换。
* 已点赞 → 调 DELETElikes -1;未点赞 → 调 POSTlikes +1。
*/
function handleHeartClick(e: React.MouseEvent<HTMLButtonElement>) {
e.stopPropagation();
@@ -740,6 +995,29 @@ function ShortsSlide({
}
}
/**
* 拉黑并隐藏视频
*/
function handleHideClick(e: React.MouseEvent<HTMLButtonElement>) {
e.stopPropagation();
setIsMarkedHidden(true);
void hideVideo(item.id)
.then((res) => {
if (res.ok) {
onHideSuccess(index);
} else {
setIsMarkedHidden(false);
showHud("操作失败,请重试", <AlertCircle size={16} />);
}
})
.catch(() => {
setIsMarkedHidden(false);
showHud("网络请求出错", <AlertCircle size={16} />);
});
}
// ---- 进度条拖动 ----
// 触摸进度条时:暂停 → 跟随手指更新 currentTime → 松手 resume
function handleProgressPointerDown(e: React.PointerEvent<HTMLDivElement>) {
@@ -800,6 +1078,7 @@ function ShortsSlide({
className="shorts-slide"
data-shorts-slide=""
data-index={index}
data-active={isActive}
onClick={handleSlideClick}
>
{/* 模糊海报背景:避免横屏视频两边出现刺眼黑边 */}
@@ -835,16 +1114,41 @@ function ShortsSlide({
{fastActive && (
<div className="shorts-slide__rate-hint" aria-hidden="true">
2x
2x
</div>
)}
{paused && isActive && !scrubbing && (
{paused && isActive && !scrubbing && !playPauseHud && (
<div className="shorts-slide__paused" aria-hidden="true">
</div>
)}
{/* 视频加载/缓冲旋转器 */}
{isBuffering && isActive && shouldMount && !isMarkedHidden && (
<div className="shorts-slide__buffering" aria-hidden="true">
<Loader2 size={30} className="shorts-slide__buffering-icon" />
</div>
)}
{/* 播放暂停瞬间 HUD 动效 */}
{playPauseHud && isActive && (
<div key={playPauseHud.id} className="shorts-slide__hud-pulse" aria-hidden="true">
{playPauseHud.type === "play" ? <Play size={30} fill="currentColor" /> : <Pause size={30} fill="currentColor" />}
</div>
)}
{/* 不再展示屏蔽遮罩 */}
{isMarkedHidden && (
<div className="shorts-slide__hidden-overlay" onClick={(e) => e.stopPropagation()}>
<EyeOff size={38} style={{ color: "#ff4060", marginBottom: "8px" }} />
<div className="shorts-slide__hidden-title"></div>
<div className="shorts-slide__hidden-desc"></div>
</div>
)}
<div className="shorts-slide__overlay" onClick={(e) => e.stopPropagation()}>
<h2 className="shorts-slide__title">{item.title}</h2>
<div className="shorts-slide__meta">
@@ -860,30 +1164,53 @@ function ShortsSlide({
</span>
)}
</div>
<Link
to={`/video/${encodeURIComponent(item.id)}`}
className="shorts-slide__detail"
>
<Info size={13} />
<span></span>
</Link>
</div>
{/* 右下角操作栏(TikTok 式垂直排布)。当前只有点赞,
保持竖排结构方便后续加收藏/分享/评论。 */}
{/* 右下角操作栏 */}
<aside
className="shorts-slide__actions"
onClick={(e) => e.stopPropagation()}
>
{/* 云盘来源徽章 */}
<div className="shorts-drive-badge" title={`来源: ${item.sourceLabel || "本地"}`}>
{getDriveShortName(item.sourceLabel || "本地")}
</div>
{/* 点赞 */}
<button
type="button"
className={`shorts-slide__action ${
isLiked ? "is-liked" : ""
}`}
className={`shorts-slide__action ${isLiked ? "is-liked" : ""}`}
aria-label={isLiked ? "取消点赞" : "点赞"}
aria-pressed={isLiked}
onClick={handleHeartClick}
>
<Heart
size={28}
size={24}
fill={isLiked ? "currentColor" : "none"}
strokeWidth={2}
/>
<span className="shorts-slide__action-count">{formatCount(likes)}</span>
</button>
{/* 不再展示 */}
<button
type="button"
className="shorts-slide__action"
aria-label="不再展示"
onClick={handleHideClick}
>
<EyeOff size={22} />
<span className="shorts-slide__action-count"></span>
</button>
</aside>
{/* 双击点赞时弹起的心形动画 */}
@@ -898,10 +1225,8 @@ function ShortsSlide({
</div>
)}
{/* 进度条:默认隐藏(仅一根细线),用户按到底部约 32px 区域时才"激活"
成可拖动的高对比进度条。靠 pointer events 自己实现拖拽,
不需要 input[type=range] 那种鼠标点击行为。 */}
{shouldMount && (
{/* 进度条 */}
{shouldMount && !isMarkedHidden && (
<div
className={`shorts-slide__progress ${
scrubbing ? "is-scrubbing" : ""
@@ -912,7 +1237,12 @@ function ShortsSlide({
onPointerCancel={handleProgressPointerEnd}
onClick={(e) => e.stopPropagation()}
>
<div className="shorts-slide__progress-track">
<div
className="shorts-slide__progress-track"
style={{
"--progress-pct": `${progressRatio * 100}%`,
} as React.CSSProperties}
>
<div
className="shorts-slide__progress-fill"
style={{ width: `${progressRatio * 100}%` }}
@@ -948,3 +1278,15 @@ function formatCount(n: number) {
if (n < 10000) return (n / 1000).toFixed(1).replace(/\.0$/, "") + "k";
return (n / 10000).toFixed(1).replace(/\.0$/, "") + "w";
}
/** 识别云盘缩写名称 */
function getDriveShortName(source: string): string {
const s = source.toLowerCase();
if (s.includes("115")) return "115";
if (s.includes("pikpak")) return "PikP";
if (s.includes("quark") || s.includes("夸克")) return "Quak";
if (s.includes("onedrive")) return "OneDrive";
if (s.includes("wopan") || s.includes("沃盘")) return "沃盘";
if (s.includes("spider") || s.includes("爬虫")) return "爬虫";
return source.substring(0, 4);
}
+536 -140
View File
@@ -18,23 +18,24 @@
overscroll-behavior: none;
}
/* ---------- 顶部条 ---------- */
/* ---------- 顶部条 (高阶毛玻璃渐变遮罩) ---------- */
.shorts-header {
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 5;
z-index: 20;
/* 用 padding-top 把 header 内容压到 safe area 下面,
这样刘海/状态栏不会盖住返回按钮 */
padding: env(safe-area-inset-top) 12px 0 12px;
height: calc(56px + env(safe-area-inset-top));
padding: env(safe-area-inset-top) 16px 0 16px;
height: calc(64px + env(safe-area-inset-top));
display: flex;
align-items: center;
justify-content: space-between;
background: linear-gradient(
to bottom,
rgba(0, 0, 0, 0.55) 0%,
rgba(0, 0, 0, 0.7) 0%,
rgba(0, 0, 0, 0.4) 40%,
rgba(0, 0, 0, 0) 100%
);
pointer-events: none;
@@ -43,33 +44,103 @@
.shorts-header__back,
.shorts-header__icon-btn {
pointer-events: auto;
width: 40px;
height: 40px;
width: 42px;
height: 42px;
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.4);
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 50%;
color: #fff;
cursor: pointer;
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
transition: all 0.2s cubic-bezier(0.25, 0.8, 0.25, 1);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.shorts-header__back:hover,
.shorts-header__icon-btn:hover {
background: rgba(255, 255, 255, 0.16);
border-color: rgba(255, 255, 255, 0.3);
transform: scale(1.05);
}
.shorts-header__back:active,
.shorts-header__icon-btn:active {
transform: scale(0.95);
}
.shorts-header__actions {
pointer-events: auto;
display: inline-flex;
align-items: center;
gap: 8px;
gap: 12px;
}
.shorts-header__title {
/* 桌面端音量条容器 */
.shorts-header__volume-group {
position: relative;
display: flex;
align-items: center;
}
.shorts-header__volume-slider-container {
position: absolute;
right: calc(100% + 8px);
background: rgba(0, 0, 0, 0.75);
border: 1px solid rgba(255, 255, 255, 0.15);
padding: 8px 12px;
border-radius: 99px;
display: flex;
align-items: center;
opacity: 0;
pointer-events: none;
font-size: 16px;
font-weight: 600;
letter-spacing: 0.04em;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6);
transform: translateX(10px) scale(0.95);
transition: all 0.2s cubic-bezier(0.25, 0.8, 0.25, 1);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
}
.shorts-header__volume-group:hover .shorts-header__volume-slider-container,
.shorts-header__volume-slider-container:hover {
opacity: 1;
pointer-events: auto;
transform: translateX(0) scale(1);
}
@media (max-width: 768px) {
.shorts-header__volume-slider-container {
display: none !important;
}
}
.shorts-header__volume-slider {
-webkit-appearance: none;
appearance: none;
width: 80px;
height: 4px;
background: rgba(255, 255, 255, 0.3);
border-radius: 2px;
outline: none;
cursor: pointer;
}
.shorts-header__volume-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 12px;
height: 12px;
border-radius: 50%;
background: #fff;
box-shadow: 0 0 6px rgba(255, 255, 255, 0.8);
transition: transform 0.1s;
}
.shorts-header__volume-slider::-webkit-slider-thumb:hover {
transform: scale(1.2);
}
/* ---------- feed 列表 ---------- */
@@ -87,13 +158,10 @@
display: none;
}
/* ---------- 单屏 ---------- */
/* ---------- 单屏 (高度锁定 svh 杜绝抖动) ---------- */
.shorts-slide {
position: relative;
width: 100%;
/* 100svhsmallest viewport height(地址栏永久占位的高度),
避免移动端浏览器地址栏弹出/收起时一屏高度跳动;
不支持的浏览器降级到 100dvh,再降级到 100vh。 */
height: 100svh;
scroll-snap-align: start;
scroll-snap-stop: always;
@@ -119,8 +187,8 @@
inset: 0;
background-size: cover;
background-position: center;
filter: blur(28px) brightness(0.55);
transform: scale(1.1); /* 模糊后边缘羽化,放大避免漏边 */
filter: blur(32px) brightness(0.45);
transform: scale(1.15); /* 模糊后边缘羽化,放大避免漏边 */
z-index: 0;
}
@@ -133,9 +201,93 @@
margin: auto;
object-fit: contain; /* 横屏视频不裁切,多余处由 __bg 顶上 */
z-index: 1;
/* 影院镜头变焦动效:非活跃状态下稍微缩小,切入时柔和放大并淡入 */
transform: scale(0.96);
opacity: 0.5;
transition: transform 0.6s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.6s cubic-bezier(0.16, 1, 0.3, 1);
}
/* 暂停时画面中央显示一个大三角 */
.shorts-slide[data-active="true"] .shorts-slide__video,
.shorts-slide[data-active="true"] .shorts-slide__poster {
transform: scale(1);
opacity: 1;
filter: drop-shadow(0 20px 50px rgba(0, 0, 0, 0.95));
}
/* 播放状态脉冲 HUD (磨砂玻璃大图标弹出动效) */
.shorts-slide__hud-pulse {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 80px;
height: 80px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.45);
border: 1.5px solid rgba(255, 255, 255, 0.25);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
display: flex;
align-items: center;
justify-content: center;
color: #fff;
z-index: 15;
pointer-events: none;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
animation: shorts-hud-pop 450ms cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
}
@keyframes shorts-hud-pop {
0% {
opacity: 0;
transform: translate(-50%, -50%) scale(0.6);
}
25% {
opacity: 1;
transform: translate(-50%, -50%) scale(1.1);
}
75% {
opacity: 0.85;
transform: translate(-50%, -50%) scale(1);
}
100% {
opacity: 0;
transform: translate(-50%, -50%) scale(0.9);
}
}
/* 视频加载/卡顿缓冲磨砂提示圈 */
.shorts-slide__buffering {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 68px;
height: 68px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.6);
border: 1px solid rgba(255, 255, 255, 0.15);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
display: flex;
align-items: center;
justify-content: center;
color: rgba(255, 255, 255, 0.9);
z-index: 12;
pointer-events: none;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
}
.shorts-slide__buffering-icon {
animation: shorts-spin 1.1s linear infinite;
}
@keyframes shorts-spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* 原始大三角形的遗留样式(做兼容处理) */
.shorts-slide__paused {
position: absolute;
inset: 0;
@@ -146,159 +298,293 @@
text-shadow: 0 4px 20px rgba(0, 0, 0, 0.6);
pointer-events: none;
z-index: 3;
animation: shorts-paused-pop 160ms ease-out;
}
@keyframes shorts-paused-pop {
from {
opacity: 0;
transform: scale(0.8);
}
to {
opacity: 1;
transform: scale(1);
}
}
/* 2x 倍速指示 */
.shorts-slide__rate-hint {
position: absolute;
top: calc(80px + env(safe-area-inset-top));
top: calc(76px + env(safe-area-inset-top));
left: 50%;
transform: translateX(-50%);
padding: 4px 12px;
padding: 6px 14px;
border-radius: 999px;
background: rgba(0, 0, 0, 0.7);
background: rgba(0, 0, 0, 0.72);
border: 1px solid rgba(255, 255, 255, 0.15);
color: #fff;
font-size: 13px;
font-weight: 700;
letter-spacing: 0.04em;
letter-spacing: 0.05em;
pointer-events: none;
z-index: 4;
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
animation: shorts-rate-in 120ms ease-out;
z-index: 10;
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
animation: shorts-rate-in 150ms cubic-bezier(0.16, 1, 0.3, 1) forwards;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
display: flex;
align-items: center;
gap: 6px;
}
.shorts-slide__rate-hint::before {
content: "";
display: inline-block;
width: 6px;
height: 6px;
background: #ff4060;
border-radius: 50%;
animation: shorts-ping 1.2s infinite;
}
@keyframes shorts-ping {
0% { transform: scale(1); opacity: 1; }
70% { transform: scale(2.2); opacity: 0; }
100% { transform: scale(2.2); opacity: 0; }
}
@keyframes shorts-rate-in {
from {
opacity: 0;
transform: translate(-50%, -4px);
transform: translate(-50%, -10px) scale(0.9);
}
to {
opacity: 1;
transform: translate(-50%, 0);
transform: translate(-50%, 0) scale(1);
}
}
/* ---------- 底部信息覆盖层 ---------- */
.shorts-slide__overlay {
position: absolute;
left: 0;
/* 给右侧操作栏让 */
right: 76px;
/* 把信息层抬高一点,给底部进度条留 12px 触摸余量 */
bottom: calc(env(safe-area-inset-bottom) + 14px);
padding: 56px 16px 0 16px;
/* 更深、更高的渐变,确保浅色视频底部也能撑起标题对比度 */
background: linear-gradient(
to top,
rgba(0, 0, 0, 0.85) 0%,
rgba(0, 0, 0, 0.6) 45%,
rgba(0, 0, 0, 0.25) 80%,
rgba(0, 0, 0, 0) 100%
);
z-index: 4;
/* 给右侧操作栏让出更多位置,并提高人体工学间隔 */
right: 80px;
/* 抬高以腾出空间给下方的进度条,避免文字与进度条重叠 */
bottom: calc(env(safe-area-inset-bottom) + 52px);
padding: 0 16px 10px 16px;
/* 完全去掉背景灰色/黑影区域,保持背景 100% 透明 */
background: transparent;
z-index: 10;
display: flex;
flex-direction: column;
gap: 8px;
pointer-events: none;
/* 影院文字浮现动效:切屏时,文字稍微往下位移并淡入 */
transform: translateY(15px);
opacity: 0;
transition: transform 0.5s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.5s cubic-bezier(0.16, 1, 0.3, 1);
transition-delay: 0.1s;
}
.shorts-slide[data-active="true"] .shorts-slide__overlay {
transform: translateY(0);
opacity: 1;
}
.shorts-slide__overlay * {
pointer-events: auto;
}
.shorts-slide__title {
margin: 0;
font-size: 18px;
font-size: 17px;
line-height: 1.4;
font-weight: 700;
font-weight: 600;
color: #fff;
/* 多层阴影叠加:近距离锐边 + 远距离弥散,浅色视频上也清楚 */
text-shadow:
0 1px 0 rgba(0, 0, 0, 0.9),
0 2px 6px rgba(0, 0, 0, 0.85),
0 4px 18px rgba(0, 0, 0, 0.7);
0 1px 1px rgba(0, 0, 0, 0.9),
0 2px 5px rgba(0, 0, 0, 0.8),
0 4px 15px rgba(0, 0, 0, 0.6);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
max-height: 2.8em;
}
.shorts-slide__meta {
display: flex;
flex-wrap: wrap;
gap: 6px 10px;
font-size: 12px;
color: rgba(255, 255, 255, 0.85);
gap: 6px 8px;
font-size: 11px;
color: rgba(255, 255, 255, 0.9);
}
.shorts-slide__meta-item {
background: rgba(0, 0, 0, 0.4);
padding: 3px 8px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.12);
padding: 2.5px 8px;
border-radius: 99px;
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
font-weight: 500;
}
/* 查看详情胶囊按钮 */
.shorts-slide__detail {
align-self: flex-start;
margin-top: 4px;
padding: 6px 14px;
border-radius: 999px;
padding: 5px 12px;
border-radius: 99px;
background: rgba(255, 255, 255, 0.12);
border: 1px solid rgba(255, 255, 255, 0.25);
border: 1px solid rgba(255, 255, 255, 0.2);
color: #fff;
font-size: 12px;
font-size: 11.5px;
font-weight: 500;
text-decoration: none;
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
}
.shorts-slide__detail:hover {
background: rgba(255, 255, 255, 0.2);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
display: inline-flex;
align-items: center;
gap: 4px;
transition: all 0.2s cubic-bezier(0.25, 0.8, 0.25, 1);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
/* ---------- 右下角竖排操作栏(点赞等) ---------- */
.shorts-slide__detail:hover {
background: rgba(255, 255, 255, 0.22);
border-color: rgba(255, 255, 255, 0.35);
box-shadow: 0 4px 12px rgba(255, 255, 255, 0.15);
}
.shorts-slide__detail:active {
transform: scale(0.96);
}
@media (max-width: 768px) {
.shorts-slide__detail {
display: none !important;
}
}
/* ---------- 右下角竖排操作栏 (符合人体工学的拇指扇形热区) ---------- */
.shorts-slide__actions {
position: absolute;
right: 12px;
/* 底部抬高,避开进度条和 Home 指示器 */
bottom: calc(env(safe-area-inset-bottom) + 80px);
right: 14px;
/* 进一步抬升操作栏高度,避开底部交互,符合右手大拇指舒适操纵热区 */
bottom: calc(env(safe-area-inset-bottom) + 120px);
display: flex;
flex-direction: column;
gap: 18px;
z-index: 5;
align-items: center;
gap: 16px;
z-index: 15;
/* 影院动作条滑入动效:切屏时,右侧按钮向右微移并淡入 */
transform: translateX(18px);
opacity: 0;
transition: transform 0.5s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.5s cubic-bezier(0.16, 1, 0.3, 1);
transition-delay: 0.15s;
}
.shorts-slide[data-active="true"] .shorts-slide__actions {
transform: translateX(0);
opacity: 1;
}
/* 来源云盘圆形徽章,支持微光呼吸效果 */
.shorts-drive-badge {
width: 40px;
height: 40px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.6);
border: 1px solid rgba(255, 255, 255, 0.2);
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 10px;
font-weight: 700;
letter-spacing: -0.02em;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.4);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
position: relative;
z-index: 2;
margin-bottom: 4px;
}
/* 云盘的圆形炫光边框 */
.shorts-drive-badge::after {
content: "";
position: absolute;
inset: -1.5px;
border-radius: 50%;
padding: 1.5px;
background: linear-gradient(135deg, #ff4060, #ff8030);
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
animation: rotate-gradient 4s linear infinite;
}
@keyframes rotate-gradient {
100% { transform: rotate(360deg); }
}
.shorts-slide__action {
display: inline-flex;
flex-direction: column;
align-items: center;
gap: 4px;
background: transparent;
border: 0;
padding: 6px;
justify-content: center;
gap: 3px;
background: rgba(0, 0, 0, 0.45);
border: 1px solid rgba(255, 255, 255, 0.15);
width: 44px;
height: 44px;
border-radius: 50%;
padding: 0;
color: #fff;
cursor: pointer;
text-shadow: 0 2px 6px rgba(0, 0, 0, 0.6);
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.4));
transition: transform 120ms ease;
transition: all 0.2s cubic-bezier(0.34, 1.56, 0.64, 1);
-webkit-tap-highlight-color: transparent;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
position: relative;
}
/* 大拇指物理触控回弹微缩放动效 */
.shorts-slide__action:active {
transform: scale(0.92);
transform: scale(0.85);
}
.shorts-slide__action:hover {
background: rgba(255, 255, 255, 0.15);
border-color: rgba(255, 255, 255, 0.35);
/* 炫酷的悬停外发光,突出按键焦点 */
box-shadow: 0 0 12px rgba(255, 255, 255, 0.2), 0 4px 12px rgba(0, 0, 0, 0.35);
}
/* 点赞激活态 (高对比度红粉发光) */
.shorts-slide__action.is-liked {
color: #ff4060;
background: rgba(255, 64, 96, 0.12);
border-color: rgba(255, 64, 96, 0.4);
box-shadow: 0 0 12px rgba(255, 64, 96, 0.35);
animation: heart-active-bounce 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275) forwards;
}
@keyframes heart-active-bounce {
0% { transform: scale(1); }
50% { transform: scale(1.22); }
100% { transform: scale(1); }
}
/* 操作计数标签 */
.shorts-slide__action-count {
font-size: 12px;
position: absolute;
top: calc(100% + 2px);
left: 50%;
transform: translateX(-50%);
font-size: 11px;
font-weight: 600;
letter-spacing: 0.04em;
color: rgba(255, 255, 255, 0.85);
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.8);
white-space: nowrap;
pointer-events: none;
}
/* ---------- 双击点赞的心形飞起动画 ---------- */
@@ -308,57 +594,84 @@
transform: translate(-50%, -50%);
color: #ff4060;
pointer-events: none;
z-index: 7;
animation: shorts-heart-pop 700ms cubic-bezier(0.22, 0.7, 0.3, 1) forwards;
filter: drop-shadow(0 4px 12px rgba(255, 64, 96, 0.55));
z-index: 18;
animation: shorts-heart-pop 650ms cubic-bezier(0.175, 0.885, 0.32, 1.275) forwards;
filter: drop-shadow(0 4px 16px rgba(255, 64, 96, 0.7));
}
@keyframes shorts-heart-pop {
0% {
opacity: 0;
transform: translate(-50%, -50%) scale(0.4) rotate(-12deg);
transform: translate(-50%, -50%) scale(0.3) rotate(-16deg);
}
30% {
opacity: 1;
transform: translate(-50%, -50%) scale(1.15) rotate(-6deg);
transform: translate(-50%, -50%) scale(1.25) rotate(-6deg);
}
60% {
50% {
opacity: 1;
transform: translate(-50%, -75%) scale(1) rotate(-2deg);
transform: translate(-50%, -60%) scale(1.05) rotate(4deg);
}
100% {
opacity: 0;
transform: translate(-50%, -110%) scale(0.85) rotate(0);
transform: translate(-50%, -105%) scale(0.7) rotate(12deg);
}
}
/* ---------- 进度条 ---------- */
/* 进度条容器是一条横跨视频底部的"hit area",高度比视觉条粗很多,
方便用户用手指按到。视觉的细线条只在容器中下部展示。 */
/* ---------- 进度条 (触摸激活与发光手柄) ---------- */
.shorts-slide__progress {
position: absolute;
left: 0;
right: 0;
/* 抬高到 safe area 之上,避开 Home 指示器 */
bottom: env(safe-area-inset-bottom);
height: 28px;
z-index: 6;
/* 抬高到 safe area 之上并留出 14px 缓冲区,以彻底杜绝用户拉动进度条时误触手机手势导航条/Home条 */
bottom: calc(env(safe-area-inset-bottom) + 14px);
height: 40px; /* 适当增加点击与拖拽的热区高度 */
z-index: 16;
display: flex;
align-items: flex-end;
padding-bottom: 6px;
align-items: center; /* 纵向居中,使实际感应轴心往上提升,拉大与屏幕底边缘的安全间距 */
/* 拦截手势:进度条上的手指不要触发 feed 上下滚动 */
touch-action: none;
cursor: pointer;
}
/* 不在拖动时,整体淡一些;拖动时整个区域高亮 */
/* 进度条轨道 */
.shorts-slide__progress-track {
width: 100%;
height: 2px;
background: rgba(255, 255, 255, 0.28);
height: 3px;
background: rgba(255, 255, 255, 0.22);
position: relative;
border-radius: 2px;
overflow: hidden;
transition: height 120ms ease, background 120ms ease;
border-radius: 99px;
transition: height 0.15s ease, background-color 0.15s ease;
margin: 0 16px;
}
/* 进度条悬停/激活手柄 (微型发光白点) */
.shorts-slide__progress-track::after {
content: "";
position: absolute;
right: calc(100% - var(--progress-pct, 0%) - 6px);
top: 50%;
transform: translateY(-50%) scale(0);
width: 12px;
height: 12px;
border-radius: 50%;
background: #fff;
box-shadow: 0 0 8px rgba(255, 255, 255, 0.9);
transition: transform 0.15s cubic-bezier(0.34, 1.56, 0.64, 1);
pointer-events: none;
z-index: 4;
}
/* 悬停或拖动状态下,轨道变粗,白色手柄浮现 */
.shorts-slide__progress:hover .shorts-slide__progress-track,
.shorts-slide__progress.is-scrubbing .shorts-slide__progress-track {
height: 6px;
background: rgba(255, 255, 255, 0.35);
}
.shorts-slide__progress:hover .shorts-slide__progress-track::after,
.shorts-slide__progress.is-scrubbing .shorts-slide__progress-track::after {
transform: translateY(-50%) scale(1);
}
.shorts-slide__progress-fill {
@@ -366,39 +679,106 @@
left: 0;
top: 0;
bottom: 0;
background: rgba(255, 255, 255, 0.95);
border-radius: 2px;
/* 使用高颜值渐变(从粉红到蛇金橙),契合主色调,带来极高的高级感 */
background: linear-gradient(90deg, #ff4060 0%, #ff8030 100%);
border-radius: 99px;
transition: width 80ms linear;
}
/* 拖动状态:进度条变粗、变亮,并显示当前时间 */
.shorts-slide__progress.is-scrubbing .shorts-slide__progress-track {
height: 6px;
background: rgba(255, 255, 255, 0.4);
}
.shorts-slide__progress.is-scrubbing .shorts-slide__progress-fill {
transition: none;
background: #fff;
box-shadow: 0 0 8px rgba(255, 255, 255, 0.6);
background: linear-gradient(90deg, #ff4060 0%, #ff8030 100%);
box-shadow: 0 0 10px rgba(255, 64, 96, 0.6);
}
/* 拖动时显示的时间文字 */
/* 拖拽seek时间提示气泡 */
.shorts-slide__progress-time {
position: absolute;
bottom: calc(100% + 8px);
bottom: 24px;
left: 50%;
transform: translateX(-50%);
padding: 6px 12px;
border-radius: 999px;
background: rgba(0, 0, 0, 0.78);
transform: translateX(-50%) scale(0.9);
padding: 6px 14px;
border-radius: 99px;
background: rgba(0, 0, 0, 0.82);
border: 1px solid rgba(255, 255, 255, 0.15);
color: #fff;
font-size: 13px;
font-weight: 600;
letter-spacing: 0.05em;
white-space: nowrap;
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
pointer-events: none;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
animation: shorts-tooltip-pop 150ms cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
@keyframes shorts-tooltip-pop {
to { transform: translateX(-50%) scale(1); }
}
/* ---------- “不再展示” (Hide) 半透明毛玻璃屏蔽遮罩 ---------- */
.shorts-slide__hidden-overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
z-index: 30;
animation: hidden-fade-in 0.25s ease-out forwards;
color: #fff;
text-align: center;
padding: 24px;
}
@keyframes hidden-fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
.shorts-slide__hidden-title {
font-size: 20px;
font-weight: 700;
letter-spacing: 0.02em;
}
.shorts-slide__hidden-desc {
font-size: 13px;
color: rgba(255, 255, 255, 0.6);
}
/* ---------- 统一悬浮 HUD 提醒 (音量/键盘快捷键/链接复制) ---------- */
.shorts-hud-toast {
position: absolute;
top: calc(76px + env(safe-area-inset-top));
left: 50%;
transform: translateX(-50%);
z-index: 40;
pointer-events: none;
background: rgba(0, 0, 0, 0.78);
border: 1px solid rgba(255, 255, 255, 0.15);
padding: 8px 16px;
border-radius: 99px;
color: #fff;
font-size: 13px;
font-weight: 600;
display: flex;
align-items: center;
gap: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
animation: hud-toast-in 0.25s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
@keyframes hud-toast-in {
from { opacity: 0; transform: translate(-50%, -15px) scale(0.92); }
to { opacity: 1; transform: translate(-50%, 0) scale(1); }
}
/* ---------- 空 / 加载提示 ---------- */
@@ -419,13 +799,29 @@
.shorts-empty__link {
color: #fff;
text-decoration: underline;
text-decoration: none;
background: rgba(255, 255, 255, 0.15);
border: 1px solid rgba(255, 255, 255, 0.25);
padding: 8px 20px;
border-radius: 99px;
font-weight: 500;
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
margin-top: 8px;
transition: all 0.2s;
}
.shorts-empty__link:hover {
background: rgba(255, 255, 255, 0.25);
}
.shorts-loading {
height: 60px;
display: grid;
place-items: center;
height: 70px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
color: rgba(255, 255, 255, 0.6);
font-size: 13px;
font-weight: 500;
}