You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
AdLike2048/assets/script/sdk/functions/RewardedVideoAd.ts

387 lines
14 KiB

3 weeks ago
import { MiniGamePf, type MiniGamePfId } from '../../../mx/tools/MiniGameHostInfo';
import { sdkConfig } from '../sdkConfig';
/** 小游戏平台标识(与 window 全局对象一一对应) */
type MiniGamePlatform = MiniGamePfId;
interface PlatformAdProfile {
/** 创建实例后是否需主动 load(抖音/部分快游戏不会自动拉取) */
manualLoadOnCreate: boolean;
/** 关闭后是否主动 load 预载下一条(抖音官方建议下次直接 show) */
preloadAfterClose: boolean;
/** 全局单例,禁止频繁 destroy 重建 */
singleton: boolean;
/** 创建广告位 id 字段名(OPPO 旧版为 posId) */
idField: 'adUnitId' | 'posId';
/** 点击展示前埋点 sceneId(B 站要求) */
reportSceneId?: number;
/** 创建接口名。支付宝是 createRewardedAd,其余是 createRewardedVideoAd */
createApi?: 'createRewardedVideoAd' | 'createRewardedAd';
/** 必须 load 成功后再 show(支付宝未加载 show 返回 61101) */
showAfterLoad?: boolean;
/** 创建时宿主已自动拉取,等 onLoad,不要立刻再 load(vivo 激励视频) */
waitAutoLoad?: boolean;
}
const PLATFORM_PROFILE: Record<MiniGamePlatform, PlatformAdProfile> = {
// 微信/QQ:创建后自动拉取,关闭后自动更新;show 失败 → load → show
wx: { manualLoadOnCreate: false, preloadAfterClose: true, singleton: true, idField: 'adUnitId' },
qq: { manualLoadOnCreate: false, preloadAfterClose: true, singleton: true, idField: 'adUnitId' },
// 抖音:须先 onLoad 再 load;关闭后可直接 show,不必每次 load
tt: { manualLoadOnCreate: true, preloadAfterClose: false, singleton: true, idField: 'adUnitId' },
// 快手:全局单例,无 load/onLoad,关闭后直接 show;create 可能返回 null
ks: { manualLoadOnCreate: false, preloadAfterClose: false, singleton: true, idField: 'adUnitId' },
// B 站:须 load → show;点击前 reportScene(1007)
bl: { manualLoadOnCreate: true, preloadAfterClose: true, singleton: true, idField: 'adUnitId', reportSceneId: 1007 },
// 快游戏(OPPO/vivo/华为等):多数需手动 load;OPPO 旧 API 用 posId
qg: { manualLoadOnCreate: true, preloadAfterClose: true, singleton: true, idField: 'posId' },
// 支付宝:createRewardedAd;先 load 再 show;关闭后再 load。重复创建返回 61003,不 destroy
my: {
manualLoadOnCreate: true,
preloadAfterClose: true,
singleton: true,
idField: 'adUnitId',
createApi: 'createRewardedAd',
showAfterLoad: true,
},
};
const MAX_ERROR_RETRY = 5;
/** 激励视频广告(全平台统一封装) */
export class RewardedVideoAd {
static keys: string[] = [];
private static _platform: MiniGamePlatform | null = null;
private static _pt: Record<string, unknown> | null = null;
private static _ad: RewardedVideoAdInstance | null = null;
private static _keyIndex = 0;
private static _currentUnitId = '';
private static _callback: ((ok: boolean) => void) | null = null;
private static _errCount = 0;
private static _waitShow = false;
private static _showing = false;
private static _loaded = false;
/** vivo 首次 create 已自动拉取,为 false 时禁止再 load */
private static _allowVivoLoad = false;
/** vivo 失败后延迟 load 的定时器 */
private static _vivoTimer = 0;
private static _onLoadBound = () => RewardedVideoAd._onLoad();
private static _onErrorBound = (err: unknown) => RewardedVideoAd._onError(err);
private static _onCloseBound = (res: RewardedVideoCloseResult | undefined) => RewardedVideoAd._onClose(res);
static init(keys: string[]): void {
this.keys = keys?.filter(Boolean) ?? [];
this._platform = this._detectPlatform();
this._pt = this._resolvePlatformObject();
if (!this._pt || this.keys.length === 0) return;
this.ensureAdInstance();
this.preload();
}
/** 应用回到前台:静默预载,避免后台久置后 show 失败 */
static onAppShow(): void {
if (this._showing || !this._pt || this.keys.length === 0) return;
this.ensureAdInstance();
this.preload();
}
/** 静默预载(不触发业务回调) */
static preload(): void {
if (!this._pt || this.keys.length === 0) return;
if (this._profile()?.waitAutoLoad && (this._loaded || !this._allowVivoLoad)) return;
this.ensureAdInstance();
if (!this._ad?.load) return;
try {
const ret = this._ad.load();
if (ret && typeof (ret as Promise<unknown>).catch === 'function') {
(ret as Promise<unknown>).catch(() => { /* 预载失败等 onError */ });
}
} catch (e) {
console.log('[RewardedVideoAd] preload failed', String(e));
}
}
static show(callback?: (ok: boolean) => void): void {
if (this._showing) {
callback?.(false);
return;
}
this._callback = callback ?? null;
this._errCount = 0;
if (!this._pt) {
this._finish(true);
return;
}
if (this.keys.length === 0) {
this._finish(false);
return;
}
this.ensureAdInstance();
if (!this._ad) {
this._finish(false);
return;
}
this._reportSceneIfNeeded();
this._showing = true;
this._tryShow();
}
private static _detectPlatform(): MiniGamePlatform | null {
return MiniGamePf.id();
}
private static _resolvePlatformObject(): Record<string, unknown> | null {
const p = this._platform;
if (!p) return null;
return (window[p] as Record<string, unknown>) ?? null;
}
private static _profile(): PlatformAdProfile | null {
if (this._platform === 'qg' && sdkConfig.config.chanId === 'vivo') {
return {
manualLoadOnCreate: false,
preloadAfterClose: true,
singleton: true,
idField: 'posId',
showAfterLoad: true,
waitAutoLoad: true,
};
}
return this._platform ? PLATFORM_PROFILE[this._platform] : null;
}
private static ensureAdInstance(): void {
const pt = this._pt;
const unitId = this.keys[this._keyIndex];
if (!pt || !unitId) return;
const createName = this._profile()?.createApi ?? 'createRewardedVideoAd';
const createFn = pt[createName] as ((opts: Record<string, string>) => RewardedVideoAdInstance) | undefined;
if (!createFn) {
console.error('[RewardedVideoAd] ' + createName + ' not supported');
return;
}
if (this._ad && this._currentUnitId === unitId) return;
this._destroyAd();
this._currentUnitId = unitId;
this._loaded = false;
this._waitShow = false;
const param = this._buildCreateParam(unitId);
try {
this._ad = createFn(param) ?? null;
} catch (e) {
console.error('[RewardedVideoAd] create failed', unitId, String(e));
this._ad = null;
return;
}
if (!this._ad) {
console.error('[RewardedVideoAd] create returned null', unitId);
return;
}
this._ad.onLoad?.(this._onLoadBound);
this._ad.onError?.(this._onErrorBound);
this._ad.onClose?.(this._onCloseBound);
if (this._profile()?.waitAutoLoad) this._allowVivoLoad = false;
if (this._profile()?.manualLoadOnCreate) {
this.preload();
}
}
private static _buildCreateParam(unitId: string): Record<string, string> {
const profile = this._profile();
if (!profile) return { adUnitId: unitId };
if (profile.idField === 'posId') {
// 快游戏:OPPO 旧版 posId;华为/vivo 新版 adUnitId — 同时传入兼容
return { posId: unitId, adUnitId: unitId };
}
return { adUnitId: unitId };
}
private static _destroyAd(): void {
const ad = this._ad;
if (!ad) return;
ad.offLoad?.(this._onLoadBound);
ad.offError?.(this._onErrorBound);
ad.offClose?.(this._onCloseBound);
// 快手全局单例、支付宝 61003:destroy 后再 create 会打坏后续展示
if (ad.destroy && this._platform !== 'ks' && this._platform !== 'my') {
try { ad.destroy(); } catch { /* ignore */ }
}
this._ad = null;
this._loaded = false;
}
private static _switchToNextUnit(): boolean {
const oldKey = this.keys[this._keyIndex];
this._keyIndex = (this._keyIndex + 1) % this.keys.length;
return oldKey !== this.keys[this._keyIndex];
}
private static _tryShow(): void {
const ad = this._ad;
if (!ad?.show) {
this._showing = false;
this._finish(false);
return;
}
if (this._profile()?.showAfterLoad && !this._loaded) {
this._waitShow = true;
if (!this._profile()?.waitAutoLoad) this.preload();
return;
}
const showPromise = ad.show();
if (!showPromise || typeof showPromise.catch !== 'function') {
return;
}
showPromise.catch((err: unknown) => {
console.log('[RewardedVideoAd] show rejected, reload', String(err));
this._waitShow = true;
this._loaded = false;
if (this._profile()?.waitAutoLoad) {
this._allowVivoLoad = true;
this._scheduleVivoReload();
return;
}
this.preload();
});
}
/** vivo 出错后隔 1 秒再 load,避开 30002 */
private static _scheduleVivoReload(): void {
if (this._vivoTimer) window.clearTimeout(this._vivoTimer);
this._vivoTimer = window.setTimeout(this._vivoReload, 1000);
}
/** vivo 延迟 load */
private static _vivoReload = (): void => {
RewardedVideoAd._vivoTimer = 0;
RewardedVideoAd.preload();
};
private static _onLoad(): void {
this._loaded = true;
this._allowVivoLoad = true;
if (!this._waitShow || !this._showing) return;
this._waitShow = false;
this._tryShow();
}
private static _onError(err: unknown): void {
const info = normalizeAdError(err);
console.log('[RewardedVideoAd] onError', info.code, info.msg);
this._loaded = false;
this._errCount++;
if (this._errCount >= MAX_ERROR_RETRY) {
this._waitShow = false;
this._showing = false;
this._finish(false);
return;
}
if (this._profile()?.waitAutoLoad) {
const switched = this._switchToNextUnit();
if (switched) this.ensureAdInstance();
else {
this._allowVivoLoad = true;
this._scheduleVivoReload();
}
return;
}
if (this._switchToNextUnit()) {
this.ensureAdInstance();
if (this._waitShow) this.preload();
else this.preload();
return;
}
this.preload();
}
private static _onClose(res: RewardedVideoCloseResult | undefined): void {
this._waitShow = false;
this._showing = false;
this._loaded = false;
this._allowVivoLoad = true;
const granted = isRewardGranted(res);
this._finish(granted);
if (this._profile()?.preloadAfterClose) {
this.preload();
}
}
private static _reportSceneIfNeeded(): void {
const sceneId = this._profile()?.reportSceneId;
if (!sceneId || !this._platform) return;
const pf = window[this._platform] as { reportScene?: (opts: { sceneId: number }) => void } | undefined;
try {
pf?.reportScene?.({ sceneId });
} catch (e) {
console.log('[RewardedVideoAd] reportScene failed', String(e));
}
}
private static _finish(ok: boolean): void {
const cb = this._callback;
this._callback = null;
cb?.(ok);
}
}
/** 是否完整观看(兼容各平台基础库差异) */
function isRewardGranted(res: RewardedVideoCloseResult | undefined): boolean {
if (res === undefined || res === null) {
// 微信/QQ/快手:基础库 < 2.1.0 无 isEnded,官方示例视为可发奖
return true;
}
if (typeof res.isEnded === 'boolean') {
return res.isEnded;
}
// 微信 3.7.7+ 兜底分享页关闭时仍带 isEnded
return false;
}
function normalizeAdError(err: unknown): { code: number | string; msg: string } {
if (err && typeof err === 'object') {
const o = err as { errCode?: number; errMsg?: string; code?: number; message?: string; error?: number; errorMessage?: string };
return {
code: o.errCode ?? o.error ?? o.code ?? 'unknown',
msg: o.errMsg ?? o.errorMessage ?? o.message ?? JSON.stringify(err),
};
}
return { code: 'unknown', msg: String(err ?? '') };
}
interface RewardedVideoCloseResult {
isEnded?: boolean;
useFallbackSharePage?: boolean;
}
interface RewardedVideoAdInstance {
show(): Promise<void>;
load?(): Promise<void> | void;
destroy?(): void;
onLoad?(cb: () => void): void;
offLoad?(cb: () => void): void;
onError?(cb: (err: unknown) => void): void;
offError?(cb: (err: unknown) => void): void;
onClose?(cb: (res: RewardedVideoCloseResult | undefined) => void): void;
offClose?(cb: (res: RewardedVideoCloseResult | undefined) => void): void;
}