-
+
标签
{onTagsChange && (
diff --git a/src/components/VideoMetaHeader.tsx b/src/components/VideoMetaHeader.tsx
index 24e2a5b..92db171 100644
--- a/src/components/VideoMetaHeader.tsx
+++ b/src/components/VideoMetaHeader.tsx
@@ -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 (
-
- {video.title}
-
-
{source && (
@@ -42,13 +39,28 @@ export function VideoMetaHeader({ video }: Props) {
{quality}
)}
- {duration && - {duration}
}
- -
+ {duration && (
+
-
+
+ {duration}
+
+ )}
+ -
+
{formatCount(video.views)} 次观看
- {published && - {published}
}
+ {published && (
+ -
+
+ {published}
+
+ )}
+
+
+ {video.title}
+
);
}
diff --git a/src/components/VideoPlayer.tsx b/src/components/VideoPlayer.tsx
index 4b74ce6..5e84b3a 100644
--- a/src/components/VideoPlayer.tsx
+++ b/src/components/VideoPlayer.tsx
@@ -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
();
export function VideoPlayer({
- id,
src,
poster,
previewSrc,
@@ -112,13 +132,11 @@ export function VideoPlayer({
const previewVideoRef = useRef(null);
const onFirstPlayRef = useRef(onFirstPlay);
const playedRef = useRef(false);
- const videoKey = id || src;
- const [fastActive, setFastActive] = useState(false);
const [retryNonce, setRetryNonce] = useState(0);
- const [resumePrompt, setResumePrompt] = useState(null);
const [playerError, setPlayerError] = useState(null);
const [gestureHud, setGestureHud] = useState(null);
const [previewHover, setPreviewHover] = useState(null);
+ const gestureHudTimerRef = useRef(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 (
-
- {resumePrompt && !playerError && (
-
- 上次播放到 {formatClock(resumePrompt.time)}
-
-
-
- )}
-
{playerError && (
{playerError.title}
@@ -268,11 +261,6 @@ export function VideoPlayer({
)}
- {fastActive && (
-
- 2x
-
- )}
);
}
@@ -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;
onFirstPlayRef: MutableRefObject;
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(`.${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(`.${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(`.${PLAYER_GESTURE_HUD_CLASS}`)?.remove();
+}
+
+function playerGestureHudIcon(kind: PlayerGestureHudKind, value: string) {
+ if (kind === "brightness") {
+ return `
+
+ `;
+ }
+
+ if (value === "0%") {
+ return `
+
+ `;
+ }
+
+ return `
+
+ `;
+}
+
+function noop() {
+ // noop
+}
+
function createOrientationControl(): NonNullable