/** * Page-lifecycle unload transport. A thin wrapper over navigator.sendBeacon for the single edge case where * an awaited fetch cannot run: the page is being torn down (tab close, navigation, bfcache freeze, mobile * backgrounding). It exposes a sendBeacon POST plus a page-unload handler that lets the player close an * open play session as the tab goes away. sendBeacon (not fetch) is the load-bearing choice here: it * survives page unload, where a fetch would be cancelled. * * Normal play closes (organic end / track-switch / stop) and all share events do NOT use this module — * they go over a first-party same-origin HttpClient POST from C#, which privacy/tracking heuristics do not * block. This module is named off the former telemetry/beacon path (DeepDrftLifecycle, served from * js/session/lifecycle.js) so even this retained unload fallback is not caught by name-based blockers. * * Exposed on window.DeepDrftLifecycle; imported once in App.razor alongside the audio engine. */ // .NET interop type — a DotNetObjectReference the unload handler invokes back into. interface DotNetObjectReference { invokeMethodAsync(methodName: string, ...args: unknown[]): Promise; invokeMethod(methodName: string, ...args: unknown[]): unknown; } // Registered unload listeners. Holding the handler lets us detach on dispose so a torn-down player // circuit does not get called into. type UnloadEntry = { dotNetRef: DotNetObjectReference; methodName: string }; const unloadHandlers = new Map(); let unloadWired = false; // Fire every registered unload handler synchronously. invokeMethod (sync) — not invokeMethodAsync — is // required here: in pagehide/visibilitychange→hidden the event loop will not pump a microtask before the // page is frozen, so an awaited call would never run. The .NET side does only synchronous beacon work. function fireUnloadHandlers(): void { for (const { dotNetRef, methodName } of unloadHandlers.values()) { try { dotNetRef.invokeMethod(methodName); } catch { // A torn-down circuit or a transient interop failure must never block unload. } } } function wireUnloadOnce(): void { if (unloadWired) return; unloadWired = true; // pagehide is the canonical "page is going away" signal (covers tab close, navigation, and the // bfcache freeze). visibilitychange→hidden additionally covers the mobile case where the tab is // backgrounded and may be discarded without a pagehide. Both funnel to the same close path; the // .NET side is idempotent, so a double-fire closes the session at most once. window.addEventListener('pagehide', fireUnloadHandlers); document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') fireUnloadHandlers(); }); } const DeepDrftLifecycle = { /** * Queue a fire-and-forget sendBeacon POST of a small JSON body, for the page-unload edge only. Returns * false if the browser refused to queue the beacon (e.g. over the per-origin byte budget) — callers * ignore it; a dropped telemetry event is acceptable by design. */ send: (url: string, json: string): boolean => { try { const blob = new Blob([json], { type: 'application/json' }); return navigator.sendBeacon(url, blob); } catch { return false; } }, /** * Register a .NET callback to run on page unload (and on visibility→hidden). Keyed so a given player * registers once and can replace/detach cleanly across its lifecycle. */ registerUnload: (key: string, dotNetRef: DotNetObjectReference, methodName: string): void => { wireUnloadOnce(); unloadHandlers.set(key, { dotNetRef, methodName }); }, /** Detach a previously-registered unload callback (player dispose). */ unregisterUnload: (key: string): void => { unloadHandlers.delete(key); }, }; declare global { interface Window { DeepDrftLifecycle: typeof DeepDrftLifecycle; } } window.DeepDrftLifecycle = DeepDrftLifecycle; export { DeepDrftLifecycle };