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 = { // 微信/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 | 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).catch === 'function') { (ret as Promise).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 | null { const p = this._platform; if (!p) return null; return (window[p] as Record) ?? 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) => 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 { 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; load?(): Promise | 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; }