Initial commit

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
谢宇宁
2026-10-26 17:51:31 +07:00
co-authored by Claude Opus 5.5
commit 61d0a11dfa
371 changed files with 35836 additions and 0 deletions
+266
View File
@@ -0,0 +1,266 @@
"use client";
import { useEffect, useRef, type RefObject } from "react";
import type Player from "xgplayer";
import type { IPlayerOptions } from "xgplayer";
import "xgplayer/dist/index.min.css";
import { bindPlayerAnalytics } from "@/components/analytics/use-playback-analytics";
import { useDecryptedSrc } from "@/hooks/use-decrypted-src";
import { IMG_PLACEHOLDER } from "@/lib/image/decrypt";
import { isHLSURL, nativeHLS, SHELL_BROWSER } from "@/lib/media/playback";
export type XgPlayerInstance = Player;
export const playbackRates = [2, 1.5, 1.25, 1, 0.75];
/** 祖先 CSS zoom 乘积 */
function getAncestorCssZoom(el: HTMLElement | null): number {
let zoom = 1;
let node: HTMLElement | null = el;
while (node && node !== document.documentElement) {
const raw = getComputedStyle(node).zoom;
if (raw && raw !== "normal") {
const n = Number.parseFloat(String(raw));
if (Number.isFinite(n) && n > 0) zoom *= n;
}
node = node.parentElement;
}
return zoom;
}
/**
* xgplayer config.zoom:仅在 getBoundingClientRect 未计入 CSS zoom 时需要校正
*(旧 Chromium / 部分 X5 WebView);Chrome 128+ 已计入则保持 1,避免点进度条偏移。
*/
function resolvePlayerZoom(host: HTMLElement): number {
const cssZoom = getAncestorCssZoom(host);
if (Math.abs(cssZoom - 1) < 0.001) return 1;
const parent = host.parentElement ?? host;
const probe = document.createElement("div");
probe.style.cssText =
"position:absolute;left:0;top:0;width:100px;height:1px;visibility:hidden;pointer-events:none;margin:0;padding:0;border:0";
parent.appendChild(probe);
const layout = probe.offsetWidth;
const visual = probe.getBoundingClientRect().width;
parent.removeChild(probe);
if (layout <= 0) return 1;
// GBR 已含 zoom → 不需要 xgplayer 再除一次
if (Math.abs(visual / layout - cssZoom) < 0.05) return 1;
return cssZoom;
}
type XgPlayerProps = {
src: string;
poster?: string;
className?: string;
options?: IPlayerOptions;
playerRef?: RefObject<Player | null>;
onReady?: (player: Player) => void;
/** 当前 src 首次真正进入 playing(播放成功)时回调一次 */
onPlaySuccess?: () => void;
/** 当前集播放结束 */
onEnded?: () => void;
/** 播放埋点类型;不传则不上报播放行为 */
analyticsKind?: "drama" | "video";
/**
* 进入页不自动播,需点播放按钮。
* 默认 true;短剧连播下一集时可显式传 false。
*/
clickToPlay?: boolean;
};
export function XgPlayer({
src,
poster,
className = "",
options,
playerRef,
onReady,
onPlaySuccess,
onEnded,
analyticsKind,
clickToPlay = true,
}: XgPlayerProps) {
const hostRef = useRef<HTMLDivElement>(null);
const optionsRef = useRef(options);
const onReadyRef = useRef(onReady);
const onPlaySuccessRef = useRef(onPlaySuccess);
const onEndedRef = useRef(onEnded);
/** 接口封面需解密后才能作为播放器 poster */
const posterSrc = useDecryptedSrc(poster, { priority: true, fallback: IMG_PLACEHOLDER });
useEffect(() => {
optionsRef.current = options;
onReadyRef.current = onReady;
onPlaySuccessRef.current = onPlaySuccess;
onEndedRef.current = onEnded;
});
useEffect(() => {
const host = hostRef.current;
if (!host || !src) return;
let player: Player | null = null;
let cancelled = false;
let playSucceeded = false;
let unbindAnalytics: (() => void) | undefined;
/** 用户点过播放后,不再拦截正常起播 */
let userStarted = !clickToPlay;
const syncZoom = () => {
if (!player) return;
player.zoom = resolvePlayerZoom(host);
};
(async () => {
const [{ default: PresetPlayer }, { default: HlsPlugin }] = await Promise.all([
import("xgplayer"),
import("xgplayer-hls"),
]);
if (cancelled) return;
const probe = document.createElement("video");
const useHlsPlugin = isHLSURL(src) && !nativeHLS(probe) && HlsPlugin.isSupported();
player = new PresetPlayer({
el: host,
url: src,
poster: {
poster: posterSrc,
fillMode: "cover",
isEndedShow: true,
hideCanplay: false,
},
width: "100%",
height: "100%",
lang: "zh-cn",
playsinline: true,
playbackRate: playbackRates,
commonStyle: {
playedColor: "#F12B9E",
volumeColor: "#F12B9E",
sliderBtnStyle: { background: "#fff" },
},
plugins: useHlsPlugin ? [HlsPlugin] : [],
hls: {
retryCount: 3,
retryDelay: 1000,
loadTimeout: 15000,
fetchOptions: { mode: "cors" as RequestMode, credentials: "same-origin" as RequestCredentials },
},
// QQ(X5) / 夸克 / UC:强制页内播放,避免劫持到系统全屏播放器
videoAttributes: {
playsinline: "true",
"webkit-playsinline": "true",
"x5-playsinline": "true",
"x5-video-player-type": "h5-page",
"x5-video-player-fullscreen": "true",
"x5-video-orientation": "portraint",
"x-webkit-airplay": "allow",
preload: clickToPlay ? "none" : "metadata",
controlsList: "nodownload nofullscreen noremoteplayback",
disablePictureInPicture: "true",
},
closeVideoClick: false,
closeVideoDblclick: true,
...optionsRef.current,
// clickToPlay:进页不自动播;连播下一集时关闭 clickToPlay 并 autoplay
autoplay: !clickToPlay,
autoplayMuted: false,
// seek 后保持拖动前状态(播则继续播);进页防自动起播仍靠 forcePauseUntilUserClick
seekedStatus: optionsRef.current?.seekedStatus ?? "auto",
// PC design-frame / [zoom:var(--page-scale)]:仅旧内核需要校正点击坐标
zoom: optionsRef.current?.zoom ?? resolvePlayerZoom(host),
icons: {
startPlay:
'<img src="/images/play.svg?v=2" width="72" height="72" alt="" draggable="false" />',
...optionsRef.current?.icons,
},
fullscreen: {
useCssFullscreen: () => {
const doc = document as Document & { webkitFullscreenEnabled?: boolean };
const nativeFullscreen = Boolean(document.fullscreenEnabled || doc.webkitFullscreenEnabled);
return SHELL_BROWSER.test(navigator.userAgent) || !nativeFullscreen;
},
...((optionsRef.current?.fullscreen as object | undefined) ?? {}),
},
});
const forcePauseUntilUserClick = () => {
if (cancelled || userStarted || !player) return;
if (!player.paused) player.pause();
};
if (clickToPlay) {
// 仅认用户手势(中心播放钮 / 画面点击),不要用 play 事件(自动起播也会触发)
const markUserStarted = () => {
userStarted = true;
};
player.on("user_action", (data: { action?: string; eventType?: string }) => {
const action = data?.action || "";
const eventType = data?.eventType || "";
if (
action === "switch_play_pause" ||
eventType === "click" ||
eventType === "touchend" ||
eventType === "touchstart"
) {
markUserStarted();
}
});
host.addEventListener("pointerdown", markUserStarted, { once: true, capture: true });
// 拦截浏览器/播放器自动起播
player.on("autoplay_started", forcePauseUntilUserClick);
player.on("canplay", forcePauseUntilUserClick);
player.on("loadeddata", forcePauseUntilUserClick);
}
player.on("playing", () => {
if (playSucceeded || cancelled) return;
if (clickToPlay && !userStarted) {
player?.pause();
return;
}
playSucceeded = true;
onPlaySuccessRef.current?.();
});
player.on("ended", () => {
if (cancelled) return;
onEndedRef.current?.();
});
window.addEventListener("resize", syncZoom);
if (analyticsKind) {
unbindAnalytics = bindPlayerAnalytics(
player as { media?: HTMLVideoElement | null },
analyticsKind,
);
}
if (playerRef) playerRef.current = player;
onReadyRef.current?.(player);
if (clickToPlay) {
forcePauseUntilUserClick();
} else {
// 连播:就绪后主动 play(部分浏览器需再补一次)
const tryPlay = () => {
if (cancelled || !player) return;
void player.play()?.catch(() => {
/* 自动播放被拒时保留封面,用户可手动点播 */
});
};
player.once("canplay", tryPlay);
tryPlay();
}
})();
return () => {
cancelled = true;
unbindAnalytics?.();
window.removeEventListener("resize", syncZoom);
if (playerRef) playerRef.current = null;
player?.destroy();
};
}, [src, posterSrc, playerRef, analyticsKind, clickToPlay]);
return <div data-component="xg-player" ref={hostRef} className={`xg-theme isolate ${className}`} />;
}