Last active
June 18, 2026 10:19
-
-
Save bruvv/94c2df5c745a8be6c685854f8b56de22 to your computer and use it in GitHub Desktop.
world-cup-2026-kiosk.user.js
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==UserScript== | |
| // @name Home Assistant World Cup 2026 Kiosk | |
| // @namespace https://github.com/bruvv | |
| // @version 1.5.1 | |
| // @description Wisselt automatisch tussen World Cup 2026-tabs, verbergt ongewenste onderdelen en herstelt bij navigatieproblemen. | |
| // @author bruvv | |
| // @match *://homeassistant.local:8123/world-cup-2026* | |
| // @match *://homeassistan.local:8123/world-cup-2026* | |
| // @updateURL https://gist.githubusercontent.com/bruvv/94c2df5c745a8be6c685854f8b56de22/raw/world-cup-2026-kiosk.user.js | |
| // @downloadURL https://gist.githubusercontent.com/bruvv/94c2df5c745a8be6c685854f8b56de22/raw/world-cup-2026-kiosk.user.js | |
| // @grant none | |
| // @run-at document-start | |
| // ==/UserScript== | |
| (() => { | |
| 'use strict'; | |
| /* | |
| * Aantal seconden dat iedere tab zichtbaar blijft. | |
| */ | |
| const SWITCH_INTERVAL_SECONDS = 30; | |
| /* | |
| * Aantal seconden wachten voordat de automatische rotatie begint. | |
| */ | |
| const STARTUP_DELAY_SECONDS = 10; | |
| /* | |
| * Tabs die automatisch worden getoond. | |
| * Supporters wordt bewust overgeslagen. | |
| */ | |
| const TAB_PAGES = [ | |
| 'live', | |
| 'overview', | |
| 'teams', | |
| 'fixtures', | |
| 'results', | |
| 'groups', | |
| 'knockout', | |
| 'players', | |
| 'records', | |
| 'stats', | |
| 'venues', | |
| ]; | |
| const CONFIG = { | |
| switchIntervalMs: SWITCH_INTERVAL_SECONDS * 1000, | |
| startupDelayMs: STARTUP_DELAY_SECONDS * 1000, | |
| clickTimeoutMs: 8_000, | |
| clickRetryDelayMs: 2_000, | |
| pageHealthCheckIntervalMs: 10_000, | |
| hideElementsIntervalMs: 1_000, | |
| reloadDelayMs: 1_000, | |
| requiredFailedPageChecks: 3, | |
| }; | |
| let rotationTimer = null; | |
| let pageHealthTimer = null; | |
| let hideElementsTimer = null; | |
| let navigationRunning = false; | |
| let reloadRunning = false; | |
| let failedPageChecks = 0; | |
| function log(message, ...values) { | |
| console.log(`[World Cup kiosk] ${message}`, ...values); | |
| } | |
| function logError(message, ...values) { | |
| console.error(`[World Cup kiosk] ${message}`, ...values); | |
| } | |
| function sleep(milliseconds) { | |
| return new Promise((resolve) => { | |
| window.setTimeout(resolve, milliseconds); | |
| }); | |
| } | |
| function injectStyles() { | |
| if (document.getElementById('world-cup-kiosk-styles')) { | |
| return; | |
| } | |
| const style = document.createElement('style'); | |
| style.id = 'world-cup-kiosk-styles'; | |
| style.textContent = ` | |
| .overview-premium-strip { | |
| display: none !important; | |
| visibility: hidden !important; | |
| height: 0 !important; | |
| min-height: 0 !important; | |
| max-height: 0 !important; | |
| margin: 0 !important; | |
| padding: 0 !important; | |
| overflow: hidden !important; | |
| } | |
| .wc-nav button[data-page="supporters"] { | |
| display: none !important; | |
| visibility: hidden !important; | |
| } | |
| `; | |
| const target = document.head || document.documentElement; | |
| if (target) { | |
| target.appendChild(style); | |
| } | |
| } | |
| function collectRoots(root = document, roots = []) { | |
| if (!root || roots.includes(root)) { | |
| return roots; | |
| } | |
| roots.push(root); | |
| const elements = root.querySelectorAll?.('*') ?? []; | |
| for (const element of elements) { | |
| if (element.shadowRoot) { | |
| collectRoots(element.shadowRoot, roots); | |
| } | |
| } | |
| return roots; | |
| } | |
| function querySelectorDeep(selector) { | |
| for (const root of collectRoots()) { | |
| try { | |
| const element = root.querySelector(selector); | |
| if (element) { | |
| return element; | |
| } | |
| } catch (error) { | |
| logError(`Ongeldige selector: ${selector}`, error); | |
| } | |
| } | |
| return null; | |
| } | |
| function querySelectorAllDeep(selector) { | |
| const results = []; | |
| const seen = new Set(); | |
| for (const root of collectRoots()) { | |
| let elements; | |
| try { | |
| elements = root.querySelectorAll(selector); | |
| } catch (error) { | |
| continue; | |
| } | |
| for (const element of elements) { | |
| if (!seen.has(element)) { | |
| seen.add(element); | |
| results.push(element); | |
| } | |
| } | |
| } | |
| return results; | |
| } | |
| function hideElement(element) { | |
| if (!(element instanceof Element)) { | |
| return; | |
| } | |
| element.style.setProperty('display', 'none', 'important'); | |
| element.style.setProperty('visibility', 'hidden', 'important'); | |
| element.style.setProperty('height', '0', 'important'); | |
| element.style.setProperty('min-height', '0', 'important'); | |
| element.style.setProperty('max-height', '0', 'important'); | |
| element.style.setProperty('margin', '0', 'important'); | |
| element.style.setProperty('padding', '0', 'important'); | |
| element.style.setProperty('overflow', 'hidden', 'important'); | |
| element.hidden = true; | |
| element.setAttribute('aria-hidden', 'true'); | |
| } | |
| function hideUnwantedElements() { | |
| injectStyles(); | |
| const premiumStrips = querySelectorAllDeep( | |
| '.overview-premium-strip', | |
| ); | |
| for (const premiumStrip of premiumStrips) { | |
| hideElement(premiumStrip); | |
| } | |
| const supportersButtons = querySelectorAllDeep( | |
| '.wc-nav button[data-page="supporters"]', | |
| ); | |
| for (const supportersButton of supportersButtons) { | |
| hideElement(supportersButton); | |
| } | |
| } | |
| function startHideElementsCheck() { | |
| if (hideElementsTimer !== null) { | |
| return; | |
| } | |
| hideUnwantedElements(); | |
| hideElementsTimer = window.setInterval(() => { | |
| hideUnwantedElements(); | |
| }, CONFIG.hideElementsIntervalMs); | |
| } | |
| function isElementVisible(element) { | |
| if (!(element instanceof Element)) { | |
| return false; | |
| } | |
| const style = window.getComputedStyle(element); | |
| const rectangle = element.getBoundingClientRect(); | |
| return ( | |
| !element.hidden && | |
| style.display !== 'none' && | |
| style.visibility !== 'hidden' && | |
| style.opacity !== '0' && | |
| rectangle.width > 0 && | |
| rectangle.height > 0 | |
| ); | |
| } | |
| function findTabButton(page) { | |
| const buttons = querySelectorAllDeep( | |
| `.wc-nav button[data-page="${page}"]`, | |
| ); | |
| return ( | |
| buttons.find((button) => isElementVisible(button)) ?? | |
| buttons[0] ?? | |
| null | |
| ); | |
| } | |
| function getActivePage() { | |
| const activeButton = querySelectorDeep( | |
| '.wc-nav button.active[data-page]', | |
| ); | |
| return activeButton?.dataset?.page ?? null; | |
| } | |
| function getAvailablePages() { | |
| return querySelectorAllDeep( | |
| '.wc-nav button[data-page]', | |
| ) | |
| .map((button) => button.dataset.page) | |
| .filter(Boolean); | |
| } | |
| function isNavigationAvailable() { | |
| const availablePages = new Set(getAvailablePages()); | |
| return TAB_PAGES.every((page) => { | |
| return availablePages.has(page); | |
| }); | |
| } | |
| function stopRotation() { | |
| if (rotationTimer !== null) { | |
| window.clearTimeout(rotationTimer); | |
| rotationTimer = null; | |
| log('Automatische rotatie gestopt'); | |
| } | |
| } | |
| function scheduleNextRotation() { | |
| stopRotation(); | |
| if (reloadRunning) { | |
| return; | |
| } | |
| rotationTimer = window.setTimeout(() => { | |
| void rotateToNextTab(); | |
| }, CONFIG.switchIntervalMs); | |
| } | |
| function waitForActivePage(expectedPage) { | |
| return new Promise((resolve) => { | |
| const startedAt = Date.now(); | |
| const interval = window.setInterval(() => { | |
| const activePage = getActivePage(); | |
| if (activePage === expectedPage) { | |
| window.clearInterval(interval); | |
| resolve(true); | |
| return; | |
| } | |
| if ( | |
| Date.now() - startedAt >= | |
| CONFIG.clickTimeoutMs | |
| ) { | |
| window.clearInterval(interval); | |
| resolve(false); | |
| } | |
| }, 100); | |
| }); | |
| } | |
| async function clickTab(page) { | |
| let button = findTabButton(page); | |
| if (!button) { | |
| logError(`Tabknop niet gevonden: ${page}`); | |
| return false; | |
| } | |
| if (getActivePage() === page) { | |
| return true; | |
| } | |
| try { | |
| button.scrollIntoView({ | |
| block: 'nearest', | |
| inline: 'nearest', | |
| }); | |
| button.click(); | |
| } catch (error) { | |
| logError(`Klikken op ${page} gaf een fout`, error); | |
| return false; | |
| } | |
| let succeeded = await waitForActivePage(page); | |
| if (succeeded) { | |
| log(`Tab geopend: ${page}`); | |
| hideUnwantedElements(); | |
| return true; | |
| } | |
| logError( | |
| `Eerste klik op ${page} werkte niet, opnieuw proberen`, | |
| ); | |
| await sleep(CONFIG.clickRetryDelayMs); | |
| button = findTabButton(page); | |
| if (!button) { | |
| return false; | |
| } | |
| try { | |
| button.dispatchEvent( | |
| new MouseEvent('click', { | |
| bubbles: true, | |
| cancelable: true, | |
| view: window, | |
| }), | |
| ); | |
| } catch (error) { | |
| logError(`Tweede klik op ${page} gaf een fout`, error); | |
| return false; | |
| } | |
| succeeded = await waitForActivePage(page); | |
| if (succeeded) { | |
| log(`Tab na tweede poging geopend: ${page}`); | |
| hideUnwantedElements(); | |
| } | |
| return succeeded; | |
| } | |
| async function rotateToNextTab() { | |
| if (navigationRunning || reloadRunning) { | |
| return; | |
| } | |
| navigationRunning = true; | |
| try { | |
| hideUnwantedElements(); | |
| const activePage = getActivePage(); | |
| const activeIndex = TAB_PAGES.indexOf(activePage); | |
| const nextIndex = | |
| activeIndex >= 0 | |
| ? (activeIndex + 1) % TAB_PAGES.length | |
| : 0; | |
| const nextPage = TAB_PAGES[nextIndex]; | |
| const succeeded = await clickTab(nextPage); | |
| if (!succeeded) { | |
| await reloadPage( | |
| `tab ${nextPage} kon niet worden geopend`, | |
| ); | |
| return; | |
| } | |
| scheduleNextRotation(); | |
| } finally { | |
| navigationRunning = false; | |
| } | |
| } | |
| async function reloadPage(reason) { | |
| if (reloadRunning) { | |
| return; | |
| } | |
| reloadRunning = true; | |
| stopRotation(); | |
| logError(`Volledige pagina wordt herladen: ${reason}`); | |
| await sleep(CONFIG.reloadDelayMs); | |
| window.location.reload(); | |
| } | |
| function runPageHealthCheck() { | |
| hideUnwantedElements(); | |
| if (reloadRunning) { | |
| return; | |
| } | |
| if (isNavigationAvailable()) { | |
| failedPageChecks = 0; | |
| if ( | |
| rotationTimer === null && | |
| !navigationRunning | |
| ) { | |
| scheduleNextRotation(); | |
| } | |
| return; | |
| } | |
| failedPageChecks += 1; | |
| logError( | |
| `Navigatiecontrole mislukt: ${failedPageChecks}/${CONFIG.requiredFailedPageChecks}`, | |
| ); | |
| if ( | |
| failedPageChecks >= | |
| CONFIG.requiredFailedPageChecks | |
| ) { | |
| void reloadPage( | |
| 'de World Cup-navigatie is niet beschikbaar', | |
| ); | |
| } | |
| } | |
| function startPageHealthCheck() { | |
| if (pageHealthTimer !== null) { | |
| return; | |
| } | |
| pageHealthTimer = window.setInterval(() => { | |
| runPageHealthCheck(); | |
| }, CONFIG.pageHealthCheckIntervalMs); | |
| } | |
| window.addEventListener('pageshow', () => { | |
| failedPageChecks = 0; | |
| reloadRunning = false; | |
| hideUnwantedElements(); | |
| }); | |
| injectStyles(); | |
| startHideElementsCheck(); | |
| startPageHealthCheck(); | |
| window.setTimeout(() => { | |
| hideUnwantedElements(); | |
| if (!isNavigationAvailable()) { | |
| void reloadPage( | |
| 'de World Cup-navigatie werd niet geladen', | |
| ); | |
| return; | |
| } | |
| log( | |
| `Automatische rotatie gestart met ${SWITCH_INTERVAL_SECONDS} seconden per tab`, | |
| ); | |
| scheduleNextRotation(); | |
| }, CONFIG.startupDelayMs); | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment