@@ -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}`} />;
|
||||
}
|
||||
Reference in New Issue
Block a user