mirror of
https://github.com/nianzhibai/91.git
synced 2026-06-15 08:45:41 +08:00
style(shorts): optimize UI and user interaction experience on mobile and desktop
This commit is contained in:
@@ -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
@@ -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({
|
||||
|
||||
/**
|
||||
* 点击右下角心形按钮:在"已点赞 / 未点赞"之间切换。
|
||||
* 已点赞 → 调 DELETE,likes -1;未点赞 → 调 POST,likes +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
@@ -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%;
|
||||
/* 100svh:smallest 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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user