Improve video detail player controls and layout

This commit is contained in:
nianzhibai
2026-06-07 15:17:08 +08:00
parent 9def08b0c5
commit 2f2bfbfcdc
13 changed files with 1482 additions and 506 deletions
+76 -6
View File
@@ -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 />
+5 -7
View File
@@ -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) => (
+19 -6
View File
@@ -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${
+3 -3
View File
@@ -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 && (
+20 -8
View File
@@ -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
View File
@@ -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)}%`;
}
+1 -1
View File
@@ -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 />
+67 -13
View File
@@ -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}
+4
View File
@@ -10,6 +10,10 @@
font-size: var(--font-base);
}
.app-shell__nav-stack {
flex: 0 0 auto;
}
.app-shell__main {
flex: 1;
width: 100%;
+18
View File
@@ -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);
File diff suppressed because it is too large Load Diff
+33
View File
@@ -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
);
});
+155
View File
@@ -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
);
});