mirror of
https://github.com/nianzhibai/91.git
synced 2026-06-15 00:44:30 +08:00
Improve video detail player controls and layout
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { ReactNode } from "react";
|
||||
import { ReactNode, useEffect, useState } from "react";
|
||||
import { TopBar } from "./TopBar";
|
||||
import { MainNav } from "./MainNav";
|
||||
import { SubNav } from "./SubNav";
|
||||
@@ -7,14 +7,84 @@ import { BackToTop } from "./BackToTop";
|
||||
|
||||
type Props = {
|
||||
children: ReactNode;
|
||||
mobileAutoHideNav?: boolean;
|
||||
};
|
||||
|
||||
export function AppShell({ children }: Props) {
|
||||
const MOBILE_NAV_QUERY = "(max-width: 768px)";
|
||||
const SCROLL_DELTA_THRESHOLD = 6;
|
||||
const HIDE_AFTER_SCROLL_Y = 56;
|
||||
|
||||
export function AppShell({ children, mobileAutoHideNav = false }: Props) {
|
||||
const [mobileNavHidden, setMobileNavHidden] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mobileAutoHideNav) {
|
||||
setMobileNavHidden(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const mediaQuery = window.matchMedia(MOBILE_NAV_QUERY);
|
||||
let lastScrollY = Math.max(window.scrollY, 0);
|
||||
let ticking = false;
|
||||
|
||||
const showNav = () => setMobileNavHidden(false);
|
||||
|
||||
const updateNavVisibility = () => {
|
||||
ticking = false;
|
||||
const currentScrollY = Math.max(window.scrollY, 0);
|
||||
|
||||
if (!mediaQuery.matches || currentScrollY <= 0) {
|
||||
showNav();
|
||||
lastScrollY = currentScrollY;
|
||||
return;
|
||||
}
|
||||
|
||||
const delta = currentScrollY - lastScrollY;
|
||||
if (Math.abs(delta) < SCROLL_DELTA_THRESHOLD) return;
|
||||
|
||||
if (delta > 0 && currentScrollY > HIDE_AFTER_SCROLL_Y) {
|
||||
setMobileNavHidden(true);
|
||||
} else if (delta < 0) {
|
||||
showNav();
|
||||
}
|
||||
|
||||
lastScrollY = currentScrollY;
|
||||
};
|
||||
|
||||
const handleScroll = () => {
|
||||
if (ticking) return;
|
||||
ticking = true;
|
||||
window.requestAnimationFrame(updateNavVisibility);
|
||||
};
|
||||
|
||||
const handleMediaChange = () => {
|
||||
lastScrollY = Math.max(window.scrollY, 0);
|
||||
showNav();
|
||||
};
|
||||
|
||||
handleMediaChange();
|
||||
window.addEventListener("scroll", handleScroll, { passive: true });
|
||||
mediaQuery.addEventListener("change", handleMediaChange);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("scroll", handleScroll);
|
||||
mediaQuery.removeEventListener("change", handleMediaChange);
|
||||
};
|
||||
}, [mobileAutoHideNav]);
|
||||
|
||||
const className = [
|
||||
"app-shell",
|
||||
mobileAutoHideNav ? "app-shell--mobile-auto-hide-nav" : "",
|
||||
mobileNavHidden ? "is-mobile-nav-hidden" : "",
|
||||
].filter(Boolean).join(" ");
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<TopBar />
|
||||
<MainNav />
|
||||
<SubNav />
|
||||
<div className={className}>
|
||||
<div className="app-shell__nav-stack">
|
||||
<TopBar />
|
||||
<MainNav />
|
||||
<SubNav />
|
||||
</div>
|
||||
<main className="app-shell__main">{children}</main>
|
||||
<Footer />
|
||||
<BackToTop />
|
||||
|
||||
@@ -38,13 +38,11 @@ export function RecommendedRail({ videos }: Props) {
|
||||
return (
|
||||
<aside className="vd-rail" aria-label="推荐视频">
|
||||
<header className="vd-rail__head">
|
||||
<span className="vd-rail__head-bar" aria-hidden="true" />
|
||||
<div className="vd-rail__head-text">
|
||||
<h2 className="vd-rail__head-title">推荐视频</h2>
|
||||
<span className="vd-rail__head-sub">
|
||||
根据当前视频 · {videos.length} 条
|
||||
</span>
|
||||
</div>
|
||||
<span className="vd-rail__head-icon" aria-hidden="true">
|
||||
<span />
|
||||
<span />
|
||||
</span>
|
||||
<h2 className="vd-rail__head-title">推荐视频</h2>
|
||||
</header>
|
||||
<ul className="vd-rail__list">
|
||||
{videos.map((v) => (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { EyeOff, ThumbsDown, ThumbsUp } from "lucide-react";
|
||||
import type { VideoDetail } from "@/types";
|
||||
import { formatCount } from "@/lib/format";
|
||||
@@ -12,11 +12,11 @@ type Props = {
|
||||
/**
|
||||
* 视频操作工具条。
|
||||
* - 整体是一张浮起的圆角玻璃卡,比上一版的横线分隔更"成体"。
|
||||
* - 点赞 + 点踩组成一个胶囊(中间一道竖线分隔),两侧分别带计数。
|
||||
* - 点赞 + 点踩是两个独立按钮。
|
||||
* - "不再显示" 单独成一个次要按钮,hover 时露出 danger 色。
|
||||
*
|
||||
* 功能没变:
|
||||
* - 后端只有点赞接口(POST /api/video/:id/like),点踩仅本地 state。
|
||||
* - 后端只有点赞计数接口,点踩仅本地 state。
|
||||
* - 失败回滚已经处理。
|
||||
*/
|
||||
export function VideoActions({ video, onHideVideo, hideSaving }: Props) {
|
||||
@@ -25,11 +25,20 @@ export function VideoActions({ video, onHideVideo, hideSaving }: Props) {
|
||||
const [bursting, setBursting] = useState(false);
|
||||
const [liked, setLiked] = useState(false);
|
||||
const [disliked, setDisliked] = useState(false);
|
||||
const [likeSubmitted, setLikeSubmitted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setLikes(video.likes ?? 0);
|
||||
setDislikes(video.dislikes ?? 0);
|
||||
setBursting(false);
|
||||
setLiked(false);
|
||||
setDisliked(false);
|
||||
setLikeSubmitted(false);
|
||||
}, [video.id, video.likes, video.dislikes]);
|
||||
|
||||
async function handleLike() {
|
||||
if (liked) return;
|
||||
setLiked(true);
|
||||
setLikes((n) => n + 1);
|
||||
setBursting(true);
|
||||
window.setTimeout(() => setBursting(false), 320);
|
||||
|
||||
@@ -38,6 +47,11 @@ export function VideoActions({ video, onHideVideo, hideSaving }: Props) {
|
||||
setDislikes((n) => Math.max(0, n - 1));
|
||||
}
|
||||
|
||||
if (likeSubmitted) return;
|
||||
|
||||
setLikeSubmitted(true);
|
||||
setLikes((n) => n + 1);
|
||||
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/video/${encodeURIComponent(video.id)}/like`,
|
||||
@@ -51,6 +65,7 @@ export function VideoActions({ video, onHideVideo, hideSaving }: Props) {
|
||||
} catch {
|
||||
setLikes((n) => Math.max(0, n - 1));
|
||||
setLiked(false);
|
||||
setLikeSubmitted(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,7 +79,6 @@ export function VideoActions({ video, onHideVideo, hideSaving }: Props) {
|
||||
setDislikes((n) => n + 1);
|
||||
if (liked) {
|
||||
setLiked(false);
|
||||
setLikes((n) => Math.max(0, n - 1));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,7 +97,6 @@ export function VideoActions({ video, onHideVideo, hideSaving }: Props) {
|
||||
<ThumbsUp size={18} fill={liked ? "currentColor" : "none"} />
|
||||
<span className="vd-actions__count">{formatCount(likes)}</span>
|
||||
</button>
|
||||
<span className="vd-actions__divider" aria-hidden="true" />
|
||||
<button
|
||||
type="button"
|
||||
className={`vd-actions__pill vd-actions__dislike${
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Hash, Pencil, X } from "lucide-react";
|
||||
import { Pencil, Tag, X } from "lucide-react";
|
||||
import type { TagItem, VideoDetail } from "@/types";
|
||||
|
||||
type Props = {
|
||||
@@ -17,7 +17,7 @@ type Props = {
|
||||
* 视觉上和上一版的"两张分离卡"相比,整体感更强:
|
||||
* - 一张大卡内分两个小区块,区块之间用细分隔线
|
||||
* - 简介区块加 "简介" 标题前缀
|
||||
* - 标签区块加 # 图标暗示
|
||||
* - 标签区块加标签轮廓图标暗示
|
||||
*/
|
||||
export function VideoInfoPanel({
|
||||
video,
|
||||
@@ -99,7 +99,7 @@ export function VideoInfoPanel({
|
||||
<div className="vd-info__tags">
|
||||
<div className="vd-info__section-head">
|
||||
<span className="vd-info__section-title">
|
||||
<Hash size={14} aria-hidden="true" />
|
||||
<Tag size={15} strokeWidth={2} aria-hidden="true" />
|
||||
标签
|
||||
</span>
|
||||
{onTagsChange && (
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CalendarDays, Clock3, Eye } from "lucide-react";
|
||||
import type { VideoDetail } from "@/types";
|
||||
import { formatCount } from "@/lib/format";
|
||||
|
||||
@@ -9,9 +10,9 @@ type Props = {
|
||||
* 详情页标题块。
|
||||
*
|
||||
* 视觉:
|
||||
* - 标题:大、粗、最高两行
|
||||
* - meta:一组小胶囊(来源、画质、时长、观看数、发布时间)
|
||||
* 每个胶囊有自己的语义色彩,避免传统 "·" 分隔列表的列表感。
|
||||
* - 标题:大、粗、最高两行,位于 meta 下方
|
||||
*/
|
||||
export function VideoMetaHeader({ video }: Props) {
|
||||
const source = (video.sourceLabel ?? "").trim();
|
||||
@@ -22,10 +23,6 @@ export function VideoMetaHeader({ video }: Props) {
|
||||
|
||||
return (
|
||||
<header className="vd-header">
|
||||
<h1 className="vd-header__title" title={video.title}>
|
||||
{video.title}
|
||||
</h1>
|
||||
|
||||
<div className="vd-header__row">
|
||||
<ul className="vd-meta" aria-label="视频信息">
|
||||
{source && (
|
||||
@@ -42,13 +39,28 @@ export function VideoMetaHeader({ video }: Props) {
|
||||
{quality}
|
||||
</li>
|
||||
)}
|
||||
{duration && <li className="vd-meta__chip">{duration}</li>}
|
||||
<li className="vd-meta__chip">
|
||||
{duration && (
|
||||
<li className="vd-meta__chip vd-meta__chip--plain">
|
||||
<Clock3 size={14} aria-hidden="true" />
|
||||
{duration}
|
||||
</li>
|
||||
)}
|
||||
<li className="vd-meta__chip vd-meta__chip--plain">
|
||||
<Eye size={14} aria-hidden="true" />
|
||||
<strong>{formatCount(video.views)}</strong> 次观看
|
||||
</li>
|
||||
{published && <li className="vd-meta__chip">{published}</li>}
|
||||
{published && (
|
||||
<li className="vd-meta__chip vd-meta__chip--plain">
|
||||
<CalendarDays size={14} aria-hidden="true" />
|
||||
{published}
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<h1 className="vd-header__title" title={video.title}>
|
||||
{video.title}
|
||||
</h1>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
+524
-177
@@ -21,10 +21,6 @@ type Props = {
|
||||
onFirstPlay?: () => void;
|
||||
};
|
||||
|
||||
type ResumePrompt = {
|
||||
time: number;
|
||||
};
|
||||
|
||||
type PlayerError = {
|
||||
title: string;
|
||||
message: string;
|
||||
@@ -45,18 +41,31 @@ type PlayerSettings = {
|
||||
volume: number;
|
||||
muted: boolean;
|
||||
playbackRate: number;
|
||||
};
|
||||
|
||||
type PlaybackRecord = {
|
||||
time: number;
|
||||
duration: number;
|
||||
updatedAt: number;
|
||||
brightness: number;
|
||||
};
|
||||
|
||||
type VideoElementWithHls = HTMLVideoElement & {
|
||||
__hls?: Hls | null;
|
||||
};
|
||||
|
||||
type MobileGestureMode = "seek" | "volume" | "brightness";
|
||||
type MobileGestureSide = "left" | "right";
|
||||
type PlayerGestureHudKind = "volume" | "brightness";
|
||||
type MobileGestureState = {
|
||||
startX: number;
|
||||
startY: number;
|
||||
startTime: number;
|
||||
startVolume: number;
|
||||
startBrightness: number;
|
||||
side: MobileGestureSide;
|
||||
mode: MobileGestureMode | null;
|
||||
targetTime: number;
|
||||
moved: boolean;
|
||||
fastActive: boolean;
|
||||
previousRate: number;
|
||||
pressTimer: number | null;
|
||||
};
|
||||
|
||||
type OrientationMode = "landscape" | "portrait";
|
||||
type OrientationKind = "native" | "web";
|
||||
type FullscreenElement = HTMLElement & {
|
||||
@@ -87,20 +96,31 @@ const NORMAL_RATE = 1;
|
||||
Artplayer.FAST_FORWARD_VALUE = FAST_RATE;
|
||||
|
||||
const SETTINGS_KEY = "video-site:player-settings";
|
||||
const PLAYBACK_KEY_PREFIX = "video-site:playback:";
|
||||
const DEFAULT_SETTINGS: PlayerSettings = {
|
||||
volume: 0.7,
|
||||
muted: false,
|
||||
playbackRate: 1,
|
||||
brightness: 1,
|
||||
};
|
||||
const ORIENTATION_CONTROL_NAME = "orientationToggle";
|
||||
const MANUAL_ORIENTATION_CLASS = "art-manual-orientation";
|
||||
const RESUME_MIN_SECONDS = 10;
|
||||
const RESUME_END_GAP_SECONDS = 12;
|
||||
const FAST_RATE_CLASS = "art-fast-rate-active";
|
||||
const FAST_RATE_HINT_CLASS = "video-player__art-rate-hint";
|
||||
const PLAYER_GESTURE_HUD_CLASS = "video-player__art-gesture-hud";
|
||||
const PLAYER_GESTURE_HUD_ICON_CLASS = "video-player__art-gesture-hud-icon";
|
||||
const PLAYER_GESTURE_HUD_VALUE_CLASS = "video-player__art-gesture-hud-value";
|
||||
const PREVIEW_WIDTH = 168;
|
||||
const BRIGHTNESS_MIN = 0.45;
|
||||
const BRIGHTNESS_MAX = 1.35;
|
||||
const GESTURE_ACTIVATION_PX = 12;
|
||||
const GESTURE_DIRECTION_LOCK_RATIO = 1.2;
|
||||
const GESTURE_VERTICAL_SCALE = 1.15;
|
||||
const GESTURE_SEEK_MIN_SECONDS = 30;
|
||||
const GESTURE_SEEK_MAX_SECONDS = 120;
|
||||
const GESTURE_SEEK_DURATION_RATIO = 0.12;
|
||||
const playerGestureHudTimers = new WeakMap<HTMLElement, number>();
|
||||
|
||||
export function VideoPlayer({
|
||||
id,
|
||||
src,
|
||||
poster,
|
||||
previewSrc,
|
||||
@@ -112,13 +132,11 @@ export function VideoPlayer({
|
||||
const previewVideoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const onFirstPlayRef = useRef<Props["onFirstPlay"]>(onFirstPlay);
|
||||
const playedRef = useRef(false);
|
||||
const videoKey = id || src;
|
||||
const [fastActive, setFastActive] = useState(false);
|
||||
const [retryNonce, setRetryNonce] = useState(0);
|
||||
const [resumePrompt, setResumePrompt] = useState<ResumePrompt | null>(null);
|
||||
const [playerError, setPlayerError] = useState<PlayerError | null>(null);
|
||||
const [gestureHud, setGestureHud] = useState<GestureHud | null>(null);
|
||||
const [previewHover, setPreviewHover] = useState<PreviewHover | null>(null);
|
||||
const gestureHudTimerRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
onFirstPlayRef.current = onFirstPlay;
|
||||
@@ -129,56 +147,48 @@ export function VideoPlayer({
|
||||
if (!mount) return;
|
||||
|
||||
playedRef.current = false;
|
||||
setFastActive(false);
|
||||
setResumePrompt(null);
|
||||
setPlayerError(null);
|
||||
setPreviewHover(null);
|
||||
|
||||
const cleanupPlayer = mountArtPlayer({
|
||||
mount,
|
||||
videoKey,
|
||||
src,
|
||||
poster,
|
||||
title,
|
||||
artRef,
|
||||
playedRef,
|
||||
onFirstPlayRef,
|
||||
onFastChange: setFastActive,
|
||||
onResumeAvailable: setResumePrompt,
|
||||
onFastChange: noop,
|
||||
onError: setPlayerError,
|
||||
onPreviewHover: setPreviewHover,
|
||||
onGestureHud: showGestureHud,
|
||||
});
|
||||
|
||||
return cleanupPlayer;
|
||||
}, [poster, retryNonce, src, title, videoKey]);
|
||||
}, [poster, retryNonce, src, title]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (gestureHudTimerRef.current !== null) {
|
||||
window.clearTimeout(gestureHudTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!previewSrc || !previewHover) return;
|
||||
syncPreviewVideo(previewVideoRef.current, previewHover.ratio);
|
||||
}, [previewHover, previewSrc]);
|
||||
|
||||
function continuePlayback() {
|
||||
const video = artRef.current?.video;
|
||||
if (!video || !resumePrompt) return;
|
||||
try {
|
||||
video.currentTime = resumePrompt.time;
|
||||
} catch {
|
||||
// ignore
|
||||
function showGestureHud(label: string, duration = 700) {
|
||||
if (gestureHudTimerRef.current !== null) {
|
||||
window.clearTimeout(gestureHudTimerRef.current);
|
||||
}
|
||||
setResumePrompt(null);
|
||||
}
|
||||
|
||||
function restartPlayback() {
|
||||
const video = artRef.current?.video;
|
||||
if (video) {
|
||||
try {
|
||||
video.currentTime = 0;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
clearPlaybackRecord(videoKey);
|
||||
setResumePrompt(null);
|
||||
setGestureHud({ key: Date.now(), label });
|
||||
gestureHudTimerRef.current = window.setTimeout(() => {
|
||||
setGestureHud(null);
|
||||
gestureHudTimerRef.current = null;
|
||||
}, duration);
|
||||
}
|
||||
|
||||
function retryPlayback() {
|
||||
@@ -190,10 +200,10 @@ export function VideoPlayer({
|
||||
const absolute = new URL(src, window.location.href).href;
|
||||
try {
|
||||
await navigator.clipboard.writeText(absolute);
|
||||
showTransientHud(setGestureHud, "播放地址已复制");
|
||||
showGestureHud("播放地址已复制", 900);
|
||||
} catch {
|
||||
fallbackCopyText(absolute);
|
||||
showTransientHud(setGestureHud, "播放地址已复制");
|
||||
showGestureHud("播放地址已复制", 900);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,25 +213,8 @@ export function VideoPlayer({
|
||||
|
||||
return (
|
||||
<div className="video-player">
|
||||
<div
|
||||
className="video-player__poster-bg"
|
||||
style={{ backgroundImage: poster ? `url(${poster})` : undefined }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div ref={mountRef} className="video-player__mount" />
|
||||
|
||||
{resumePrompt && !playerError && (
|
||||
<div className="video-player__resume" role="status">
|
||||
<span>上次播放到 {formatClock(resumePrompt.time)}</span>
|
||||
<button type="button" onClick={continuePlayback}>
|
||||
继续播放
|
||||
</button>
|
||||
<button type="button" onClick={restartPlayback}>
|
||||
从头播放
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{playerError && (
|
||||
<div className="video-player__error" role="alert">
|
||||
<div className="video-player__error-title">{playerError.title}</div>
|
||||
@@ -268,11 +261,6 @@ export function VideoPlayer({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fastActive && (
|
||||
<div className="video-player__rate-hint" aria-hidden="true">
|
||||
2x
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -286,7 +274,6 @@ function inferSourceType(src: string) {
|
||||
|
||||
function mountArtPlayer({
|
||||
mount,
|
||||
videoKey,
|
||||
src,
|
||||
poster,
|
||||
title,
|
||||
@@ -294,12 +281,11 @@ function mountArtPlayer({
|
||||
playedRef,
|
||||
onFirstPlayRef,
|
||||
onFastChange,
|
||||
onResumeAvailable,
|
||||
onError,
|
||||
onPreviewHover,
|
||||
onGestureHud,
|
||||
}: {
|
||||
mount: HTMLDivElement;
|
||||
videoKey: string;
|
||||
src: string;
|
||||
poster: string;
|
||||
title: string;
|
||||
@@ -307,14 +293,15 @@ function mountArtPlayer({
|
||||
playedRef: MutableRefObject<boolean>;
|
||||
onFirstPlayRef: MutableRefObject<Props["onFirstPlay"]>;
|
||||
onFastChange: (active: boolean) => void;
|
||||
onResumeAvailable: (prompt: ResumePrompt | null) => void;
|
||||
onError: (error: PlayerError | null) => void;
|
||||
onPreviewHover: (hover: PreviewHover | null) => void;
|
||||
onGestureHud: (label: string, duration?: number) => void;
|
||||
}) {
|
||||
const sourceType = inferSourceType(src);
|
||||
const settings = readPlayerSettings();
|
||||
const fastActiveRef = { current: false };
|
||||
const loadHlsSource = createHlsSourceLoader(onError);
|
||||
const enableOrientationControl = shouldEnableMobileOrientationControl();
|
||||
const option: Option = {
|
||||
id: "91-detail-player",
|
||||
container: mount,
|
||||
@@ -333,13 +320,13 @@ function mountArtPlayer({
|
||||
pip: true,
|
||||
mutex: true,
|
||||
fullscreen: true,
|
||||
fullscreenWeb: true,
|
||||
fullscreenWeb: !enableOrientationControl,
|
||||
miniProgressBar: true,
|
||||
backdrop: true,
|
||||
backdrop: false,
|
||||
playsInline: true,
|
||||
lock: true,
|
||||
gesture: true,
|
||||
fastForward: true,
|
||||
gesture: false,
|
||||
fastForward: false,
|
||||
airplay: true,
|
||||
customType: {
|
||||
hls: loadHlsSource,
|
||||
@@ -347,8 +334,9 @@ function mountArtPlayer({
|
||||
},
|
||||
moreVideoAttr: {
|
||||
preload: "metadata",
|
||||
playsInline: true,
|
||||
},
|
||||
controls: [createOrientationControl()],
|
||||
controls: enableOrientationControl ? [createOrientationControl()] : [],
|
||||
contextmenu: [],
|
||||
cssVar: {
|
||||
"--art-theme": "var(--video-player-progress)",
|
||||
@@ -364,8 +352,10 @@ function mountArtPlayer({
|
||||
const video = art.video as VideoElementWithHls;
|
||||
video.setAttribute("aria-label", title);
|
||||
video.setAttribute("controlsList", "nodownload");
|
||||
video.setAttribute("webkit-playsinline", "true");
|
||||
video.disablePictureInPicture = false;
|
||||
video.playbackRate = settings.playbackRate;
|
||||
applyPlayerBrightness(art, settings.brightness);
|
||||
|
||||
function preventContextMenu(event: Event) {
|
||||
event.preventDefault();
|
||||
@@ -396,22 +386,10 @@ function mountArtPlayer({
|
||||
|
||||
function resetFastRate() {
|
||||
fastActiveRef.current = false;
|
||||
setPlayerFastRateHint(art, false);
|
||||
onFastChange(false);
|
||||
}
|
||||
|
||||
function handleEnded() {
|
||||
resetFastRate();
|
||||
clearPlaybackRecord(videoKey);
|
||||
}
|
||||
|
||||
function handleLoadedMetadata() {
|
||||
maybeOfferResume(videoKey, video, onResumeAvailable);
|
||||
}
|
||||
|
||||
function handleTimeUpdate() {
|
||||
savePlaybackRecord(videoKey, video);
|
||||
}
|
||||
|
||||
function handleVolumeChange() {
|
||||
writePlayerSettings({
|
||||
volume: clamp(video.volume, 0, 1),
|
||||
@@ -427,21 +405,30 @@ function mountArtPlayer({
|
||||
});
|
||||
}
|
||||
|
||||
const unbindFastRate = bindLongPressFast(video, (active) => {
|
||||
const handleFastChange = (active: boolean) => {
|
||||
fastActiveRef.current = active;
|
||||
setPlayerFastRateHint(art, active);
|
||||
onFastChange(active);
|
||||
});
|
||||
};
|
||||
|
||||
const unbindFastRate = bindLongPressFast(video, handleFastChange);
|
||||
const unbindMobileGestures = bindMobilePlayerGestures(
|
||||
art,
|
||||
video,
|
||||
handleFastChange,
|
||||
onGestureHud
|
||||
);
|
||||
const unbindProgressPreview = bindProgressPreview(
|
||||
art,
|
||||
video,
|
||||
mount,
|
||||
onPreviewHover
|
||||
);
|
||||
const unbindOrientationToggle = bindOrientationToggle(art);
|
||||
const unbindOrientationToggle = enableOrientationControl
|
||||
? bindOrientationToggle(art)
|
||||
: noop;
|
||||
|
||||
mount.addEventListener("contextmenu", preventContextMenu);
|
||||
video.addEventListener("loadedmetadata", handleLoadedMetadata);
|
||||
video.addEventListener("timeupdate", handleTimeUpdate);
|
||||
video.addEventListener("volumechange", handleVolumeChange);
|
||||
video.addEventListener("ratechange", handleRateChange);
|
||||
|
||||
@@ -453,15 +440,15 @@ function mountArtPlayer({
|
||||
art.on("error", handleVideoError);
|
||||
art.on("video:play", handlePlay);
|
||||
art.on("video:pause", resetFastRate);
|
||||
art.on("video:ended", handleEnded);
|
||||
art.on("video:ended", resetFastRate);
|
||||
|
||||
return () => {
|
||||
unbindFastRate();
|
||||
unbindMobileGestures();
|
||||
unbindProgressPreview();
|
||||
unbindOrientationToggle();
|
||||
setPlayerFastRateHint(art, false);
|
||||
mount.removeEventListener("contextmenu", preventContextMenu);
|
||||
video.removeEventListener("loadedmetadata", handleLoadedMetadata);
|
||||
video.removeEventListener("timeupdate", handleTimeUpdate);
|
||||
video.removeEventListener("volumechange", handleVolumeChange);
|
||||
video.removeEventListener("ratechange", handleRateChange);
|
||||
destroyHls(video);
|
||||
@@ -473,7 +460,7 @@ function mountArtPlayer({
|
||||
art.off("error", handleVideoError);
|
||||
art.off("video:play", handlePlay);
|
||||
art.off("video:pause", resetFastRate);
|
||||
art.off("video:ended", handleEnded);
|
||||
art.off("video:ended", resetFastRate);
|
||||
art.destroy(true);
|
||||
if (artRef.current === art) {
|
||||
artRef.current = null;
|
||||
@@ -482,6 +469,130 @@ function mountArtPlayer({
|
||||
};
|
||||
}
|
||||
|
||||
function shouldEnableMobileOrientationControl() {
|
||||
const coarsePointer = window.matchMedia?.(
|
||||
"(hover: none) and (pointer: coarse)"
|
||||
).matches;
|
||||
if (coarsePointer) return true;
|
||||
|
||||
return /Android|iPhone|iPad|iPod|Mobile/i.test(navigator.userAgent);
|
||||
}
|
||||
|
||||
function shouldEnableMobileGestures() {
|
||||
return shouldEnableMobileOrientationControl();
|
||||
}
|
||||
|
||||
function isPlayerExpanded(art: Artplayer) {
|
||||
return Boolean(
|
||||
art.fullscreen || art.fullscreenWeb || getNativeFullscreenElement()
|
||||
);
|
||||
}
|
||||
|
||||
function setPlayerFastRateHint(art: Artplayer, active: boolean) {
|
||||
const player = art.template.$player;
|
||||
player.classList.toggle(FAST_RATE_CLASS, active);
|
||||
|
||||
let hint = player.querySelector<HTMLElement>(`.${FAST_RATE_HINT_CLASS}`);
|
||||
if (!active) {
|
||||
hint?.remove();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hint) {
|
||||
hint = document.createElement("div");
|
||||
hint.className = FAST_RATE_HINT_CLASS;
|
||||
hint.setAttribute("aria-hidden", "true");
|
||||
hint.textContent = `${FAST_RATE}x`;
|
||||
player.appendChild(hint);
|
||||
}
|
||||
}
|
||||
|
||||
function showPlayerGestureHud(
|
||||
art: Artplayer,
|
||||
kind: PlayerGestureHudKind,
|
||||
value: string,
|
||||
duration = 680
|
||||
) {
|
||||
const player = art.template.$player;
|
||||
const currentTimer = playerGestureHudTimers.get(player);
|
||||
if (currentTimer !== undefined) {
|
||||
window.clearTimeout(currentTimer);
|
||||
}
|
||||
|
||||
let hud = player.querySelector<HTMLElement>(`.${PLAYER_GESTURE_HUD_CLASS}`);
|
||||
if (!hud) {
|
||||
hud = document.createElement("div");
|
||||
hud.setAttribute("aria-hidden", "true");
|
||||
player.appendChild(hud);
|
||||
}
|
||||
|
||||
hud.className = [
|
||||
PLAYER_GESTURE_HUD_CLASS,
|
||||
`${PLAYER_GESTURE_HUD_CLASS}--${kind}`,
|
||||
kind === "volume" && value === "0%" ? `${PLAYER_GESTURE_HUD_CLASS}--muted` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
hud.replaceChildren();
|
||||
|
||||
const icon = document.createElement("span");
|
||||
icon.className = PLAYER_GESTURE_HUD_ICON_CLASS;
|
||||
icon.innerHTML = playerGestureHudIcon(kind, value);
|
||||
|
||||
const valueElement = document.createElement("span");
|
||||
valueElement.className = PLAYER_GESTURE_HUD_VALUE_CLASS;
|
||||
valueElement.textContent = value;
|
||||
|
||||
hud.append(icon, valueElement);
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
hud?.remove();
|
||||
playerGestureHudTimers.delete(player);
|
||||
}, duration);
|
||||
playerGestureHudTimers.set(player, timer);
|
||||
}
|
||||
|
||||
function clearPlayerGestureHud(art: Artplayer) {
|
||||
const player = art.template.$player;
|
||||
const currentTimer = playerGestureHudTimers.get(player);
|
||||
if (currentTimer !== undefined) {
|
||||
window.clearTimeout(currentTimer);
|
||||
playerGestureHudTimers.delete(player);
|
||||
}
|
||||
player.querySelector<HTMLElement>(`.${PLAYER_GESTURE_HUD_CLASS}`)?.remove();
|
||||
}
|
||||
|
||||
function playerGestureHudIcon(kind: PlayerGestureHudKind, value: string) {
|
||||
if (kind === "brightness") {
|
||||
return `
|
||||
<svg viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="4.2" stroke="currentColor" stroke-width="1.7"/>
|
||||
<path d="M12 2.8v2.1M12 19.1v2.1M4.9 4.9l1.5 1.5M17.6 17.6l1.5 1.5M2.8 12h2.1M19.1 12h2.1M4.9 19.1l1.5-1.5M17.6 6.4l1.5-1.5" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/>
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||
|
||||
if (value === "0%") {
|
||||
return `
|
||||
<svg viewBox="0 0 24 24" fill="none">
|
||||
<path d="M4.8 9.7h3l4.3-3.6v11.8l-4.3-3.6h-3V9.7Z" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="m16.1 9.9 4.1 4.1M20.2 9.9 16.1 14" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/>
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||
|
||||
return `
|
||||
<svg viewBox="0 0 24 24" fill="none">
|
||||
<path d="M4.8 9.7h3l4.3-3.6v11.8l-4.3-3.6h-3V9.7Z" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M15.4 9.2a4.2 4.2 0 0 1 0 5.6M18 6.7a7.7 7.7 0 0 1 0 10.6" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/>
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||
|
||||
function noop() {
|
||||
// noop
|
||||
}
|
||||
|
||||
function createOrientationControl(): NonNullable<Option["controls"]>[number] {
|
||||
return {
|
||||
name: ORIENTATION_CONTROL_NAME,
|
||||
@@ -765,6 +876,53 @@ function orientationLabel(mode: OrientationMode) {
|
||||
return mode === "landscape" ? "横屏" : "竖屏";
|
||||
}
|
||||
|
||||
function applyPlayerBrightness(art: Artplayer, brightness: number) {
|
||||
art.template.$player.style.setProperty(
|
||||
"--video-player-brightness",
|
||||
clamp(brightness, BRIGHTNESS_MIN, BRIGHTNESS_MAX).toFixed(2)
|
||||
);
|
||||
}
|
||||
|
||||
function getPlayerBrightness(art: Artplayer) {
|
||||
const raw = art.template.$player.style.getPropertyValue(
|
||||
"--video-player-brightness"
|
||||
);
|
||||
if (!raw.trim()) return DEFAULT_SETTINGS.brightness;
|
||||
return clampNumber(
|
||||
Number(raw),
|
||||
DEFAULT_SETTINGS.brightness,
|
||||
BRIGHTNESS_MIN,
|
||||
BRIGHTNESS_MAX
|
||||
);
|
||||
}
|
||||
|
||||
function mobileGestureSeekSpan(duration: number) {
|
||||
return Math.min(
|
||||
duration,
|
||||
clamp(
|
||||
duration * GESTURE_SEEK_DURATION_RATIO,
|
||||
GESTURE_SEEK_MIN_SECONDS,
|
||||
GESTURE_SEEK_MAX_SECONDS
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function seekGestureLabel(
|
||||
startTime: number,
|
||||
targetTime: number,
|
||||
duration: number
|
||||
) {
|
||||
const action = targetTime >= startTime ? "快进" : "快退";
|
||||
return `${action} ${formatClock(targetTime)} / ${formatClock(duration)}`;
|
||||
}
|
||||
|
||||
function formatBrightnessPercent(brightness: number) {
|
||||
const normalized =
|
||||
(clamp(brightness, BRIGHTNESS_MIN, BRIGHTNESS_MAX) - BRIGHTNESS_MIN) /
|
||||
(BRIGHTNESS_MAX - BRIGHTNESS_MIN);
|
||||
return formatPercent(normalized);
|
||||
}
|
||||
|
||||
function createHlsSourceLoader(
|
||||
onError: (error: PlayerError | null) => void
|
||||
) {
|
||||
@@ -864,6 +1022,7 @@ function bindLongPressFast(
|
||||
let pressTimer: number | null = null;
|
||||
let fastActive = false;
|
||||
let previousRate = NORMAL_RATE;
|
||||
let suppressNextClick = false;
|
||||
|
||||
function clearPressTimer() {
|
||||
if (pressTimer !== null) {
|
||||
@@ -899,9 +1058,13 @@ function bindLongPressFast(
|
||||
}, LONG_PRESS_MS);
|
||||
}
|
||||
|
||||
function endPress() {
|
||||
function endPress(suppressClick = false) {
|
||||
clearPressTimer();
|
||||
const wasFastActive = fastActive;
|
||||
setFast(false);
|
||||
if (wasFastActive && suppressClick) {
|
||||
suppressNextClick = true;
|
||||
}
|
||||
}
|
||||
|
||||
function handleMouseDown(event: MouseEvent) {
|
||||
@@ -909,20 +1072,263 @@ function bindLongPressFast(
|
||||
startPress();
|
||||
}
|
||||
|
||||
function handleMouseUp(event: MouseEvent) {
|
||||
if (event.button !== 0) return;
|
||||
endPress(true);
|
||||
}
|
||||
|
||||
function handlePressEnd() {
|
||||
endPress();
|
||||
}
|
||||
|
||||
function handleClick(event: MouseEvent) {
|
||||
if (!suppressNextClick) return;
|
||||
suppressNextClick = false;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.stopImmediatePropagation();
|
||||
}
|
||||
|
||||
video.addEventListener("mousedown", handleMouseDown);
|
||||
video.addEventListener("mouseup", endPress);
|
||||
video.addEventListener("mouseleave", endPress);
|
||||
video.addEventListener("pause", endPress);
|
||||
video.addEventListener("ended", endPress);
|
||||
video.addEventListener("mouseup", handleMouseUp);
|
||||
video.addEventListener("click", handleClick, true);
|
||||
video.addEventListener("mouseleave", handlePressEnd);
|
||||
video.addEventListener("pause", handlePressEnd);
|
||||
video.addEventListener("ended", handlePressEnd);
|
||||
|
||||
return () => {
|
||||
clearPressTimer();
|
||||
setFast(false);
|
||||
video.removeEventListener("mousedown", handleMouseDown);
|
||||
video.removeEventListener("mouseup", endPress);
|
||||
video.removeEventListener("mouseleave", endPress);
|
||||
video.removeEventListener("pause", endPress);
|
||||
video.removeEventListener("ended", endPress);
|
||||
video.removeEventListener("mouseup", handleMouseUp);
|
||||
video.removeEventListener("click", handleClick, true);
|
||||
video.removeEventListener("mouseleave", handlePressEnd);
|
||||
video.removeEventListener("pause", handlePressEnd);
|
||||
video.removeEventListener("ended", handlePressEnd);
|
||||
};
|
||||
}
|
||||
|
||||
function bindMobilePlayerGestures(
|
||||
art: Artplayer,
|
||||
video: HTMLVideoElement,
|
||||
onFastChange: (active: boolean) => void,
|
||||
onGestureHud: (label: string, duration?: number) => void
|
||||
) {
|
||||
if (!shouldEnableMobileGestures()) return noop;
|
||||
|
||||
const player = art.template.$player;
|
||||
let state: MobileGestureState | null = null;
|
||||
|
||||
function clearPressTimer() {
|
||||
if (!state || state.pressTimer === null) return;
|
||||
window.clearTimeout(state.pressTimer);
|
||||
state.pressTimer = null;
|
||||
}
|
||||
|
||||
function setTouchFast(next: boolean) {
|
||||
if (!state || state.fastActive === next) return;
|
||||
if (next) {
|
||||
state.previousRate =
|
||||
Number.isFinite(video.playbackRate) && video.playbackRate > 0
|
||||
? video.playbackRate
|
||||
: NORMAL_RATE;
|
||||
state.fastActive = true;
|
||||
onFastChange(true);
|
||||
video.playbackRate = FAST_RATE;
|
||||
return;
|
||||
}
|
||||
|
||||
const previousRate = state.previousRate;
|
||||
state.fastActive = false;
|
||||
onFastChange(false);
|
||||
video.playbackRate = previousRate;
|
||||
}
|
||||
|
||||
function resetGesture() {
|
||||
clearPressTimer();
|
||||
if (state?.fastActive) {
|
||||
setTouchFast(false);
|
||||
}
|
||||
state = null;
|
||||
}
|
||||
|
||||
function handleTouchStart(event: TouchEvent) {
|
||||
if (event.touches.length !== 1 || art.isLock) return;
|
||||
|
||||
const touch = event.touches[0];
|
||||
const rect = player.getBoundingClientRect();
|
||||
const localX = touch.clientX - rect.left;
|
||||
state = {
|
||||
startX: touch.clientX,
|
||||
startY: touch.clientY,
|
||||
startTime: video.currentTime || 0,
|
||||
startVolume: video.muted ? 0 : clamp(video.volume, 0, 1),
|
||||
startBrightness: getPlayerBrightness(art),
|
||||
side: localX < rect.width / 2 ? "left" : "right",
|
||||
mode: null,
|
||||
targetTime: video.currentTime || 0,
|
||||
moved: false,
|
||||
fastActive: false,
|
||||
previousRate: video.playbackRate || NORMAL_RATE,
|
||||
pressTimer: null,
|
||||
};
|
||||
|
||||
state.pressTimer = window.setTimeout(() => {
|
||||
if (!state || state.mode || state.moved || video.paused || video.ended) {
|
||||
return;
|
||||
}
|
||||
setTouchFast(true);
|
||||
}, LONG_PRESS_MS);
|
||||
}
|
||||
|
||||
function lockGestureMode(dx: number, dy: number) {
|
||||
if (!state) return;
|
||||
const absX = Math.abs(dx);
|
||||
const absY = Math.abs(dy);
|
||||
if (absX < GESTURE_ACTIVATION_PX && absY < GESTURE_ACTIVATION_PX) return;
|
||||
|
||||
state.moved = true;
|
||||
clearPressTimer();
|
||||
|
||||
if (absX >= absY * GESTURE_DIRECTION_LOCK_RATIO) {
|
||||
state.mode = "seek";
|
||||
return;
|
||||
}
|
||||
|
||||
if (absY >= absX * GESTURE_DIRECTION_LOCK_RATIO) {
|
||||
if (!isPlayerExpanded(art)) {
|
||||
resetGesture();
|
||||
return;
|
||||
}
|
||||
state.mode = state.side === "right" ? "volume" : "brightness";
|
||||
}
|
||||
}
|
||||
|
||||
function handleTouchMove(event: TouchEvent) {
|
||||
if (!state) return;
|
||||
if (event.touches.length !== 1) {
|
||||
resetGesture();
|
||||
return;
|
||||
}
|
||||
|
||||
const touch = event.touches[0];
|
||||
const dx = touch.clientX - state.startX;
|
||||
const dy = touch.clientY - state.startY;
|
||||
|
||||
if (state.fastActive) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!state.mode) {
|
||||
lockGestureMode(dx, dy);
|
||||
if (!state || !state.mode) return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
if (state.mode === "seek") {
|
||||
handleSeekGesture(event, dx);
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.mode === "volume") {
|
||||
handleVolumeGesture(touch.clientY);
|
||||
return;
|
||||
}
|
||||
|
||||
handleBrightnessGesture(touch.clientY);
|
||||
}
|
||||
|
||||
function handleSeekGesture(event: TouchEvent, dx: number) {
|
||||
if (!state) return;
|
||||
const duration = video.duration;
|
||||
if (!Number.isFinite(duration) || duration <= 0) return;
|
||||
const rect = player.getBoundingClientRect();
|
||||
const span = mobileGestureSeekSpan(duration);
|
||||
const targetTime = clamp(
|
||||
state.startTime + (dx / Math.max(1, rect.width)) * span,
|
||||
0,
|
||||
duration
|
||||
);
|
||||
state.targetTime = targetTime;
|
||||
art.emit("setBar", "played", targetTime / duration, event);
|
||||
if (!isPlayerExpanded(art)) return;
|
||||
onGestureHud(seekGestureLabel(state.startTime, targetTime, duration), 560);
|
||||
}
|
||||
|
||||
function handleVolumeGesture(currentY: number) {
|
||||
if (!state) return;
|
||||
const rect = player.getBoundingClientRect();
|
||||
const delta = (state.startY - currentY) / Math.max(1, rect.height);
|
||||
const nextVolume = clamp(state.startVolume + delta, 0, 1);
|
||||
const normalized = Math.round(nextVolume * 100) / 100;
|
||||
video.volume = normalized;
|
||||
video.muted = normalized <= 0;
|
||||
showPlayerGestureHud(art, "volume", formatPercent(normalized));
|
||||
}
|
||||
|
||||
function handleBrightnessGesture(currentY: number) {
|
||||
if (!state) return;
|
||||
const rect = player.getBoundingClientRect();
|
||||
const delta =
|
||||
((state.startY - currentY) / Math.max(1, rect.height)) *
|
||||
GESTURE_VERTICAL_SCALE;
|
||||
const nextBrightness = clamp(
|
||||
state.startBrightness + delta,
|
||||
BRIGHTNESS_MIN,
|
||||
BRIGHTNESS_MAX
|
||||
);
|
||||
applyPlayerBrightness(art, nextBrightness);
|
||||
showPlayerGestureHud(art, "brightness", formatBrightnessPercent(nextBrightness));
|
||||
}
|
||||
|
||||
function handleTouchEnd() {
|
||||
if (!state) return;
|
||||
|
||||
if (state.mode === "seek") {
|
||||
const duration = video.duration;
|
||||
if (Number.isFinite(duration) && duration > 0) {
|
||||
art.seek = clamp(state.targetTime, 0, duration);
|
||||
if (isPlayerExpanded(art)) {
|
||||
onGestureHud(
|
||||
seekGestureLabel(state.startTime, state.targetTime, duration),
|
||||
720
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if (state.mode === "brightness") {
|
||||
writePlayerSettings({
|
||||
brightness: getPlayerBrightness(art),
|
||||
});
|
||||
} else if (state.mode === "volume") {
|
||||
writePlayerSettings({
|
||||
volume: clamp(video.volume, 0, 1),
|
||||
muted: video.muted,
|
||||
});
|
||||
}
|
||||
|
||||
resetGesture();
|
||||
}
|
||||
|
||||
video.addEventListener("touchstart", handleTouchStart, { passive: true });
|
||||
video.addEventListener("touchmove", handleTouchMove, { passive: false });
|
||||
video.addEventListener("touchend", handleTouchEnd);
|
||||
video.addEventListener("touchcancel", resetGesture);
|
||||
video.addEventListener("pause", resetGesture);
|
||||
video.addEventListener("ended", resetGesture);
|
||||
window.addEventListener("blur", resetGesture);
|
||||
|
||||
return () => {
|
||||
clearPlayerGestureHud(art);
|
||||
resetGesture();
|
||||
video.removeEventListener("touchstart", handleTouchStart);
|
||||
video.removeEventListener("touchmove", handleTouchMove);
|
||||
video.removeEventListener("touchend", handleTouchEnd);
|
||||
video.removeEventListener("touchcancel", resetGesture);
|
||||
video.removeEventListener("pause", resetGesture);
|
||||
video.removeEventListener("ended", resetGesture);
|
||||
window.removeEventListener("blur", resetGesture);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -971,70 +1377,18 @@ function bindProgressPreview(
|
||||
};
|
||||
}
|
||||
|
||||
function maybeOfferResume(
|
||||
videoKey: string,
|
||||
video: HTMLVideoElement,
|
||||
onResumeAvailable: (prompt: ResumePrompt | null) => void
|
||||
) {
|
||||
const record = readPlaybackRecord(videoKey);
|
||||
const duration = video.duration;
|
||||
if (
|
||||
!record ||
|
||||
!Number.isFinite(duration) ||
|
||||
duration <= 0 ||
|
||||
record.time < RESUME_MIN_SECONDS ||
|
||||
record.time > duration - RESUME_END_GAP_SECONDS
|
||||
) {
|
||||
onResumeAvailable(null);
|
||||
return;
|
||||
}
|
||||
onResumeAvailable({ time: record.time });
|
||||
}
|
||||
|
||||
function savePlaybackRecord(videoKey: string, video: HTMLVideoElement) {
|
||||
const duration = video.duration;
|
||||
const time = video.currentTime;
|
||||
if (!Number.isFinite(duration) || duration <= 0 || !Number.isFinite(time)) {
|
||||
return;
|
||||
}
|
||||
if (time > duration - RESUME_END_GAP_SECONDS) {
|
||||
clearPlaybackRecord(videoKey);
|
||||
return;
|
||||
}
|
||||
if (time < RESUME_MIN_SECONDS) return;
|
||||
|
||||
const key = playbackStorageKey(videoKey);
|
||||
const previous = readPlaybackRecord(videoKey);
|
||||
if (previous && Math.abs(previous.time - time) < 2) return;
|
||||
safeSetJSON(key, { time, duration, updatedAt: Date.now() });
|
||||
}
|
||||
|
||||
function readPlaybackRecord(videoKey: string): PlaybackRecord | null {
|
||||
const value = safeGetJSON<PlaybackRecord>(playbackStorageKey(videoKey));
|
||||
if (!value || Date.now() - value.updatedAt > 1000 * 60 * 60 * 24 * 30) {
|
||||
return null;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function clearPlaybackRecord(videoKey: string) {
|
||||
try {
|
||||
localStorage.removeItem(playbackStorageKey(videoKey));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
function playbackStorageKey(videoKey: string) {
|
||||
return PLAYBACK_KEY_PREFIX + encodeURIComponent(videoKey);
|
||||
}
|
||||
|
||||
function readPlayerSettings(): PlayerSettings {
|
||||
const saved = safeGetJSON<Partial<PlayerSettings>>(SETTINGS_KEY) ?? {};
|
||||
return {
|
||||
volume: clampNumber(saved.volume, DEFAULT_SETTINGS.volume, 0, 1),
|
||||
muted: typeof saved.muted === "boolean" ? saved.muted : DEFAULT_SETTINGS.muted,
|
||||
playbackRate: clampNumber(saved.playbackRate, DEFAULT_SETTINGS.playbackRate, 0.5, 3),
|
||||
brightness: clampNumber(
|
||||
saved.brightness,
|
||||
DEFAULT_SETTINGS.brightness,
|
||||
BRIGHTNESS_MIN,
|
||||
BRIGHTNESS_MAX
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1069,17 +1423,6 @@ function syncPreviewVideo(video: HTMLVideoElement | null, ratio: number) {
|
||||
}
|
||||
}
|
||||
|
||||
function showTransientHud(
|
||||
setGestureHud: (hud: GestureHud | null) => void,
|
||||
label: string
|
||||
) {
|
||||
const key = Date.now();
|
||||
setGestureHud({ key, label });
|
||||
window.setTimeout(() => {
|
||||
setGestureHud(null);
|
||||
}, 900);
|
||||
}
|
||||
|
||||
function fallbackCopyText(text: string) {
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = text;
|
||||
@@ -1143,3 +1486,7 @@ function formatClock(seconds: number) {
|
||||
}
|
||||
return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function formatPercent(value: number) {
|
||||
return `${Math.round(clamp(value, 0, 1) * 100)}%`;
|
||||
}
|
||||
|
||||
@@ -105,7 +105,7 @@ export default function HomePage() {
|
||||
const latest = latestVideos.slice(0, displayCount);
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<AppShell mobileAutoHideNav>
|
||||
<div className="container page-section">
|
||||
<PromoStrip />
|
||||
<SearchPanel />
|
||||
|
||||
@@ -84,14 +84,66 @@ export default function VideoDetailPage() {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<AppShell>
|
||||
<AppShell mobileAutoHideNav>
|
||||
<div className="vd-page">
|
||||
<div className="vd-ambient" aria-hidden="true" />
|
||||
<div className="container vd-page__inner">
|
||||
<div className="vd-skeleton">
|
||||
<div className="vd-skeleton__player" />
|
||||
<div className="vd-skeleton__title" />
|
||||
<div className="vd-skeleton__meta" />
|
||||
<div
|
||||
className="vd-layout vd-skeleton"
|
||||
aria-busy="true"
|
||||
aria-label="视频详情加载中"
|
||||
>
|
||||
<div className="vd-main">
|
||||
<div className="vd-skeleton__player" />
|
||||
|
||||
<div className="vd-skeleton__summary">
|
||||
<div className="vd-skeleton__chips">
|
||||
<span className="vd-skeleton__chip vd-skeleton__chip--source" />
|
||||
<span className="vd-skeleton__chip" />
|
||||
<span className="vd-skeleton__chip vd-skeleton__chip--plain" />
|
||||
<span className="vd-skeleton__chip vd-skeleton__chip--plain" />
|
||||
</div>
|
||||
<div className="vd-skeleton__title" />
|
||||
<div className="vd-skeleton__actions">
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="vd-skeleton__info">
|
||||
<span className="vd-skeleton__section-head" />
|
||||
<span className="vd-skeleton__line" />
|
||||
<span className="vd-skeleton__line vd-skeleton__line--short" />
|
||||
<div className="vd-skeleton__tag-row">
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside className="vd-rail vd-skeleton__rail">
|
||||
<div className="vd-rail__head">
|
||||
<span className="vd-rail__head-icon" aria-hidden="true">
|
||||
<span />
|
||||
<span />
|
||||
</span>
|
||||
<span className="vd-skeleton__rail-head" />
|
||||
</div>
|
||||
<ul className="vd-rail__list vd-skeleton__rail-list">
|
||||
{Array.from({ length: 6 }).map((_, index) => (
|
||||
<li key={index} className="vd-skeleton__rail-item">
|
||||
<span className="vd-skeleton__rail-thumb" />
|
||||
<span className="vd-skeleton__rail-body">
|
||||
<span className="vd-skeleton__rail-title" />
|
||||
<span className="vd-skeleton__rail-title vd-skeleton__rail-title--short" />
|
||||
<span className="vd-skeleton__rail-meta" />
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -101,7 +153,7 @@ export default function VideoDetailPage() {
|
||||
|
||||
if (!detail) {
|
||||
return (
|
||||
<AppShell>
|
||||
<AppShell mobileAutoHideNav>
|
||||
<div className="vd-page">
|
||||
<div className="container vd-page__inner">
|
||||
<div className="vd-empty">视频不存在或已被移除</div>
|
||||
@@ -112,7 +164,7 @@ export default function VideoDetailPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<AppShell mobileAutoHideNav>
|
||||
<div className="vd-page">
|
||||
{/* Ambient 背景层:用海报作模糊底色,叠加渐变过渡到页面背景 */}
|
||||
<div
|
||||
@@ -141,13 +193,15 @@ export default function VideoDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<VideoMetaHeader video={detail} />
|
||||
<section className="vd-summary" aria-label="当前视频">
|
||||
<VideoMetaHeader video={detail} />
|
||||
|
||||
<VideoActions
|
||||
video={detail}
|
||||
onHideVideo={handleHideVideo}
|
||||
hideSaving={hideSaving}
|
||||
/>
|
||||
<VideoActions
|
||||
video={detail}
|
||||
onHideVideo={handleHideVideo}
|
||||
hideSaving={hideSaving}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<VideoInfoPanel
|
||||
video={detail}
|
||||
|
||||
@@ -10,6 +10,10 @@
|
||||
font-size: var(--font-base);
|
||||
}
|
||||
|
||||
.app-shell__nav-stack {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.app-shell__main {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
|
||||
@@ -179,6 +179,24 @@
|
||||
|
||||
/* ----- 响应式 ----- */
|
||||
@media (max-width: 768px) {
|
||||
.app-shell--mobile-auto-hide-nav .app-shell__nav-stack {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: var(--z-nav);
|
||||
transform: translateY(0);
|
||||
transition: transform 220ms var(--ease-out);
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.app-shell--mobile-auto-hide-nav.is-mobile-nav-hidden .app-shell__nav-stack {
|
||||
transform: translateY(-100%);
|
||||
}
|
||||
|
||||
.app-shell--mobile-auto-hide-nav .main-nav {
|
||||
position: relative;
|
||||
z-index: auto;
|
||||
}
|
||||
|
||||
.main-nav__inner {
|
||||
height: 56px;
|
||||
gap: var(--space-3);
|
||||
|
||||
+557
-285
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,33 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
const actionsSource = readFileSync(
|
||||
new URL("../src/components/VideoActions.tsx", import.meta.url),
|
||||
"utf8"
|
||||
);
|
||||
const detailCss = readFileSync(
|
||||
new URL("../src/styles/video-detail.css", import.meta.url),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
test("detail dislike does not locally decrement persisted likes", () => {
|
||||
const match = /function handleDislike\(\) \{([\s\S]*?)\n return \(/.exec(
|
||||
actionsSource
|
||||
);
|
||||
assert.ok(match, "handleDislike block should be present");
|
||||
assert.match(match[1], /setDisliked\(true\)/);
|
||||
assert.doesNotMatch(match[1], /setLikes/);
|
||||
});
|
||||
|
||||
test("detail like and dislike buttons are visually separated", () => {
|
||||
assert.doesNotMatch(actionsSource, /vd-actions__divider/);
|
||||
assert.match(
|
||||
detailCss,
|
||||
/\.vd-actions__group\s*\{[^}]*gap:\s*var\(--space-2\)/s
|
||||
);
|
||||
assert.match(
|
||||
detailCss,
|
||||
/\.vd-actions__pill\s*\{[^}]*border:\s*1px solid var\(--border-subtle\)[^}]*border-radius:\s*var\(--radius-sm\)/s
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
const detailCss = readFileSync(
|
||||
new URL("../src/styles/video-detail.css", import.meta.url),
|
||||
"utf8"
|
||||
);
|
||||
const playerSource = readFileSync(
|
||||
new URL("../src/components/VideoPlayer.tsx", import.meta.url),
|
||||
"utf8"
|
||||
);
|
||||
const detailPageSource = readFileSync(
|
||||
new URL("../src/pages/VideoDetailPage.tsx", import.meta.url),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
test("detail player poster uses full-frame contain scaling", () => {
|
||||
assert.match(
|
||||
detailCss,
|
||||
/\.video-player \.art-poster\s*\{[^}]*background-position:\s*center[^}]*background-repeat:\s*no-repeat[^}]*background-size:\s*contain/s
|
||||
);
|
||||
});
|
||||
|
||||
test("detail player does not keep playback resume state", () => {
|
||||
assert.doesNotMatch(playerSource, /ResumePrompt/);
|
||||
assert.doesNotMatch(playerSource, /PlaybackRecord/);
|
||||
assert.doesNotMatch(playerSource, /PLAYBACK_KEY_PREFIX/);
|
||||
assert.doesNotMatch(playerSource, /maybeOfferResume/);
|
||||
assert.doesNotMatch(playerSource, /savePlaybackRecord/);
|
||||
assert.doesNotMatch(playerSource, /clearPlaybackRecord/);
|
||||
assert.doesNotMatch(playerSource, /video-player__resume/);
|
||||
assert.doesNotMatch(detailCss, /video-player__resume/);
|
||||
});
|
||||
|
||||
test("detail loading skeleton matches current desktop video page layout", () => {
|
||||
assert.match(detailPageSource, /className="vd-layout vd-skeleton"/);
|
||||
assert.match(detailPageSource, /className="vd-skeleton__summary"/);
|
||||
assert.match(detailPageSource, /className="vd-skeleton__info"/);
|
||||
assert.match(detailPageSource, /className="vd-rail vd-skeleton__rail"/);
|
||||
assert.match(detailPageSource, /Array\.from\(\{ length: 6 \}\)/);
|
||||
assert.doesNotMatch(detailPageSource, /className="vd-skeleton__meta"/);
|
||||
assert.match(
|
||||
detailCss,
|
||||
/\.vd-skeleton__player\s*\{[^}]*aspect-ratio:\s*16 \/ 9[^}]*border-radius:\s*0/s
|
||||
);
|
||||
assert.match(
|
||||
detailCss,
|
||||
/\.vd-skeleton__summary,\s*\.vd-skeleton__info\s*\{[^}]*border:\s*1px solid var\(--border-default\)[^}]*border-radius:\s*var\(--radius-md\)/s
|
||||
);
|
||||
assert.match(
|
||||
detailCss,
|
||||
/\.vd-skeleton__rail-item\s*\{[^}]*grid-template-columns:\s*150px minmax\(0,\s*1fr\)/s
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
detailCss,
|
||||
/\.vd-skeleton__player\s*\{[^}]*box-shadow:\s*var\(--shadow-lg\)/s
|
||||
);
|
||||
});
|
||||
|
||||
test("detail video title uses a restrained size", () => {
|
||||
assert.match(
|
||||
detailCss,
|
||||
/\.vd-header__title\s*\{[^}]*font-size:\s*var\(--font-xl\)[^}]*line-height:\s*1\.34/s
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
detailCss,
|
||||
/\.vd-header__title\s*\{[^}]*font-size:\s*var\(--font-2xl\)/s
|
||||
);
|
||||
assert.match(
|
||||
detailCss,
|
||||
/@media \(max-width:\s*480px\)\s*\{[\s\S]*\.vd-header__title\s*\{[^}]*font-size:\s*var\(--font-base\)/s
|
||||
);
|
||||
});
|
||||
|
||||
test("detail player uses custom mobile gestures instead of ArtPlayer native gestures", () => {
|
||||
assert.match(playerSource, /gesture:\s*false/);
|
||||
assert.match(playerSource, /fastForward:\s*false/);
|
||||
assert.match(playerSource, /function bindMobilePlayerGestures/);
|
||||
assert.match(playerSource, /let suppressNextClick = false/);
|
||||
assert.match(playerSource, /endPress\(true\)/);
|
||||
assert.match(playerSource, /event\.stopImmediatePropagation\(\)/);
|
||||
assert.match(playerSource, /addEventListener\("click", handleClick, true\)/);
|
||||
assert.match(playerSource, /state\.mode = "seek"/);
|
||||
assert.match(playerSource, /state\.side === "right" \? "volume" : "brightness"/);
|
||||
assert.doesNotMatch(playerSource, /function isPlayerLandscapeExpanded/);
|
||||
assert.doesNotMatch(playerSource, /getEffectivePlayerOrientation\(art\) === "landscape"/);
|
||||
assert.match(playerSource, /if \(!isPlayerExpanded\(art\)\) \{\s*resetGesture\(\);/);
|
||||
assert.match(playerSource, /if \(!isPlayerExpanded\(art\)\) return;\s*onGestureHud\(seekGestureLabel/);
|
||||
assert.match(playerSource, /const FAST_RATE_CLASS = "art-fast-rate-active"/);
|
||||
assert.match(playerSource, /const FAST_RATE_HINT_CLASS = "video-player__art-rate-hint"/);
|
||||
assert.match(playerSource, /const PLAYER_GESTURE_HUD_CLASS = "video-player__art-gesture-hud"/);
|
||||
assert.match(playerSource, /setPlayerFastRateHint\(art, active\)/);
|
||||
assert.match(playerSource, /player\.appendChild\(hint\)/);
|
||||
assert.match(playerSource, /showPlayerGestureHud\(art, "volume", formatPercent\(normalized\)\)/);
|
||||
assert.match(playerSource, /showPlayerGestureHud\(art, "brightness", formatBrightnessPercent\(nextBrightness\)\)/);
|
||||
assert.match(playerSource, /stroke-width="1\.7"/);
|
||||
assert.match(playerSource, /M15\.4 9\.2a4\.2 4\.2 0 0 1 0 5\.6/);
|
||||
assert.match(playerSource, /M4\.8 9\.7h3l4\.3-3\.6v11\.8l-4\.3-3\.6h-3/);
|
||||
assert.doesNotMatch(playerSource, /stroke-width="2\.2"/);
|
||||
assert.doesNotMatch(playerSource, /onGestureHud\(`音量 /);
|
||||
assert.doesNotMatch(playerSource, /onGestureHud\(`亮度 /);
|
||||
assert.match(playerSource, /fullscreen:\s*true/);
|
||||
assert.match(playerSource, /fullscreenWeb:\s*!enableOrientationControl/);
|
||||
assert.doesNotMatch(playerSource, /addTextTrack\("captions", "Playback rate"/);
|
||||
assert.doesNotMatch(playerSource, /new VTTCue\(/);
|
||||
assert.doesNotMatch(playerSource, /onGestureHud\(`\$\{FAST_RATE\}x`/);
|
||||
assert.match(playerSource, /addEventListener\("touchmove", handleTouchMove, \{ passive: false \}\)/);
|
||||
});
|
||||
|
||||
test("detail player fullscreen long-press rate hint lives inside ArtPlayer", () => {
|
||||
assert.match(
|
||||
detailCss,
|
||||
/\.video-player__rate-hint,\s*\.video-player__art-rate-hint\s*\{[\s\S]*position:\s*absolute[\s\S]*top:\s*12px/s
|
||||
);
|
||||
assert.match(
|
||||
detailCss,
|
||||
/\.video-player__art-rate-hint\s*\{[^}]*z-index:\s*130/s
|
||||
);
|
||||
assert.match(
|
||||
detailCss,
|
||||
/\.art-video-player\.art-fullscreen \.video-player__art-rate-hint,[\s\S]*\.art-video-player\.art-fullscreen-web \.video-player__art-rate-hint,[\s\S]*position:\s*fixed/s
|
||||
);
|
||||
});
|
||||
|
||||
test("detail player mobile brightness gesture only filters the video surface", () => {
|
||||
assert.match(
|
||||
detailCss,
|
||||
/\.video-player \.art-video,\s*\.video-player \.art-poster\s*\{[^}]*filter:\s*brightness\(var\(--video-player-brightness, 1\)\)/s
|
||||
);
|
||||
assert.match(
|
||||
detailCss,
|
||||
/@media \(hover: none\) and \(pointer: coarse\)\s*\{[\s\S]*\.video-player \.art-video-player,[\s\S]*touch-action:\s*pan-y/s
|
||||
);
|
||||
assert.match(
|
||||
detailCss,
|
||||
/\.video-player \.art-video-player\.art-fullscreen,[\s\S]*\.video-player \.art-video-player\.art-fullscreen-web,[\s\S]*touch-action:\s*none/s
|
||||
);
|
||||
assert.match(
|
||||
detailCss,
|
||||
/\.video-player__art-gesture-hud\s*\{[^}]*top:\s*16%[^}]*background:\s*rgba\(18,\s*18,\s*20,\s*0\.8\)[^}]*font-size:\s*18px/s
|
||||
);
|
||||
assert.match(
|
||||
detailCss,
|
||||
/\.video-player__art-gesture-hud-icon\s*\{[^}]*width:\s*18px[^}]*height:\s*18px[^}]*transform:\s*translateY\(-1px\)/s
|
||||
);
|
||||
assert.match(
|
||||
detailCss,
|
||||
/\.video-player__art-gesture-hud-icon svg\s*\{[^}]*width:\s*18px[^}]*height:\s*18px/s
|
||||
);
|
||||
assert.match(
|
||||
detailCss,
|
||||
/\.art-video-player\.art-fullscreen \.video-player__art-gesture-hud,[\s\S]*\.art-video-player\.art-manual-orientation \.video-player__art-gesture-hud\s*\{[^}]*position:\s*fixed/s
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user