// ==UserScript== // @name Tobii Ambassador Bonanza Stats // @description Provide stats for Tobii - show points, hours watched and countdown to start/end. // @author Lone Destroyer // @license MIT // @match https://engage.tobii.gg/ambassador-bonanza* // @icon https://engage.tobii.gg/favicon.ico // @version 1.0 // @namespace https://github.com/LoneDestroyer // @downloadURL https://raw.githubusercontent.com/LoneDestroyer/Tobii-Ambassador-Bonanza-Stats/main/Tobii-Ambassador-Bonanza-Stats.user.js // @updateURL https://raw.githubusercontent.com/LoneDestroyer/Tobii-Ambassador-Bonanza-Stats/main/Tobii-Ambassador-Bonanza-Stats.user.js // @run-at document-start // ==/UserScript== (function() { 'use strict'; const PATHS = { status: '/api/sweepstake/ambassador-bonanza/status', watched: '/api/sweepstake/ambassador-bonanza/watched' }; const state = { statusData: null, watchedData: null, nodes: null, renderInterval: null, closed: false }; const pick = (root, selector) => root.querySelector(selector); const nowMs = () => Date.now(); const fmt = { countdown(endAt) { if (!endAt) return ''; const diff = new Date(endAt).getTime() - nowMs(); if (Number.isNaN(diff + nowMs())) return 'Unknown'; if (diff <= 0) return 'Ended'; const total = Math.floor(diff / 1000); const days = Math.floor(total / 86400); const hours = Math.floor(total % 86400 / 3600); const minutes = Math.floor(total % 3600 / 60); const seconds = total % 60; if (total > 86400) return days + 'd ' + String(hours).padStart(2, '0') + 'h'; return [hours, minutes, seconds].map((value, index) => String(value).padStart(2, '0') + 'hms'[index]).join(' '); }, watched(totalMinutes) { return typeof totalMinutes === 'number' && Number.isFinite(totalMinutes) ? Math.floor(totalMinutes / 60) + 'h ' + totalMinutes % 60 + 'm' : ''; }, }; function ensurePanel() { if (state.nodes) return state.nodes; const panel = document.createElement('aside'); panel.id = 'tobii-bonanza-stats-panel'; panel.style.cssText = 'position:fixed;top:14px;right:14px;z-index:2147483647;width:min(280px,calc(100vw - 24px));padding:6px 8px;border:1px solid rgba(67,208,255,.28);border-radius:16px;background:linear-gradient(165deg,rgba(8,8,18,.97),rgba(14,20,44,.95) 52%,rgba(7,34,62,.94));color:#eef8ff;font:12px/1.35 Trebuchet MS,Segoe UI,sans-serif;box-shadow:0 16px 32px rgba(2,8,24,.48),inset 0 1px 0 rgba(171,115,255,.12);backdrop-filter:blur(12px)'; panel.innerHTML = '
AMBASSADOR BONANZA
Ends in
My Points
Watch Time
'; const header = pick(panel, '[data-drag]'); const toggle = pick(panel, '[data-toggle]'); const closeBtn = pick(panel, '[data-close]'); const body = pick(panel, '[data-body]'); let x = 0, y = 0; header.onpointerdown = (event) => { if (event.target === toggle || event.target === closeBtn) return; const rect = panel.getBoundingClientRect(); x = event.clientX - rect.left; y = event.clientY - rect.top; panel.style.left = rect.left + 'px'; panel.style.top = rect.top + 'px'; panel.style.right = 'auto'; header.setPointerCapture(event.pointerId); }; header.onpointermove = (event) => header.hasPointerCapture(event.pointerId) && ((panel.style.left = Math.max(8, event.clientX - x) + 'px'), (panel.style.top = Math.max(8, event.clientY - y) + 'px')); header.onpointerup = (event) => header.hasPointerCapture(event.pointerId) && header.releasePointerCapture(event.pointerId); toggle.onclick = () => ((body.style.display = body.style.display === 'none' ? 'block' : 'none'), (toggle.textContent = body.style.display === 'none' ? '+' : '−')); closeBtn.onclick = () => { state.closed = true; panel.remove(); clearInterval(state.renderInterval); }; const mount = () => document.body && !panel.isConnected && document.body.appendChild(panel); return (state.nodes = { timeLabel: pick(panel, '[data-label="time"]'), timeValue: pick(panel, '[data-key="time"]'), pointsValue: pick(panel, '[data-key="points"]'), watchedValue: pick(panel, '[data-key="watched"]'), pointsRow: pick(panel, '[data-row="points"]'), watchedRow: pick(panel, '[data-row="watched"]'), divider: pick(panel, '[data-divider]') }); } function render() { if (!state.statusData && !state.watchedData) return; const participant = state.statusData && state.statusData.participant; const sweepstake = state.statusData && state.statusData.sweepstake; const totalWatched = state.watchedData && state.watchedData.totalWatched; const startAt = sweepstake && sweepstake.startAt; const endAt = sweepstake && sweepstake.endAt; const startMs = startAt ? new Date(startAt).getTime() : NaN; const hasValidStart = Number.isFinite(startMs); const notStarted = hasValidStart && startMs > nowMs(); const timeLabel = notStarted ? 'Starts in' : 'Ends in'; const timeValue = fmt.countdown(notStarted ? startAt : endAt); const nodes = ensurePanel(); if (!nodes.timeValue.closest('aside').isConnected) { const mount = () => document.body && document.body.appendChild(nodes.timeValue.closest('aside')); document.readyState === 'loading' ? document.addEventListener('DOMContentLoaded', mount, { once: true }) : mount(); } nodes.timeLabel.textContent = timeLabel; nodes.timeValue.textContent = timeValue; nodes.pointsRow.style.display = notStarted ? 'none' : 'flex'; nodes.watchedRow.style.display = notStarted ? 'none' : 'flex'; nodes.divider.style.display = notStarted ? 'none' : 'block'; nodes.pointsValue.textContent = notStarted ? '' : participant && typeof participant.points === 'number' ? String(participant.points) : ''; nodes.watchedValue.textContent = notStarted ? '' : fmt.watched(totalWatched); unsafeWindow.__tobiiAmbassadorBonanzaStats = { status: state.statusData, watched: state.watchedData, extracted: { startsIn: notStarted ? timeValue : null, endsIn: notStarted ? null : timeValue, points: participant && participant.points, totalWatchedMinutes: totalWatched, totalWatchedHours: typeof totalWatched === 'number' ? Number((totalWatched / 60).toFixed(2)) : null }, }; } function inspectResponse(urlLike, bodyText) { try { const { pathname } = new URL(urlLike, unsafeWindow.location.origin); const key = Object.keys(PATHS).find((name) => PATHS[name] === pathname); if (!key) return; state[key + 'Data'] = JSON.parse(bodyText); render(); } catch (_) {} } const originalFetch = unsafeWindow.fetch; unsafeWindow.fetch = function(...args) { return originalFetch.apply(this, args).then((response) => { try { response && response.url && response.clone().text().then((bodyText) => inspectResponse(response.url, bodyText)).catch(() => {}); } catch (_) {} return response; }); }; const originalXhrOpen = XMLHttpRequest.prototype.open; const originalXhrSend = XMLHttpRequest.prototype.send; XMLHttpRequest.prototype.open = function(method, url, ...rest) { this.__tobiiTrackedUrl = url; return originalXhrOpen.apply(this, [method, url, ...rest]); }; XMLHttpRequest.prototype.send = function(...args) { this.addEventListener('load', function() { typeof this.responseText === 'string' && this.__tobiiTrackedUrl && inspectResponse(this.__tobiiTrackedUrl, this.responseText); }); return originalXhrSend.apply(this, args); }; unsafeWindow.setInterval(() => !state.closed && render(), 1000); })();