diff --git a/src/analytics.js b/src/analytics.js
new file mode 100644
index 0000000..8e6cb02
--- /dev/null
+++ b/src/analytics.js
@@ -0,0 +1,897 @@
+// either import the Senza SDK using a script tag, or call
+// `import * as senza from "senza-sdk"` before importing this file
+
+window.dataLayer = window.dataLayer || [];
+
+function gtag() {
+ dataLayer.push(arguments);
+}
+
+class SenzaAnalytics {
+ ipData = {};
+ config = {
+ google: {gtag: null, debug: false},
+ ipdata: {apikey: null},
+ userInfo: {},
+ lifecycle: {raw: false, summary: true},
+ player: {raw: false, summary: true},
+ disconnect: {delay: 0}
+ };
+
+ constructor() {
+ this.banner = null;
+ this.interval = null;
+ this.restoreLifecycleState();
+ this.restorePlayerState();
+
+ senza.lifecycle.addEventListener("beforestatechange", async (event) => {
+ if (event.state === "background") {
+ await this.willMoveToBackground();
+ }
+ });
+
+ senza.lifecycle.addEventListener("onstatechange", (event) => {
+ if (event.state === "background") {
+ this.movedToBackground();
+ } else if (event.state === "foreground") {
+ this.movedToForeground();
+ }
+ });
+
+ senza.lifecycle.addEventListener("userdisconnected", async () => {
+ await this.playerSessionEnd("session_end", { awaitDelivery: true });
+ await this.lifecycleSessionEnd();
+
+ if (this.config.disconnect.delay > 0) {
+ console.log(`Disconnecting in ${this.config.disconnect.delay} seconds...`);
+ await this.sleep(this.config.disconnect.delay);
+ }
+ });
+
+ this.createBanner();
+ this.startLifecycleTimer();
+ }
+
+ async init(app, sparseConfig = {}) {
+ this.configure(sparseConfig);
+
+ console.log("analytics.config", this.config);
+
+ if (this.config.google.gtag) {
+ const gtagScript = document.createElement("script");
+ gtagScript.async = true;
+ gtagScript.src = `https://www.googletagmanager.com/gtag/js?id=${this.config.google.gtag}`;
+ document.head.appendChild(gtagScript);
+ }
+
+ let props = { app };
+ copyValues(props, this.config.userInfo);
+
+ if (senza.isRunningE2E()) {
+ const deviceInfo = senza.deviceManager.deviceInfo;
+ copyValues(props, deviceInfo, ["countryCode", "tenant", "community", "connectionType"]);
+ props["user_id"] = deviceInfo.deviceId;
+ } else {
+ props["connection_type"] = "browser";
+ }
+
+ if (this.config.ipdata.apikey) {
+ this.ipData = await this.getLocation(this.config.ipdata.apikey);
+ copyValues(props, this.ipData, ["city", "region", "country_code"]);
+ }
+
+ gtag('js', new Date());
+ gtag('config', this.config.google.gtag);
+ gtag('set', 'user_properties', props);
+ gtag('set', 'debug_mode', this.config.google.debug);
+
+ console.log('analytics.init', props);
+ }
+
+ configure(sparseConfig) {
+ deepMerge(this.config, sparseConfig);
+ }
+
+ async getLocation(ipDataAPIKey) {
+ let ipAddress = senza.isRunningE2E() ? senza.deviceManager.deviceInfo.clientIp : "";
+ let json = await (await fetch(`https://api.ipdata.co/${ipAddress}?api-key=${ipDataAPIKey}`)).json();
+ return json;
+ }
+
+ logEvent(eventName, data = {}) {
+ data = {...data,
+ debug_mode: this.config.google.debug,
+ };
+ gtag('event', eventName, data)
+ console.log('analytics.logEvent', eventName, data);
+ }
+
+ //// LIFECYCLE ////
+
+ restoreLifecycleState() {
+ this.foreground = parseInt(sessionStorage.getItem("stopwatch/foreground")) || 0;
+ this.background = parseInt(sessionStorage.getItem("stopwatch/background")) || 0;
+ this.backgroundTime = parseInt(sessionStorage.getItem("stopwatch/backgroundTime")) || 0;
+ }
+
+ saveLifecycleState() {
+ sessionStorage.setItem("stopwatch/foreground", `${this.foreground}`);
+ sessionStorage.setItem("stopwatch/background", `${this.background}`);
+ sessionStorage.setItem("stopwatch/backgroundTime", `${this.backgroundTime}`);
+ }
+
+ startLifecycleTimer() {
+ this.updateBanner();
+ clearInterval(this.interval);
+ this.interval = setInterval(() => {
+ this.foreground++;
+ this.updateBanner();
+ this.saveLifecycleState();
+ }, 1000);
+ }
+
+ stopLifecycleTimer() {
+ clearInterval(this.interval);
+ }
+
+ movedToForeground() {
+ setTimeout(() => this.banner.style.color = 'white', 500);
+ if (this.backgroundTime) {
+ this.background += Math.ceil((Date.now() - this.backgroundTime) / 1000);
+ }
+ this.startLifecycleTimer();
+ this.logLifecycleEvent("foreground");
+ }
+
+ async willMoveToBackground() {
+ this.banner.style.color = 'red';
+ await this.sleep(0.025);
+ }
+
+ movedToBackground() {
+ this.banner.style.color = 'red';
+ this.backgroundTime = Date.now();
+ this.stopLifecycleTimer();
+ this.saveLifecycleState();
+ this.savePlayerState();
+ this.logLifecycleEvent("background");
+ }
+
+ lifecycleState() {
+ return {
+ foreground: this.foreground,
+ background: this.background,
+ total: this.foreground + this.background,
+ ratio: Math.floor(this.foreground / (this.foreground + this.background) * 1000) / 1000
+ };
+ }
+
+ logLifecycleEvent(state) {
+ if (this.config.lifecycle.raw) {
+ let message = this.lifecycleState();
+ message.state = state;
+ this.logEvent("lifecycle", message);
+ }
+ }
+
+ lifecycleSessionEnd() {
+ return new Promise((resolve) => {
+ if (this.config.lifecycle.summary) {
+ let message = this.lifecycleState();
+ message.event_callback = () => {
+ setTimeout(resolve, 3000);
+ };
+ message.event_timeout = 5000;
+ message.transport_type = 'beacon';
+ this.logEvent("lifecycle_session_end", message);
+ } else {
+ resolve();
+ }
+ });
+ }
+
+ createBanner() {
+ this.banner = document.createElement('div');
+ this.banner.style.position = 'fixed';
+ this.banner.style.top = '100px';
+ this.banner.style.left = '0';
+ this.banner.style.backgroundColor = 'rgba(0, 0, 0, 0.4)';
+ this.banner.style.color = 'white';
+ this.banner.style.fontFamily = 'monospace';
+ this.banner.style.fontWeight = '500';
+ this.banner.style.fontSize = '24px';
+ this.banner.style.padding = '22px';
+ this.banner.style.display = 'flex';
+ this.banner.style.zIndex = '1000';
+ this.banner.style.pointerEvents = 'none';
+ this.banner.style.opacity = 0;
+ document.body.appendChild(this.banner);
+ }
+
+ updateBanner() {
+ if (this.banner) {
+ let ratio = this.foreground ? Math.floor(this.foreground /
+ (this.foreground + this.background) * 10000) / 100 : 100;
+ this.banner.innerHTML = `Foreground: ${formatTime(this.foreground)}
`;
+ this.banner.innerHTML += `Background: ${formatTime(this.background)}
`;
+ this.banner.innerHTML += `${' '.repeat(4)} Ratio: ${ratio.toFixed(2)}%`;
+ }
+ }
+
+ showStopwatch() {
+ this.banner.style.opacity = 1;
+ }
+
+ hideStopwatch() {
+ this.banner.style.opacity = 0;
+ }
+
+ async sleep(seconds) {
+ return new Promise(resolve => setTimeout(resolve, seconds * 1000));
+ }
+
+ //// PLAYER ////
+
+ playerStorageKey = "player/session";
+
+ restorePlayerState() {
+ try {
+ const raw = sessionStorage.getItem(this.playerStorageKey);
+ this._restoredPlayerCore = raw ? JSON.parse(raw) : null;
+ } catch (_) {
+ this._restoredPlayerCore = null;
+ }
+ }
+
+ savePlayerState() {
+ const s = this._playerSession;
+ if (!s) return;
+
+ const core = {
+ src: s.url?.() || s.meta?.src || "",
+ startedAt: s.startedAt,
+ watchedMs: s.watchedMs,
+ lastPlayStart: s.lastPlayStart,
+ lastTime: s.lastTime,
+ metaSnapshot: s.meta || {},
+ sent: s.sent,
+ active: s.active,
+ };
+
+ try {
+ sessionStorage.setItem(this.playerStorageKey, JSON.stringify(core));
+ } catch (_) {}
+ }
+
+ clearPlayerState() {
+ try {
+ sessionStorage.removeItem(this.playerStorageKey);
+ } catch (_) {}
+ this._restoredPlayerCore = null;
+ }
+
+ /** Track events in the Shaka player and the media element.
+ * The third argument can be either an object with media properties,
+ * or an (async) function that takes the URL of a stream and returns its properties.
+ *
+ * If config.player.raw, sends all low-level events (play, pause, skip, etc.)
+ * If config.player.summary, sends just one player_session_end event per stream watched.
+ *
+ * Works equally well with regular Shaka or the Senza Shaka subclass.
+ * In the latter case it is sufficient to follow the local player because
+ * we know the remote player is simply following what hte local player is doing.
+ *
+ * If using the remote player directly without a local player, use trackRemotePlayerEvents() instead.
+ **/
+ trackPlayerEvents(player, media, metaOrFn = {}) {
+ // --- helpers (same semantics as your current version) ---
+ const enterPlaying = () => {
+ if (!this._playerSession?.active) return;
+ if (this._playerSession.lastPlayStart == null) {
+ this._playerSession.lastPlayStart = Date.now();
+ }
+ this.savePlayerState();
+ if (this.config.player.raw) {
+ this.logEvent("player_state", {
+ state: "playing",
+ current_time: media.currentTime || 0,
+ src: this._playerSession.url(),
+ // ...snakeMeta(this._playerSession.meta),
+ });
+ }
+ };
+
+ const leavePlaying = (state) => {
+ if (!this._playerSession?.active) return;
+ if (this._playerSession.lastPlayStart != null) {
+ this._playerSession.watchedMs += Date.now() - this._playerSession.lastPlayStart;
+ this._playerSession.lastPlayStart = null;
+ }
+ this.savePlayerState();
+ if (this.config.player.raw && state) {
+ this.logEvent("player_state", {
+ state,
+ current_time: media.currentTime || 0,
+ src: this._playerSession.url(),
+ // ...snakeMeta(this._playerSession.meta),
+ });
+ }
+ };
+
+ const onSeeking = () => {
+ leavePlaying("seeking");
+ this.savePlayerState();
+ if (this.config.player.raw) {
+ this.logEvent("player_seek", {
+ current_time: media.currentTime || 0,
+ src: this._playerSession.url(),
+ // ...snakeMeta(this._playerSession.meta),
+ });
+ }
+ };
+
+ const onSeeked = () => {
+ if (this.config.player.raw) {
+ this.logEvent("player_seeked", {
+ current_time: media.currentTime || 0,
+ src: this._playerSession.url(),
+ // ...snakeMeta(this._playerSession.meta),
+ });
+ }
+ };
+
+ const onEnded = () => {
+ leavePlaying("ended");
+ this.playerSessionEnd("ended");
+ };
+
+ const beginSession = (initialMeta = {}, urlHint = "") => {
+ this._playerSession = {
+ active: true,
+ media,
+ remote: null,
+ sent: false,
+ url: () =>
+ player.getAssetUri?.() ||
+ initialMeta.src ||
+ initialMeta.url ||
+ urlHint ||
+ media.currentSrc ||
+ "",
+ meta: { ...initialMeta },
+ startedAt: Date.now(),
+ lastPlayStart: null,
+ watchedMs: 0,
+ lastTime: 0,
+ metaProvider: metaOrFn,
+ player,
+ };
+ };
+
+ const onPause = () => leavePlaying("pause");
+ const onWaiting = () => leavePlaying("waiting");
+ const onStalled = () => leavePlaying("stalled");
+
+ media.addEventListener("playing", enterPlaying);
+ media.addEventListener("pause", onPause);
+ media.addEventListener("waiting", onWaiting);
+ media.addEventListener("stalled", onStalled);
+ media.addEventListener("seeking", onSeeking);
+ media.addEventListener("seeked", onSeeked);
+ media.addEventListener("ended", onEnded);
+
+ try {
+ player.addEventListener("unloading", () => {
+ this.playerSessionEnd("unload");
+ });
+ } catch (_) {}
+
+ const __origLoad = player.load.bind(player);
+
+ player.load = async (url, ...rest) => {
+ const restored = this._restoredPlayerCore;
+ const isContinuation =
+ restored &&
+ restored.active &&
+ !restored.sent &&
+ restored.src === url;
+
+ if (!isContinuation) {
+ if (restored && restored.active && !restored.sent) {
+ this.logEvent("player_session_end", {
+ src: restored.src,
+ reason: "restart_abandoned",
+ started_at_ms: restored.startedAt,
+ watched_ms: restored.watchedMs || 0,
+ watched_sec: Math.round((restored.watchedMs || 0) / 1000),
+ transport_type: 'beacon',
+ ...snakeMeta(restored.metaSnapshot || {}),
+ });
+ }
+ await this.playerSessionEnd("load_new_url");
+ }
+
+ const initialMeta = (typeof metaOrFn === "function")
+ ? await resolveMeta(metaOrFn, { url, player, media })
+ : (metaOrFn || {});
+ if (!initialMeta.src) initialMeta.src = url;
+
+ const result = await __origLoad(url, ...rest);
+
+ beginSession(initialMeta, url);
+
+ if (isContinuation) {
+ this._playerSession.startedAt = restored.startedAt;
+ this._playerSession.watchedMs = restored.watchedMs || 0;
+ this._playerSession.lastPlayStart = restored.lastPlayStart;
+ this._playerSession.lastTime = restored.lastTime || 0;
+ if (!Object.keys(this._playerSession.meta || {}).length) {
+ this._playerSession.meta = restored.metaSnapshot || {};
+ }
+ } else {
+ this.playerSessionStart();
+ }
+
+ this._restoredPlayerCore = null;
+ this.savePlayerState();
+
+ // backfill duration when metadata becomes available
+ const onLoadedMeta = () => {
+ if (!this._playerSession?.active) return;
+ const d = media.duration;
+ if (Number.isFinite(d) && d > 0 && this._playerSession.meta.durationSec == null) {
+ this._playerSession.meta.durationSec = Math.round(d);
+ }
+ media.removeEventListener("loadedmetadata", onLoadedMeta);
+ };
+ media.addEventListener("loadedmetadata", onLoadedMeta, { once: true, passive: true });
+
+ // prefer canonical asset URI if Shaka provides one
+ try {
+ const finalUri = typeof player.getAssetUri === "function" ? player.getAssetUri() : null;
+ if (finalUri) this._playerSession.meta.src = finalUri;
+ } catch (_) {}
+
+ this.savePlayerState();
+
+ return result;
+ };
+
+ // Stash a detach to remove media listeners if you later dispose
+ this._playerSession = this._playerSession || { active: false };
+ this._playerSession.detach = () => {
+ media.removeEventListener("playing", enterPlaying);
+ media.removeEventListener("pause", onPause);
+ media.removeEventListener("waiting", onWaiting);
+ media.removeEventListener("stalled", onStalled);
+ media.removeEventListener("seeking", onSeeking);
+ media.removeEventListener("seeked", onSeeked);
+ media.removeEventListener("ended", onEnded);
+ };
+ }
+
+ /** Track events in the Remote Player when using it direclty.
+ * The single argument can be either an object with media properties,
+ * or an (async) function that takes the URL of a stream and returns its properties.
+ *
+ * If config.player.raw, sends all low-level events (play, pause, skip, etc.)
+ * If config.player.summary, sends just one player_session_end event per stream watched.
+ *
+ * If using the (Senza) Shaka player, use trackPlayerEvents() instead.
+ **/
+ trackRemotePlayerEvents(metaOrFn = {}) {
+ const player = senza?.remotePlayer;
+ if (!player) throw new Error("remotePlayer not available");
+
+ const beginSession = (initialMeta = {}, urlHint = "") => {
+ this._playerSession = {
+ active: true,
+ media: null,
+ remote: player,
+ sent: false,
+ url: () =>
+ initialMeta.src ||
+ initialMeta.url ||
+ player.getAssetUri?.() ||
+ urlHint ||
+ "",
+ meta: { ...initialMeta },
+ startedAt: Date.now(),
+ lastPlayStart: null,
+ watchedMs: 0,
+ lastTime: 0,
+ metaProvider: metaOrFn,
+ player,
+ };
+ };
+
+ const enterPlaying = () => {
+ if (!this._playerSession?.active) return;
+ if (this._playerSession.lastPlayStart == null) {
+ this._playerSession.lastPlayStart = Date.now();
+ }
+ this.savePlayerState();
+ if (this.config.player.raw) {
+ this.logEvent("player_state", {
+ state: "playing",
+ current_time: this._safeMediaTime(),
+ src: this._playerSession.url(),
+ // ...snakeMeta(this._playerSession.meta),
+ });
+ }
+ };
+
+ const leavePlaying = (state) => {
+ if (!this._playerSession?.active) return;
+ if (this._playerSession.lastPlayStart != null) {
+ this._playerSession.watchedMs += Date.now() - this._playerSession.lastPlayStart;
+ this._playerSession.lastPlayStart = null;
+ }
+ this.savePlayerState();
+ if (this.config.player.raw && state) {
+ this.logEvent("player_state", {
+ state,
+ current_time: this._safeMediaTime(),
+ src: this._playerSession.url(),
+ // ...snakeMeta(this._playerSession.meta),
+ });
+ }
+ };
+
+ const onEnded = () => {
+ leavePlaying("ended");
+ this.playerSessionEnd("ended");
+ };
+
+ const onLoadedMeta = () => {
+ if (!this._playerSession?.active) return;
+ const d = player.duration;
+ if (Number.isFinite(d) && d > 0 && this._playerSession.meta.durationSec == null) {
+ this._playerSession.meta.durationSec = Math.round(d);
+ }
+ };
+
+ const onLoadModeChange = () => {
+ this.playerSessionEnd("load_new_url");
+ };
+
+ player.addEventListener("loadedmetadata", onLoadedMeta, { passive: true });
+ player.addEventListener("playing", enterPlaying, { passive: true });
+ player.addEventListener("ended", onEnded, { passive: true });
+ player.addEventListener("onloadmodechange", onLoadModeChange, { passive: true });
+
+ const __orig = {
+ load: player.load.bind(player),
+ unload: player.unload.bind(player),
+ stop: player.stop.bind(player),
+ detach: player.detach.bind(player),
+ pause: player.pause.bind(player),
+ };
+
+ player.load = async (url, ...rest) => {
+ const restored = this._restoredPlayerCore;
+ const isContinuation =
+ restored &&
+ restored.active &&
+ !restored.sent &&
+ restored.src === url;
+
+ if (!isContinuation) {
+ if (restored && restored.active && !restored.sent) {
+ this.logEvent("player_session_end", {
+ src: restored.src,
+ reason: "restart_abandoned",
+ started_at_ms: restored.startedAt,
+ watched_ms: restored.watchedMs || 0,
+ watched_sec: Math.round((restored.watchedMs || 0) / 1000),
+ transport_type: 'beacon',
+ ...snakeMeta(restored.metaSnapshot || {}),
+ });
+ }
+ await this.playerSessionEnd("load_new_url");
+ }
+
+ const initialMeta = (typeof metaOrFn === "function")
+ ? await resolveMeta(metaOrFn, { url, player })
+ : (metaOrFn || {});
+ if (!initialMeta.src) initialMeta.src = url;
+
+ const r = await __orig.load(url, ...rest);
+
+ beginSession(initialMeta, url);
+
+ if (isContinuation) {
+ this._playerSession.startedAt = restored.startedAt;
+ this._playerSession.watchedMs = restored.watchedMs || 0;
+ this._playerSession.lastPlayStart = restored.lastPlayStart;
+ this._playerSession.lastTime = restored.lastTime || 0;
+ if (!Object.keys(this._playerSession.meta || {}).length) {
+ this._playerSession.meta = restored.metaSnapshot || {};
+ }
+ } else {
+ this.playerSessionStart();
+ }
+
+ this._restoredPlayerCore = null;
+ this.savePlayerState();
+
+ try {
+ const finalUri = player.getAssetUri?.();
+ if (finalUri) this._playerSession.meta.src = finalUri;
+ } catch (_) {}
+
+ return r;
+ };
+
+ player.pause = async (...args) => { const r = await __orig.pause(...args); leavePlaying("pause"); return r; };
+ player.stop = async (...args) => { leavePlaying("unload"); await this.playerSessionEnd("unload"); return __orig.stop(...args); };
+ player.unload = async (...args) => { leavePlaying("unload"); await this.playerSessionEnd("unload"); return __orig.unload(...args); };
+ player.detach = async (...args) => { leavePlaying("unload"); await this.playerSessionEnd("unload", { awaitDelivery: true }); return __orig.detach(...args); };
+
+ this._playerSession = this._playerSession || { active: false };
+ this._playerSession.detach = () => {
+ try {
+ player.removeEventListener("loadedmetadata", onLoadedMeta);
+ player.removeEventListener("playing", enterPlaying);
+ player.removeEventListener("ended", onEnded);
+ player.removeEventListener("onloadmodechange", onLoadModeChange);
+ } catch (_) {}
+ };
+ }
+
+ _beginPlayerSession(player, media, initialMeta = {}, urlHint = "") {
+ this._playerSession = {
+ active: true,
+ media,
+
+ remote: media ? null : player,
+ sent: false,
+ url: () =>
+ (media && media.currentSrc) ||
+ initialMeta.src || initialMeta.url ||
+ urlHint || "",
+ meta: { ...initialMeta },
+ startedAt: Date.now(),
+ lastPlayStart: null,
+ watchedMs: 0,
+ lastTime: 0,
+ };
+ }
+
+ playerSessionStart() {
+ const s = this._playerSession;
+ if (!s?.active) return;
+
+ this.logEvent("player_session_start", {
+ src: s.url(),
+ started_at_ms: s.startedAt,
+ ...snakeMeta(s.meta),
+ });
+ }
+
+ // Return a Promise; resolve immediately unless caller opts to await delivery.
+ playerSessionEnd(reason = "unknown", { awaitDelivery = false, detachListeners = false } = {}) {
+ return new Promise((resolve) => {
+ const s = this._playerSession;
+ if (!s?.active) return resolve();
+
+ if (s.lastPlayStart != null) {
+ s.watchedMs += Date.now() - s.lastPlayStart;
+ s.lastPlayStart = null;
+ }
+
+ if (this.config.player.raw) {
+ this.logEvent("player_state", {
+ state: "closing",
+ reason,
+ current_time: (typeof document !== "undefined" && this._safeMediaTime()) || 0,
+ src: s.url(),
+ // ...snakeMeta(this._playerSession.meta),
+ });
+ }
+
+ if (this.config.player.summary && !s.sent) {
+ const mediaDurationSec = (s.meta?.durationSec != null ? s.meta.durationSec : null);
+ const watchedMs = Math.max(0, Math.round(s.watchedMs));
+ const watchedSec = Math.round(watchedMs / 1000);
+ const payload = {
+ src: s.url(),
+ reason,
+ started_at_ms: s.startedAt,
+ watched_ms: watchedMs,
+ watched_sec: watchedSec,
+ ...snakeMeta(this._playerSession.meta),
+ };
+ if (typeof mediaDurationSec === "number" && isFinite(mediaDurationSec) && mediaDurationSec > 0) {
+ payload.duration_sec = Math.round(mediaDurationSec);
+ payload.watch_ratio = Math.min(1, Math.round((watchedSec / mediaDurationSec) * 1000) / 1000);
+ }
+
+ if (awaitDelivery) {
+ payload.event_callback = () => { setTimeout(resolve, 3000); };
+ payload.event_timeout = 5000;
+ this.logEvent("player_session_end", payload);
+ } else {
+ this.logEvent("player_session_end", payload);
+ resolve();
+ }
+ } else {
+ resolve();
+ }
+
+ s.sent = true;
+ s.active = false;
+ if (detachListeners) {
+ try { s.detach?.(); } catch (_) {}
+ }
+ // Persist or clear depending on whether this is a terminal end.
+ if (reason === "ended" || reason === "session_end" || reason === "userdisconnected") {
+ this.clearPlayerState();
+ } else {
+ this.savePlayerState();
+ }
+ });
+ }
+
+ _safeMediaTime() {
+ const s = this._playerSession;
+ try {
+ if (s?.media && typeof s.media.currentTime === "number") return s.media.currentTime;
+ if (s?.remote && typeof s.remote.currentTime === "number") return s.remote.currentTime;
+ } catch (_) {}
+ return 0;
+ }
+
+ sendPlayerSummary(reason) {
+ const s = this._playerSession;
+ if (!s) return;
+
+ const mediaDurationSec =
+ (s.meta?.durationSec != null ? s.meta.durationSec : null);
+ const watchedMs = Math.max(0, Math.round(s.watchedMs));
+ const watchedSec = Math.round(watchedMs / 1000);
+ const payload = {
+ src: s.url(),
+ reason, // ended | unload | load_new_url | session_end | unknown
+ started_at_ms: s.startedAt,
+ watched_ms: watchedMs,
+ watched_sec: watchedSec,
+ ...snakeMeta(this._playerSession.meta),
+ };
+
+ if (typeof mediaDurationSec === "number" && isFinite(mediaDurationSec) && mediaDurationSec > 0) {
+ payload.duration_sec = Math.round(mediaDurationSec);
+ payload.watch_ratio = Math.min(
+ 1,
+ Math.round((watchedSec / mediaDurationSec) * 1000) / 1000
+ );
+ }
+
+ this.logEvent("player_session_end", payload);
+ }
+
+ /**
+ * Mark a logical content/program change on the same manifest/stream.
+ *
+ * Ends the current player session (sending a summary if enabled),
+ * then starts a new one with new metadata.
+ *
+ * @param {Object|Function} [metaOrFn] - Optional new meta provider,
+ * same semantics as trackPlayerEvents/trackRemotePlayerEvents:
+ * - object: used directly as meta
+ * - function: called (possibly async) with { url, player, media }
+ * - omitted: reuse previous metaProvider (if any)
+ */
+ async contentChanged(metaOrFn) {
+ const s = this._playerSession;
+ if (!s || !s.active) {
+ console.warn("contentChanged() called with no active player session");
+ return;
+ }
+
+ const provider = typeof metaOrFn !== "undefined" ? metaOrFn : s.metaProvider;
+
+ await this.playerSessionEnd("content_change", {
+ awaitDelivery: false,
+ detachListeners: false,
+ });
+
+ let initialMeta = {};
+ if (typeof provider === "function") {
+ const ctx = {
+ url: s.url(),
+ player: s.remote || s.player || null,
+ media: s.media || null,
+ };
+ initialMeta = await resolveMeta(provider, ctx);
+ } else if (provider && typeof provider === "object") {
+ initialMeta = provider;
+ }
+
+ if (!initialMeta.src) {
+ initialMeta.src = s.url();
+ }
+
+ this._playerSession = {
+ active: true,
+ media: s.media,
+ remote: s.remote,
+ sent: false,
+ url: s.url,
+ meta: { ...initialMeta },
+ startedAt: Date.now(),
+ lastPlayStart: null,
+ watchedMs: 0,
+ lastTime: 0,
+ metaProvider: provider,
+ player: s.player || s.remote || null,
+ detach: s.detach,
+ };
+ }
+}
+
+const analytics = new SenzaAnalytics();
+export default analytics;
+
+// copies values with certain keys from one object to another
+function copyValues(to, from, keys = null) {
+ if (!(keys instanceof Array)) keys = Object.keys(from);
+ for (const key of keys) {
+ if (key in from) {
+ to[camelToSnake(key)] = from[key];
+ }
+ }
+}
+
+// all Google Analytics properties should be in snake case
+function camelToSnake(str) {
+ return str
+ .replace(/([a-z0-9])([A-Z])/g, "$1_$2")
+ .toLowerCase();
+}
+
+function snakeMeta(meta = {}) {
+ const out = {};
+ for (const k of Object.keys(meta)) {
+ out[camelToSnake(k)] = meta[k];
+ }
+ return out;
+}
+
+// merge a sparse object's properties into another object
+function deepMerge(target, source) {
+ if (!source || typeof source !== 'object') return target;
+ for (const k of Object.keys(source)) {
+ if (k === '__proto__' || k === 'constructor' || k === 'prototype') continue;
+ const s = source[k], t = target[k];
+ if (Array.isArray(s)) {
+ target[k] = s.slice();
+ } else if (s && typeof s === 'object') {
+ target[k] = deepMerge((t && typeof t === 'object' && !Array.isArray(t)) ? t : {}, s);
+ } else {
+ target[k] = s;
+ }
+ }
+ return target;
+}
+
+function formatTime(seconds) {
+ const hours = Math.floor(seconds / 3600);
+ const minutes = Math.floor((seconds % 3600) / 60);
+ const secs = seconds % 60;
+ return String(hours) + ':' +
+ String(minutes).padStart(2, '0') + ':' +
+ String(secs).padStart(2, '0');
+}
+
+// Helper: run metaForLoad safely (sync or async), always returns an object
+async function resolveMeta(metaForLoad, ctx) {
+ if (typeof metaForLoad !== 'function') return {};
+ try {
+ const maybe = metaForLoad(ctx);
+ const meta = (maybe && typeof maybe.then === 'function') ? await maybe : maybe;
+ return (meta && typeof meta === 'object') ? meta : {};
+ } catch (e) {
+ return {};
+ }
+}
diff --git a/src/widgets/player.js b/src/widgets/player.js
index 63502af..a31e64b 100644
--- a/src/widgets/player.js
+++ b/src/widgets/player.js
@@ -1,4 +1,5 @@
import { createElement, importStyles } from "../core/ui/element.js";
+import analytics from "../analytics.js"
export class PlayerWidget extends HTMLElement {
constructor() {
@@ -16,6 +17,8 @@ export class PlayerWidget extends HTMLElement {
this.video.play();
}
});
+
+ this.initAnalytics();
}
configure() {
@@ -44,6 +47,28 @@ export class PlayerWidget extends HTMLElement {
}
}
+ async initAnalytics() {
+ const config = await this.loadAnalyticsConfig();
+ await analytics.init("Program Guide", {
+ google: {gtag: config.googleAnalyticsId, debug: true},
+ ipdata: {apikey: config.ipDataAPIKey},
+ lifecycle: {raw: false, summary: true},
+ player: {raw: false, summary: true}
+ });
+ analytics.trackPlayerEvents(this.player, this.video, (ctx) => this.asset);
+ }
+
+ async loadAnalyticsConfig(url = "./src/config.json") {
+ try {
+ const res = await fetch(url, { cache: "no-store" });
+ if (!res.ok) return {};
+ return await res.json();
+ } catch {
+ console.warn("Analytics config.json not found");
+ return {};
+ }
+ }
+
async load(asset) {
this.asset = asset;
this.configure();