diff --git a/js/analytics.js b/js/analytics.js new file mode 100644 index 00000000..f256f4ee --- /dev/null +++ b/js/analytics.js @@ -0,0 +1,195 @@ +// js/analytics.js +import { ANALYTICS_CONFIG } from "./analyticsConfig.js"; + +const SCRIPT_ATTR = "data-bitvid-analytics"; +const SCRIPT_IDENTIFIER = "umami"; +const pendingCalls = []; +let flushTimerId = null; +let scriptLoadedOnce = false; + +function isBrowserEnvironment() { + return typeof window !== "undefined" && typeof document !== "undefined"; +} + +function flushPendingCalls() { + if (!isBrowserEnvironment()) { + return; + } + + const umami = window.umami; + if (!umami) { + return; + } + + while (pendingCalls.length > 0) { + const { method, args } = pendingCalls.shift(); + const fn = typeof umami[method] === "function" ? umami[method] : null; + if (!fn) { + continue; + } + try { + fn.apply(umami, args); + } catch (err) { + console.warn("[analytics] Failed to call", method, err); + } + } +} + +function scheduleFlush() { + if (!isBrowserEnvironment()) { + return; + } + + if (flushTimerId !== null) { + return; + } + + // 🔧 merged conflicting changes from codex/add-tracking-script-to-all-pages vs unstable + flushTimerId = window.setInterval(() => { + if (window.umami && typeof window.umami.track === "function") { + window.clearInterval(flushTimerId); + flushTimerId = null; + flushPendingCalls(); + } + }, 500); +} + +export function ensureAnalyticsLoaded(doc = typeof document !== "undefined" ? document : null) { + if (!doc) { + return null; + } + + let script = doc.querySelector(`script[${SCRIPT_ATTR}]`); + if (script) { + if (!scriptLoadedOnce) { + // If a server rendered script already exists, make sure we flush asap. + scriptLoadedOnce = true; + flushPendingCalls(); + } + return script; + } + + script = doc.createElement("script"); + script.defer = true; + script.src = ANALYTICS_CONFIG.scriptSrc; + script.setAttribute(SCRIPT_ATTR, SCRIPT_IDENTIFIER); + script.dataset.websiteId = ANALYTICS_CONFIG.websiteId; + // 🔧 merged conflicting changes from codex/add-tracking-script-to-all-pages vs unstable + script.dataset.autoTrack = "false"; + script.addEventListener("load", () => { + scriptLoadedOnce = true; + flushPendingCalls(); + }); + doc.head.appendChild(script); + + scheduleFlush(); + + return script; +} + +function queueCall(method, args) { + pendingCalls.push({ method, args }); + scheduleFlush(); +} + +function invokeUmami(method, args) { + if (!isBrowserEnvironment()) { + return; + } + + ensureAnalyticsLoaded(); + + const umami = window.umami; + if (umami && typeof umami[method] === "function") { + try { + umami[method].apply(umami, args); + } catch (err) { + console.warn("[analytics] Failed to call", method, err); + } + return; + } + + queueCall(method, args); +} + +export function trackPageView(path, referrer) { + if (!isBrowserEnvironment()) { + return; + } + + const resolvedPath = + typeof path === "string" && path.length > 0 + ? path + : `${window.location.pathname}${window.location.hash || ""}`; + + const resolvedReferrer = + typeof referrer === "string" ? referrer : document.referrer || ""; + + // 🔧 merged conflicting changes from codex/add-tracking-script-to-all-pages vs unstable + let absoluteUrl = resolvedPath; + try { + absoluteUrl = new URL(resolvedPath, window.location.origin).toString(); + } catch (err) { + // Fall back to the provided path if it cannot be resolved. + } + + let absoluteReferrer = resolvedReferrer; + if (resolvedReferrer) { + try { + absoluteReferrer = new URL(resolvedReferrer, window.location.origin).toString(); + } catch (err) { + // Keep original referrer if it cannot be normalized. + } + } + + invokeUmami("track", [ + (basePayload = {}) => ({ + ...basePayload, + url: absoluteUrl || basePayload.url, + referrer: absoluteReferrer || basePayload.referrer, + }), + "pageview", + ]); +} + +export function trackVideoView({ + videoId, + title, + source, + hasMagnet, + hasUrl, +} = {}) { + if (!isBrowserEnvironment()) { + return; + } + + const payload = {}; + + if (videoId) { + payload.videoId = String(videoId); + } + + if (title) { + payload.title = String(title); + } + + if (source) { + payload.source = String(source); + } + + if (typeof hasMagnet === "boolean") { + payload.hasMagnet = hasMagnet; + } + + if (typeof hasUrl === "boolean") { + payload.hasUrl = hasUrl; + } + + // 🔧 merged conflicting changes from codex/add-tracking-script-to-all-pages vs unstable + invokeUmami("track", [ANALYTICS_CONFIG.videoViewEventName, payload]); +} + +// Immediately queue the analytics script so page views are captured early. +if (isBrowserEnvironment()) { + ensureAnalyticsLoaded(); +} diff --git a/js/analyticsConfig.js b/js/analyticsConfig.js new file mode 100644 index 00000000..a4bf0749 --- /dev/null +++ b/js/analyticsConfig.js @@ -0,0 +1,18 @@ +// js/analyticsConfig.js +// Central configuration for site-wide analytics tracking. + +export const ANALYTICS_CONFIG = Object.freeze({ + /** + * Hosted Umami script that powers analytics. + * Update this value if the tracker is relocated. + */ + scriptSrc: "https://umami.malin.onl/script.js", + /** + * The Umami website identifier for this deployment. + */ + websiteId: "1f8eead2-79f0-4dba-8c3b-ed9b08b6e877", + /** + * Event name used when recording individual video views. + */ + videoViewEventName: "video_view", +}); diff --git a/js/app.js b/js/app.js index 07878cc0..61fd68df 100644 --- a/js/app.js +++ b/js/app.js @@ -9,6 +9,7 @@ import { safeDecodeMagnet } from "./magnetUtils.js"; import { normalizeAndAugmentMagnet } from "./magnet.js"; import { deriveTorrentPlaybackConfig } from "./playbackUtils.js"; import { URL_FIRST_ENABLED } from "./constants.js"; +import { trackVideoView } from "./analytics.js"; import { initialWhitelist, initialBlacklist, @@ -3111,6 +3112,14 @@ class bitvidApp { const magnetSupported = isValidMagnetUri(usableMagnetCandidate); const sanitizedMagnet = magnetSupported ? usableMagnetCandidate : ""; + trackVideoView({ + videoId: video.id || eventId, + title: video.title || "Untitled", + source: "event", + hasMagnet: !!sanitizedMagnet, + hasUrl: !!trimmedUrl, + }); + this.currentVideo = { ...video, url: trimmedUrl, @@ -3195,6 +3204,17 @@ class bitvidApp { const magnetSupported = isValidMagnetUri(usableMagnet); const sanitizedMagnet = magnetSupported ? usableMagnet : ""; + trackVideoView({ + videoId: + typeof title === "string" && title.trim().length > 0 + ? `direct:${title.trim()}` + : "direct-playback", + title, + source: "direct", + hasMagnet: !!sanitizedMagnet, + hasUrl: !!sanitizedUrl, + }); + if (!sanitizedUrl && !sanitizedMagnet) { const message = trimmedMagnet && !magnetSupported ? UNSUPPORTED_BTITH_MESSAGE diff --git a/js/index.js b/js/index.js index d86228d1..12e3726b 100644 --- a/js/index.js +++ b/js/index.js @@ -1,5 +1,7 @@ // js/index.js +import { trackPageView } from "./analytics.js"; + // 1) Load modals (login, application, etc.) async function loadModal(url) { try { @@ -314,6 +316,7 @@ function handleHashChange() { if (typeof initFn === "function") { initFn(); } + recordView("most-recent-videos"); }); }); return;