Files
91/src/components/VideoPlayer.tsx
T
nianzhibai ae324d3752 feat: add Unicom cloud drive source operations
Add Unicom cloud drive support for source-file deletion and crawler uploads.

- Implement source-file removal for Unicom cloud drive so deleting videos can also remove the original cloud-drive file when requested.

- Resolve Unicom cloud drive source identifiers across file FID, object ID, directory ID, rename, and delete flows.

- Add upload support for Spider91 crawler imports targeting Unicom cloud drive storage.

- Add Unicom cloud drive QR login backend APIs, frontend form support, and tests.

- Extend drive capability metadata, scanner behavior, proxy handling, preview handling, and migration coverage for cloud-drive source operations.

- Rename Chinese display labels from 联通沃盘 to 联通网盘 and from 123 云盘 to 123网盘 while keeping the root README aligned with origin/main.

- Add referrer-policy coverage for 302 video playback and update related frontend playback tests.
2026-06-12 15:49:15 +08:00

1517 lines
43 KiB
TypeScript

import {
useEffect,
useRef,
useState,
type CSSProperties,
type MutableRefObject,
} from "react";
import Artplayer, { type Option } from "artplayer";
import type Hls from "hls.js";
type Props = {
id?: string;
src: string;
poster: string;
previewSrc?: string;
title: string;
/**
* 用户首次按下播放时触发。同一个 VideoPlayer 实例只会触发一次;
* 后续暂停-继续不会重复触发。换 src 时会重置(详情页切换视频用)。
*/
onFirstPlay?: () => void;
};
type PlayerError = {
title: string;
message: string;
};
type GestureHud = {
key: number;
label: string;
};
type PreviewHover = {
x: number;
ratio: number;
time: number;
};
type PlayerSettings = {
volume: number;
muted: boolean;
playbackRate: 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 & {
webkitRequestFullscreen?: () => Promise<void> | void;
mozRequestFullScreen?: () => Promise<void> | void;
msRequestFullscreen?: () => Promise<void> | void;
};
type FullscreenDocument = Document & {
webkitFullscreenElement?: Element | null;
mozFullScreenElement?: Element | null;
msFullscreenElement?: Element | null;
webkitExitFullscreen?: () => Promise<void> | void;
mozCancelFullScreen?: () => Promise<void> | void;
msExitFullscreen?: () => Promise<void> | void;
};
type LockableScreenOrientation = ScreenOrientation & {
lock?: (orientation: OrientationMode) => Promise<void>;
unlock?: () => void;
};
/** 长按多少毫秒后进入 2 倍速。短按属于普通点击,交给 ArtPlayer 处理。 */
const LONG_PRESS_MS = 400;
/** 长按时使用的播放倍速。 */
const FAST_RATE = 2;
/** 默认倍速。 */
const NORMAL_RATE = 1;
Artplayer.FAST_FORWARD_VALUE = FAST_RATE;
const SETTINGS_KEY = "video-site:player-settings";
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 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 MEDIA_REFERRER_POLICY = "no-referrer";
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({
src,
poster,
previewSrc,
title,
onFirstPlay,
}: Props) {
const mountRef = useRef<HTMLDivElement | null>(null);
const artRef = useRef<Artplayer | null>(null);
const previewVideoRef = useRef<HTMLVideoElement | null>(null);
const onFirstPlayRef = useRef<Props["onFirstPlay"]>(onFirstPlay);
const playedRef = useRef(false);
const [retryNonce, setRetryNonce] = useState(0);
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;
}, [onFirstPlay]);
useEffect(() => {
const mount = mountRef.current;
if (!mount) return;
playedRef.current = false;
setPlayerError(null);
setPreviewHover(null);
const cleanupPlayer = mountArtPlayer({
mount,
src,
poster,
title,
artRef,
playedRef,
onFirstPlayRef,
onFastChange: noop,
onError: setPlayerError,
onPreviewHover: setPreviewHover,
onGestureHud: showGestureHud,
});
return cleanupPlayer;
}, [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 showGestureHud(label: string, duration = 700) {
if (gestureHudTimerRef.current !== null) {
window.clearTimeout(gestureHudTimerRef.current);
}
setGestureHud({ key: Date.now(), label });
gestureHudTimerRef.current = window.setTimeout(() => {
setGestureHud(null);
gestureHudTimerRef.current = null;
}, duration);
}
function retryPlayback() {
setPlayerError(null);
setRetryNonce((n) => n + 1);
}
async function copySource() {
const absolute = new URL(src, window.location.href).href;
try {
await navigator.clipboard.writeText(absolute);
showGestureHud("播放地址已复制", 900);
} catch {
fallbackCopyText(absolute);
showGestureHud("播放地址已复制", 900);
}
}
const previewStyle = previewHover
? ({ left: `${previewHover.x}px` } as CSSProperties)
: undefined;
return (
<div className="video-player">
<div ref={mountRef} className="video-player__mount" />
{playerError && (
<div className="video-player__error" role="alert">
<div className="video-player__error-title">{playerError.title}</div>
<div className="video-player__error-message">{playerError.message}</div>
<div className="video-player__error-actions">
<button type="button" onClick={retryPlayback}>
</button>
<button type="button" onClick={copySource}>
</button>
</div>
</div>
)}
{previewSrc && previewHover && (
<div
className="video-player__seek-preview"
style={previewStyle}
aria-hidden="true"
>
<video
ref={previewVideoRef}
src={previewSrc}
poster={poster}
muted
playsInline
preload="metadata"
onLoadedMetadata={() =>
syncPreviewVideo(previewVideoRef.current, previewHover.ratio)
}
/>
<span>{formatClock(previewHover.time)}</span>
</div>
)}
{gestureHud && (
<div
key={gestureHud.key}
className="video-player__gesture-hud"
aria-hidden="true"
>
{gestureHud.label}
</div>
)}
</div>
);
}
function inferSourceType(src: string) {
const lower = src.toLowerCase();
const cleanPath = lower.split("#")[0].split("?")[0];
if (cleanPath.endsWith(".m3u8") || lower.includes(".m3u8")) return "m3u8";
if (isBackendNativeVideoRoute(cleanPath)) return "mp4";
return undefined;
}
function isBackendNativeVideoRoute(cleanPath: string) {
const pathname = sourcePathname(cleanPath);
return (
pathname.startsWith("/p/stream/") ||
pathname.startsWith("/p/upload/") ||
pathname.startsWith("/p/spider91/")
);
}
function sourcePathname(src: string) {
if (src.startsWith("http://") || src.startsWith("https://")) {
try {
return new URL(src).pathname.toLowerCase();
} catch {
return src;
}
}
return src;
}
function mountArtPlayer({
mount,
src,
poster,
title,
artRef,
playedRef,
onFirstPlayRef,
onFastChange,
onError,
onPreviewHover,
onGestureHud,
}: {
mount: HTMLDivElement;
src: string;
poster: string;
title: string;
artRef: MutableRefObject<Artplayer | null>;
playedRef: MutableRefObject<boolean>;
onFirstPlayRef: MutableRefObject<Props["onFirstPlay"]>;
onFastChange: (active: boolean) => 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,
url: "",
poster,
theme: "var(--video-player-progress)",
lang: "zh-cn",
volume: settings.volume,
muted: settings.muted,
autoplay: false,
autoSize: false,
playbackRate: true,
aspectRatio: true,
setting: true,
hotkey: true,
pip: true,
mutex: true,
fullscreen: true,
fullscreenWeb: !enableOrientationControl,
miniProgressBar: true,
backdrop: false,
playsInline: true,
lock: true,
gesture: false,
fastForward: false,
airplay: true,
customType: {
hls: loadHlsSource,
m3u8: loadHlsSource,
},
moreVideoAttr: {
preload: "metadata",
playsInline: true,
},
controls: enableOrientationControl ? [createOrientationControl()] : [],
contextmenu: [],
cssVar: {
"--art-theme": "var(--video-player-progress)",
},
};
if (sourceType) {
option.type = sourceType;
}
const art = new Artplayer(option);
artRef.current = art;
const video = art.video as VideoElementWithHls;
video.setAttribute("referrerpolicy", MEDIA_REFERRER_POLICY);
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);
art.url = src;
function preventContextMenu(event: Event) {
event.preventDefault();
}
function handlePlay() {
if (!playedRef.current) {
playedRef.current = true;
onFirstPlayRef.current?.();
}
onError(null);
}
function handleLoadStart() {
onError(null);
}
function handleReady() {
onError(null);
}
function handleVideoError() {
onError({
title: "视频源加载失败",
message: mediaErrorMessage(video.error),
});
}
function resetFastRate() {
fastActiveRef.current = false;
setPlayerFastRateHint(art, false);
onFastChange(false);
}
function handleVolumeChange() {
writePlayerSettings({
volume: clamp(video.volume, 0, 1),
muted: video.muted,
});
}
function handleRateChange() {
if (fastActiveRef.current) return;
if (!Number.isFinite(video.playbackRate)) return;
writePlayerSettings({
playbackRate: clamp(video.playbackRate, 0.5, 3),
});
}
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 = enableOrientationControl
? bindOrientationToggle(art)
: noop;
mount.addEventListener("contextmenu", preventContextMenu);
video.addEventListener("volumechange", handleVolumeChange);
video.addEventListener("ratechange", handleRateChange);
art.on("video:loadstart", handleLoadStart);
art.on("video:loadeddata", handleReady);
art.on("video:canplay", handleReady);
art.on("video:playing", handleReady);
art.on("video:error", handleVideoError);
art.on("error", handleVideoError);
art.on("video:play", handlePlay);
art.on("video:pause", resetFastRate);
art.on("video:ended", resetFastRate);
return () => {
unbindFastRate();
unbindMobileGestures();
unbindProgressPreview();
unbindOrientationToggle();
setPlayerFastRateHint(art, false);
mount.removeEventListener("contextmenu", preventContextMenu);
video.removeEventListener("volumechange", handleVolumeChange);
video.removeEventListener("ratechange", handleRateChange);
destroyHls(video);
art.off("video:loadstart", handleLoadStart);
art.off("video:loadeddata", handleReady);
art.off("video:canplay", handleReady);
art.off("video:playing", handleReady);
art.off("video:error", handleVideoError);
art.off("error", handleVideoError);
art.off("video:play", handlePlay);
art.off("video:pause", resetFastRate);
art.off("video:ended", resetFastRate);
art.destroy(true);
if (artRef.current === art) {
artRef.current = null;
}
onPreviewHover(null);
};
}
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,
position: "right",
index: 55,
tooltip: "横竖屏切换",
html: `
<span class="video-player__orientation-control-icon video-player__orientation-control-icon--to-landscape" aria-hidden="true">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<path d="M14.4 11.2h2.7c1.7 0 3 1.3 3 3v4.1c0 1.7-1.3 3-3 3h-3.8" fill="none" stroke="currentColor" stroke-opacity=".42" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round"/>
<rect x="3.1" y="6.7" width="9.7" height="14.1" rx="2.4" fill="none" stroke="currentColor" stroke-width="2.3"/>
<path d="M11.8 2.8h2.9c2.6 0 4.7 1.8 5 4.2" fill="none" stroke="currentColor" stroke-width="2.3" stroke-linecap="round"/>
<path d="M17.4 4.6 19.8 7 22 4.5" fill="none" stroke="currentColor" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</span>
<span class="video-player__orientation-control-icon video-player__orientation-control-icon--to-portrait" aria-hidden="true">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<g transform="rotate(180 12 12)">
<path d="M12.8 14.4v2.7c0 1.7-1.3 3-3 3H5.7c-1.7 0-3-1.3-3-3v-3.8" fill="none" stroke="currentColor" stroke-opacity=".42" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round"/>
<rect x="3.2" y="3.1" width="14.1" height="9.7" rx="2.4" fill="none" stroke="currentColor" stroke-width="2.3"/>
<path d="M21.2 11.8v2.9c0 2.6-1.8 4.7-4.2 5" fill="none" stroke="currentColor" stroke-width="2.3" stroke-linecap="round"/>
<path d="M19.4 17.4 17 19.8 19.5 22" fill="none" stroke="currentColor" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>
</span>
`,
mounted(element) {
element.setAttribute("role", "button");
element.setAttribute("tabindex", "0");
updateOrientationControl(this, element);
this.events.proxy(element, "keydown", (event) => {
const keyEvent = event as KeyboardEvent;
if (keyEvent.key !== "Enter" && keyEvent.key !== " ") return;
keyEvent.preventDefault();
void togglePlayerOrientation(this);
});
},
click() {
void togglePlayerOrientation(this);
},
};
}
function bindOrientationToggle(art: Artplayer) {
function handleResize() {
updateManualWebOrientation(art);
updateOrientationControl(art);
}
function handleFullscreenWeb(state: boolean) {
if (!state && getManualOrientationKind(art) === "web") {
clearManualOrientation(art);
return;
}
handleResize();
}
function handleFullscreen(state: boolean) {
if (!state && getManualOrientationKind(art) === "native") {
clearManualOrientation(art);
return;
}
updateOrientationControl(art);
}
window.addEventListener("resize", handleResize);
window.addEventListener("orientationchange", handleResize);
getScreenOrientation()?.addEventListener?.("change", handleResize);
art.on("fullscreenWeb", handleFullscreenWeb);
art.on("fullscreen", handleFullscreen);
updateOrientationControl(art);
return () => {
clearManualOrientation(art);
window.removeEventListener("resize", handleResize);
window.removeEventListener("orientationchange", handleResize);
getScreenOrientation()?.removeEventListener?.("change", handleResize);
art.off("fullscreenWeb", handleFullscreenWeb);
art.off("fullscreen", handleFullscreen);
};
}
async function togglePlayerOrientation(art: Artplayer) {
const target = nextOrientationTarget(art);
const locked = await lockNativeOrientation(art, target);
if (locked) {
clearManualWebRotation(art);
setManualOrientation(art, target, "native");
art.notice.show = `已切换${orientationLabel(target)}`;
updateOrientationControl(art);
return;
}
await exitNativeFullscreen();
if (!art.fullscreenWeb) {
art.fullscreenWeb = true;
}
setManualOrientation(art, target, "web");
updateManualWebOrientation(art);
art.notice.show = `已切换${orientationLabel(target)}`;
updateOrientationControl(art);
}
async function lockNativeOrientation(
art: Artplayer,
target: OrientationMode
) {
const orientation = getScreenOrientation();
if (!orientation?.lock) return false;
try {
const fullscreen = await requestNativeFullscreen(art.template.$player);
if (!fullscreen) return false;
await orientation.lock(target);
return true;
} catch {
return false;
}
}
async function requestNativeFullscreen(element: HTMLElement) {
if (getNativeFullscreenElement()) return true;
const target = element as FullscreenElement;
try {
if (target.requestFullscreen) {
await target.requestFullscreen({ navigationUI: "hide" });
return true;
}
const request =
target.webkitRequestFullscreen ||
target.mozRequestFullScreen ||
target.msRequestFullscreen;
if (!request) return false;
await maybePromise(request.call(target));
return true;
} catch {
return false;
}
}
async function exitNativeFullscreen() {
if (!getNativeFullscreenElement()) return;
const doc = document as FullscreenDocument;
const exit =
doc.exitFullscreen ||
doc.webkitExitFullscreen ||
doc.mozCancelFullScreen ||
doc.msExitFullscreen;
if (!exit) return;
try {
await maybePromise(exit.call(document));
} catch {
// ignore
}
}
function getNativeFullscreenElement() {
const doc = document as FullscreenDocument;
return (
document.fullscreenElement ||
doc.webkitFullscreenElement ||
doc.mozFullScreenElement ||
doc.msFullscreenElement ||
null
);
}
function getScreenOrientation() {
return window.screen?.orientation as LockableScreenOrientation | undefined;
}
async function maybePromise(value: Promise<void> | void) {
if (value && typeof value.then === "function") {
await value;
}
}
function nextOrientationTarget(art: Artplayer): OrientationMode {
const active = getManualOrientationTarget(art) ?? getViewportOrientation();
return active === "landscape" ? "portrait" : "landscape";
}
function getViewportOrientation(): OrientationMode {
const type = getScreenOrientation()?.type;
if (type?.startsWith("landscape")) return "landscape";
if (type?.startsWith("portrait")) return "portrait";
return window.innerWidth > window.innerHeight ? "landscape" : "portrait";
}
function setManualOrientation(
art: Artplayer,
target: OrientationMode,
kind: OrientationKind
) {
const { dataset } = art.template.$player;
dataset.videoPlayerOrientationTarget = target;
dataset.videoPlayerOrientationKind = kind;
}
function getManualOrientationTarget(art: Artplayer) {
const value = art.template.$player.dataset.videoPlayerOrientationTarget;
return value === "landscape" || value === "portrait" ? value : null;
}
function getManualOrientationKind(art: Artplayer) {
const value = art.template.$player.dataset.videoPlayerOrientationKind;
return value === "native" || value === "web" ? value : null;
}
function clearManualOrientation(art: Artplayer) {
const kind = getManualOrientationKind(art);
delete art.template.$player.dataset.videoPlayerOrientationTarget;
delete art.template.$player.dataset.videoPlayerOrientationKind;
clearManualWebRotation(art);
if (kind === "native") {
try {
getScreenOrientation()?.unlock?.();
} catch {
// ignore
}
}
updateOrientationControl(art);
}
function updateManualWebOrientation(art: Artplayer) {
if (getManualOrientationKind(art) !== "web") return;
const target = getManualOrientationTarget(art);
if (!target) return;
if (!art.fullscreenWeb) {
clearManualOrientation(art);
return;
}
if (target !== getViewportOrientation()) {
applyManualWebRotation(art);
} else {
clearManualWebRotation(art);
}
}
function applyManualWebRotation(art: Artplayer) {
const player = art.template.$player;
const viewWidth = document.documentElement.clientWidth;
const viewHeight = document.documentElement.clientHeight;
player.style.width = `${viewHeight}px`;
player.style.height = `${viewWidth}px`;
player.style.transformOrigin = "0 0";
player.style.transform = `rotate(90deg) translate(0, -${viewWidth}px)`;
player.classList.add(MANUAL_ORIENTATION_CLASS);
art.emit("resize");
}
function clearManualWebRotation(art: Artplayer) {
const player = art.template.$player;
player.classList.remove(MANUAL_ORIENTATION_CLASS);
player.style.transform = "";
player.style.transformOrigin = "";
if (art.fullscreenWeb) {
player.style.width = "100%";
player.style.height = "100%";
} else {
player.style.width = "";
player.style.height = "";
}
art.emit("resize");
}
function updateOrientationControl(art: Artplayer, mountedElement?: HTMLElement) {
const controls = (art as Artplayer & {
controls?: Record<string, HTMLElement | undefined>;
}).controls;
const element = mountedElement ?? controls?.[ORIENTATION_CONTROL_NAME];
if (!element) return;
const next = nextOrientationTarget(art);
const label = `切换${orientationLabel(next)}`;
element.dataset.nextOrientation = next;
element.setAttribute("aria-label", label);
element.setAttribute("title", label);
}
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
) {
return function loadHlsSource(
video: HTMLVideoElement,
url: string,
art: Artplayer
) {
const target = video as VideoElementWithHls;
destroyHls(target);
onError(null);
void import("hls.js")
.then((hlsModule) => {
if (art.isDestroy || !video.isConnected) return;
loadHlsSourceWith(video, url, art, hlsModule.default, onError);
})
.catch(() => {
if (art.isDestroy) return;
onError({
title: "HLS 内核加载失败",
message: "播放器组件加载失败,请刷新页面后重试。",
});
});
};
}
function loadHlsSourceWith(
video: HTMLVideoElement,
url: string,
art: Artplayer,
HlsCtor: typeof Hls,
onError: (error: PlayerError | null) => void
) {
const target = video as VideoElementWithHls;
destroyHls(target);
if (HlsCtor.isSupported()) {
const hls = new HlsCtor({
enableWorker: true,
lowLatencyMode: true,
backBufferLength: 90,
});
target.__hls = hls;
art.hls = hls;
hls.loadSource(url);
hls.attachMedia(video);
hls.on(HlsCtor.Events.ERROR, (_event, data) => {
if (!data.fatal) return;
if (data.type === HlsCtor.ErrorTypes.NETWORK_ERROR) {
art.notice.show = "网络错误,正在重试";
hls.startLoad();
return;
}
if (data.type === HlsCtor.ErrorTypes.MEDIA_ERROR) {
art.notice.show = "媒体错误,正在恢复";
hls.recoverMediaError();
return;
}
destroyHls(target);
onError({
title: "HLS 播放失败",
message: "当前视频流无法解析,请稍后重试或复制播放地址排查。",
});
});
return;
}
if (
video.canPlayType("application/vnd.apple.mpegurl") ||
video.canPlayType("application/x-mpegURL")
) {
video.src = url;
return;
}
onError({
title: "当前浏览器不支持 HLS",
message: "请换用新版 Chrome、Edge 或 Safari 后再试。",
});
}
function destroyHls(video: VideoElementWithHls) {
if (!video.__hls) return;
video.__hls.destroy();
video.__hls = null;
}
function bindLongPressFast(
video: HTMLVideoElement,
onFastChange: (active: boolean) => void
) {
let pressTimer: number | null = null;
let fastActive = false;
let previousRate = NORMAL_RATE;
let suppressNextClick = false;
function clearPressTimer() {
if (pressTimer !== null) {
window.clearTimeout(pressTimer);
pressTimer = null;
}
}
function setFast(next: boolean) {
if (fastActive === next) return;
if (next) {
previousRate =
Number.isFinite(video.playbackRate) && video.playbackRate > 0
? video.playbackRate
: NORMAL_RATE;
}
fastActive = next;
video.playbackRate = next ? FAST_RATE : previousRate;
onFastChange(next);
}
function activateFast() {
if (video.paused || video.ended) return;
setFast(true);
}
function startPress() {
if (video.paused || video.ended) return;
clearPressTimer();
pressTimer = window.setTimeout(() => {
pressTimer = null;
activateFast();
}, LONG_PRESS_MS);
}
function endPress(suppressClick = false) {
clearPressTimer();
const wasFastActive = fastActive;
setFast(false);
if (wasFastActive && suppressClick) {
suppressNextClick = true;
}
}
function handleMouseDown(event: MouseEvent) {
if (event.button !== 0) return;
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", 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", 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);
};
}
function bindProgressPreview(
art: Artplayer,
video: HTMLVideoElement,
mount: HTMLDivElement,
onPreviewHover: (hover: PreviewHover | null) => void
) {
const progress = art.query<HTMLElement>(".art-progress");
if (!progress) return () => undefined;
const progressEl = progress;
function update(event: PointerEvent | MouseEvent) {
if ("pointerType" in event && event.pointerType === "touch") return;
const duration = video.duration;
if (!Number.isFinite(duration) || duration <= 0) return;
const rect = progressEl.getBoundingClientRect();
const hostRect = mount.getBoundingClientRect();
const ratio = clamp((event.clientX - rect.left) / Math.max(1, rect.width), 0, 1);
const edge = Math.min(PREVIEW_WIDTH / 2 + 8, hostRect.width / 2);
const maxX = Math.max(edge, hostRect.width - edge);
onPreviewHover({
x: clamp(event.clientX - hostRect.left, edge, maxX),
ratio,
time: ratio * duration,
});
}
function hide() {
onPreviewHover(null);
}
progressEl.addEventListener("pointermove", update);
progressEl.addEventListener("pointerdown", update);
progressEl.addEventListener("pointerleave", hide);
window.addEventListener("pointerup", hide);
window.addEventListener("blur", hide);
return () => {
progressEl.removeEventListener("pointermove", update);
progressEl.removeEventListener("pointerdown", update);
progressEl.removeEventListener("pointerleave", hide);
window.removeEventListener("pointerup", hide);
window.removeEventListener("blur", hide);
};
}
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
),
};
}
function writePlayerSettings(patch: Partial<PlayerSettings>) {
safeSetJSON(SETTINGS_KEY, { ...readPlayerSettings(), ...patch });
}
function mediaErrorMessage(error: MediaError | null) {
switch (error?.code) {
case MediaError.MEDIA_ERR_ABORTED:
return "视频加载已取消,请重试。";
case MediaError.MEDIA_ERR_NETWORK:
return "视频源网络连接失败,请稍后重试。";
case MediaError.MEDIA_ERR_DECODE:
return "视频编码无法解码,可能需要转码或换用浏览器。";
case MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED:
return "视频源暂不可用或格式不受当前浏览器支持。";
default:
return "视频源暂时无法播放,请重试或复制地址排查。";
}
}
function syncPreviewVideo(video: HTMLVideoElement | null, ratio: number) {
if (!video || !Number.isFinite(video.duration) || video.duration <= 0) return;
const target = clamp(ratio * video.duration, 0, Math.max(0, video.duration - 0.05));
if (Math.abs(video.currentTime - target) > 0.25) {
try {
video.currentTime = target;
} catch {
// ignore
}
}
}
function fallbackCopyText(text: string) {
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.setAttribute("readonly", "true");
textarea.style.position = "fixed";
textarea.style.left = "-9999px";
document.body.appendChild(textarea);
textarea.select();
try {
document.execCommand("copy");
} catch {
// ignore
} finally {
textarea.remove();
}
}
function safeGetJSON<T>(key: string): T | null {
try {
const raw = localStorage.getItem(key);
return raw ? (JSON.parse(raw) as T) : null;
} catch {
return null;
}
}
function safeSetJSON(key: string, value: unknown) {
try {
localStorage.setItem(key, JSON.stringify(value));
} catch {
// ignore
}
}
function clampNumber(
value: unknown,
fallback: number,
min: number,
max: number
) {
return typeof value === "number" && Number.isFinite(value)
? clamp(value, min, max)
: fallback;
}
function clamp(n: number, min: number, max: number) {
return n < min ? min : n > max ? max : n;
}
function formatClock(seconds: number) {
if (!Number.isFinite(seconds) || seconds < 0) return "00:00";
const total = Math.floor(seconds);
const h = Math.floor(total / 3600);
const m = Math.floor((total % 3600) / 60);
const s = total % 60;
if (h > 0) {
return `${String(h).padStart(2, "0")}:${String(m).padStart(
2,
"0"
)}:${String(s).padStart(2, "0")}`;
}
return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
}
function formatPercent(value: number) {
return `${Math.round(clamp(value, 0, 1) * 100)}%`;
}