diff --git a/src/index.ts b/src/index.ts index f559c8a..7b19632 100644 --- a/src/index.ts +++ b/src/index.ts @@ -22,7 +22,6 @@ import { VibrationManager } from "@modules/vibration-manager"; import { overridePreloadState } from "@utils/preload-state"; import { disableAdobeAudienceManager, patchAudioContext, patchCanvasContext, patchMeControl, patchPointerLockApi, patchRtcCodecs, patchRtcPeerConnection, patchVideoApi } from "@utils/monkey-patches"; import { AppInterface, STATES } from "@utils/global"; -import { injectStreamMenuButtons } from "@modules/stream/stream-ui"; import { BxLogger } from "@utils/bx-logger"; import { GameBar } from "./modules/game-bar/game-bar"; import { Screenshot } from "./utils/screenshot"; @@ -38,6 +37,7 @@ import { PrefKey } from "./enums/pref-keys"; import { getPref } from "./utils/settings-storages/global-settings-storage"; import { compressCss } from "@macros/build" with {type: "macro"}; import { SettingsNavigationDialog } from "./modules/ui/dialog/settings-dialog"; +import { StreamUiHandler } from "./modules/stream/stream-ui"; // Handle login page @@ -186,7 +186,7 @@ window.addEventListener(BxEvent.STREAM_STARTING, e => { window.addEventListener(BxEvent.STREAM_PLAYING, e => { STATES.isPlaying = true; - injectStreamMenuButtons(); + StreamUiHandler.observe(); if (getPref(PrefKey.GAME_BAR_POSITION) !== 'off') { const gameBar = GameBar.getInstance(); diff --git a/src/modules/stream/stream-ui.ts b/src/modules/stream/stream-ui.ts index f63d54c..03371f1 100644 --- a/src/modules/stream/stream-ui.ts +++ b/src/modules/stream/stream-ui.ts @@ -8,212 +8,260 @@ import { StreamStats } from "./stream-stats.ts"; import { SettingsNavigationDialog } from "../ui/dialog/settings-dialog.ts"; -function cloneStreamHudButton($orgButton: HTMLElement, label: string, svgIcon: typeof BxIcon) { - const $container = $orgButton.cloneNode(true) as HTMLElement; - let timeout: number | null; +export class StreamUiHandler { + private static $btnStreamSettings: HTMLElement | null | undefined; + private static $btnStreamStats: HTMLElement | null | undefined; + private static $btnRefresh: HTMLElement | null | undefined; + private static $btnHome: HTMLElement | null | undefined; + private static observer: MutationObserver | undefined; - const onTransitionStart = (e: TransitionEvent) => { - if (e.propertyName !== 'opacity') { + private static cloneStreamHudButton($btnOrg: HTMLElement, label: string, svgIcon: typeof BxIcon): HTMLElement | null { + const $streamHud = document.getElementById('StreamHud') as HTMLElement; + if (!$streamHud || !$btnOrg) { + return null; + } + + const $container = $btnOrg.cloneNode(true) as HTMLElement; + let timeout: number | null; + + // Prevent touching other button while the bar is showing/hiding + if (STATES.browser.capabilities.touch) { + const onTransitionStart = (e: TransitionEvent) => { + if (e.propertyName !== 'opacity') { + return; + } + + timeout && clearTimeout(timeout); + $container.style.pointerEvents = 'none'; + }; + + const onTransitionEnd = (e: TransitionEvent) => { + if (e.propertyName !== 'opacity') { + return; + } + + const left = $streamHud.style.left; + if (left === '0px') { + timeout && clearTimeout(timeout); + timeout = window.setTimeout(() => { + $container.style.pointerEvents = 'auto'; + }, 100); + } + }; + + $container.addEventListener('transitionstart', onTransitionStart); + $container.addEventListener('transitionend', onTransitionEnd); + } + + const $button = $container.querySelector('button') as HTMLElement; + if (!$button) { + return null; + } + $button.setAttribute('title', label); + + const $orgSvg = $button.querySelector('svg') as SVGElement; + if (!$orgSvg) { + return null; + } + + const $svg = createSvgIcon(svgIcon); + $svg.style.fill = 'none'; + $svg.setAttribute('class', $orgSvg.getAttribute('class') || ''); + $svg.ariaHidden = 'true'; + + $orgSvg.replaceWith($svg); + return $container; + } + + private static cloneCloseButton($btnOrg: HTMLElement, icon: typeof BxIcon, className: string, onChange: any): HTMLElement | null { + if (!$btnOrg) { + return null; + } + // Create button from the Close button + const $btn = $btnOrg.cloneNode(true) as HTMLElement; + + const $svg = createSvgIcon(icon); + // Copy classes + $svg.setAttribute('class', $btn.firstElementChild!.getAttribute('class') || ''); + $svg.style.fill = 'none'; + + $btn.classList.add(className); + // Remove icon + $btn.removeChild($btn.firstElementChild!); + // Add icon + $btn.appendChild($svg); + // Add "click" event listener + $btn.addEventListener('click', onChange); + + return $btn; + } + + private static async handleStreamMenu() { + const $btnCloseHud = document.querySelector('button[class*=StreamMenu-module__backButton]') as HTMLElement; + if (!$btnCloseHud) { return; } - timeout && clearTimeout(timeout); - $container.style.pointerEvents = 'none'; - }; + let $btnRefresh = StreamUiHandler.$btnRefresh; + let $btnHome = StreamUiHandler.$btnHome; - const onTransitionEnd = (e: TransitionEvent) => { - if (e.propertyName !== 'opacity') { - return; + // Create Refresh button from the Close button + if (typeof $btnRefresh === 'undefined') { + $btnRefresh = StreamUiHandler.cloneCloseButton($btnCloseHud, BxIcon.REFRESH, 'bx-stream-refresh-button', () => { + confirm(t('confirm-reload-stream')) && window.location.reload(); + }); } - const left = document.getElementById('StreamHud')?.style.left; - if (left === '0px') { - timeout && clearTimeout(timeout); - timeout = window.setTimeout(() => { - $container.style.pointerEvents = 'auto'; - }, 100); + if (typeof $btnHome === 'undefined') { + $btnHome = StreamUiHandler.cloneCloseButton($btnCloseHud, BxIcon.HOME, 'bx-stream-home-button', () => { + confirm(t('back-to-home-confirm')) && (window.location.href = window.location.href.substring(0, 31)); + }); } - }; - if (STATES.browser.capabilities.touch) { - $container.addEventListener('transitionstart', onTransitionStart); - $container.addEventListener('transitionend', onTransitionEnd); + // Add to website + if ($btnRefresh && $btnHome) { + $btnCloseHud.insertAdjacentElement('afterend', $btnRefresh); + $btnRefresh.insertAdjacentElement('afterend', $btnHome); + } + + // Render stream badges + const $menu = document.querySelector('div[class*=StreamMenu-module__menuContainer] > div[class*=Menu-module]'); + $menu?.appendChild(await StreamBadges.getInstance().render()); } - const $button = $container.querySelector('button')!; - $button.setAttribute('title', label); + private static handleSystemMenu($streamHud: HTMLElement) { + const streamStats = StreamStats.getInstance(); - const $orgSvg = $button.querySelector('svg')!; - const $svg = createSvgIcon(svgIcon); - $svg.style.fill = 'none'; - $svg.setAttribute('class', $orgSvg.getAttribute('class') || ''); - $svg.ariaHidden = 'true'; + // Grip handle + const $gripHandle = $streamHud.querySelector('button[class^=GripHandle]') as HTMLElement; - $orgSvg.replaceWith($svg); - return $container; -} - - -function cloneCloseButton($$btnOrg: HTMLElement, icon: typeof BxIcon, className: string, onChange: any) { - // Create button from the Close button - const $btn = $$btnOrg.cloneNode(true) as HTMLElement; - - // Refresh SVG - const $svg = createSvgIcon(icon); - // Copy classes - $svg.setAttribute('class', $btn.firstElementChild!.getAttribute('class') || ''); - $svg.style.fill = 'none'; - - $btn.classList.add(className); - // Remove icon - $btn.removeChild($btn.firstElementChild!); - // Add icon - $btn.appendChild($svg); - // Add "click" event listener - $btn.addEventListener('click', onChange); - - return $btn; -} - - -export function injectStreamMenuButtons() { - const $screen = document.querySelector('#PageContent section[class*=PureScreens]'); - if (!$screen) { - return; - } - - if (($screen as any).xObserving) { - return; - } - - ($screen as any).xObserving = true; - - let $btnStreamSettings: HTMLElement; - let $btnStreamStats: HTMLElement; - const streamStats = StreamStats.getInstance(); - - const observer = new MutationObserver(mutationList => { - mutationList.forEach(item => { - if (item.type !== 'childList') { + const hideGripHandle = () => { + if (!$gripHandle) { return; } - item.addedNodes.forEach(async $node => { - if (!$node || $node.nodeType !== Node.ELEMENT_NODE) { - return; - } + $gripHandle.dispatchEvent(new PointerEvent('pointerdown')); + $gripHandle.click(); + $gripHandle.dispatchEvent(new PointerEvent('pointerdown')); + $gripHandle.click(); + } - let $elm: HTMLElement | null = $node as HTMLElement; + // Get the last button + const $orgButton = $streamHud.querySelector('div[class^=HUDButton]') as HTMLElement; + if (!$orgButton) { + return; + } - // Ignore SVG elements - if ($elm instanceof SVGSVGElement) { - return; - } + // Create Stream Settings button + let $btnStreamSettings = StreamUiHandler.$btnStreamSettings; + if (typeof $btnStreamSettings === 'undefined') { + $btnStreamSettings = StreamUiHandler.cloneStreamHudButton($orgButton, t('better-xcloud'), BxIcon.BETTER_XCLOUD); + $btnStreamSettings?.addEventListener('click', e => { + hideGripHandle(); + e.preventDefault(); - // Error Page: .PureErrorPage.ErrorScreen - if ($elm.className?.includes('PureErrorPage')) { - BxEvent.dispatch(window, BxEvent.STREAM_ERROR_PAGE); - return; - } + // Show Stream Settings dialog + SettingsNavigationDialog.getInstance().show(); + }); + } - // Render badges - if ($elm.className?.startsWith('StreamMenu-module__container')) { - const $btnCloseHud = document.querySelector('button[class*=StreamMenu-module__backButton]') as HTMLElement; - if (!$btnCloseHud) { - return; - } + // Create Stream Stats button + let $btnStreamStats = StreamUiHandler.$btnStreamStats; + if (typeof $btnStreamStats === 'undefined') { + $btnStreamStats = StreamUiHandler.cloneStreamHudButton($orgButton, t('stream-stats'), BxIcon.STREAM_STATS); + $btnStreamStats?.addEventListener('click', e => { + hideGripHandle(); + e.preventDefault(); - // Create Refresh button from the Close button - const $btnRefresh = cloneCloseButton($btnCloseHud, BxIcon.REFRESH, 'bx-stream-refresh-button', () => { - confirm(t('confirm-reload-stream')) && window.location.reload(); - }); - - const $btnHome = cloneCloseButton($btnCloseHud, BxIcon.HOME, 'bx-stream-home-button', () => { - confirm(t('back-to-home-confirm')) && (window.location.href = window.location.href.substring(0, 31)); - }); - - // Add to website - $btnCloseHud.insertAdjacentElement('afterend', $btnRefresh); - $btnRefresh.insertAdjacentElement('afterend', $btnHome); - - // Render stream badges - const $menu = document.querySelector('div[class*=StreamMenu-module__menuContainer] > div[class*=Menu-module]'); - $menu?.appendChild(await StreamBadges.getInstance().render()); - - return; - } - - if ($elm.className?.startsWith('Overlay-module_') || $elm.className?.startsWith('InProgressScreen')) { - $elm = $elm.querySelector('#StreamHud'); - } - - if (!$elm || ($elm.id || '') !== 'StreamHud') { - return; - } - - // Grip handle - const $gripHandle = $elm.querySelector('button[class^=GripHandle]') as HTMLElement; - - const hideGripHandle = () => { - if (!$gripHandle) { - return; - } - - $gripHandle.dispatchEvent(new PointerEvent('pointerdown')); - $gripHandle.click(); - $gripHandle.dispatchEvent(new PointerEvent('pointerdown')); - $gripHandle.click(); - } - - // Get the second last button - const $orgButton = $elm.querySelector('div[class^=HUDButton]') as HTMLElement; - if (!$orgButton) { - return; - } - - // Create Stream Settings button - if (!$btnStreamSettings) { - $btnStreamSettings = cloneStreamHudButton($orgButton, t('better-xcloud'), BxIcon.BETTER_XCLOUD); - $btnStreamSettings.addEventListener('click', e => { - hideGripHandle(); - e.preventDefault(); - - // Show Stream Settings dialog - SettingsNavigationDialog.getInstance().show(); - }); - } - - // Create Stream Stats button - if (!$btnStreamStats) { - $btnStreamStats = cloneStreamHudButton($orgButton, t('stream-stats'), BxIcon.STREAM_STATS); - $btnStreamStats.addEventListener('click', e => { - hideGripHandle(); - e.preventDefault(); - - // Toggle Stream Stats - streamStats.toggle(); - - const btnStreamStatsOn = (!streamStats.isHidden() && !streamStats.isGlancing()); - $btnStreamStats.classList.toggle('bx-stream-menu-button-on', btnStreamStatsOn); - }); - } + // Toggle Stream Stats + streamStats.toggle(); const btnStreamStatsOn = (!streamStats.isHidden() && !streamStats.isGlancing()); - $btnStreamStats.classList.toggle('bx-stream-menu-button-on', btnStreamStatsOn); + $btnStreamStats!.classList.toggle('bx-stream-menu-button-on', btnStreamStatsOn); + }); + } - if ($orgButton) { - const $btnParent = $orgButton.parentElement!; + const $btnParent = $orgButton.parentElement!; - // Insert buttons after Stream Settings button - $btnParent.insertBefore($btnStreamStats, $btnParent.lastElementChild); - $btnParent.insertBefore($btnStreamSettings, $btnStreamStats); + if ($btnStreamSettings && $btnStreamStats) { + const btnStreamStatsOn = (!streamStats.isHidden() && !streamStats.isGlancing()); + $btnStreamStats.classList.toggle('bx-stream-menu-button-on', btnStreamStatsOn); - // Move the Dots button to the beginning - const $dotsButton = $btnParent.lastElementChild!; - $dotsButton.parentElement!.insertBefore($dotsButton, $dotsButton.parentElement!.firstElementChild); + // Insert buttons after Stream Settings button + $btnParent.insertBefore($btnStreamStats, $btnParent.lastElementChild); + $btnParent.insertBefore($btnStreamSettings, $btnStreamStats); + } + + // Move the Dots button to the beginning + const $dotsButton = $btnParent.lastElementChild!; + $dotsButton.parentElement!.insertBefore($dotsButton, $dotsButton.parentElement!.firstElementChild); + } + + private static reset() { + StreamUiHandler.$btnStreamSettings = undefined; + StreamUiHandler.$btnStreamStats = undefined; + StreamUiHandler.$btnRefresh = undefined; + StreamUiHandler.$btnHome = undefined; + + StreamUiHandler.observer && StreamUiHandler.observer.disconnect(); + StreamUiHandler.observer = undefined; + } + + static observe() { + StreamUiHandler.reset(); + + const $screen = document.querySelector('#PageContent section[class*=PureScreens]'); + if (!$screen) { + return; + } + + console.log('StreamUI', 'observing'); + const observer = new MutationObserver(mutationList => { + mutationList.forEach(item => { + if (item.type !== 'childList') { + return; } + + item.addedNodes.forEach(async $node => { + if (!$node || $node.nodeType !== Node.ELEMENT_NODE) { + return; + } + + let $elm: HTMLElement | null = $node as HTMLElement; + + // Ignore non-HTML elements + if (!($elm instanceof HTMLElement)) { + return; + } + + const className = $elm.className || ''; + + // Error Page: .PureErrorPage.ErrorScreen + if (className.includes('PureErrorPage')) { + BxEvent.dispatch(window, BxEvent.STREAM_ERROR_PAGE); + return; + } + + // Render badges + if (className.startsWith('StreamMenu-module__container')) { + StreamUiHandler.handleStreamMenu(); + return; + } + + if (className.startsWith('Overlay-module_') || className.startsWith('InProgressScreen')) { + $elm = $elm.querySelector('#StreamHud'); + } + + if (!$elm || ($elm.id || '') !== 'StreamHud') { + return; + } + + // Handle System Menu bar + StreamUiHandler.handleSystemMenu($elm); + }); }); }); - }); - observer.observe($screen, {subtree: true, childList: true}); + + observer.observe($screen, {subtree: true, childList: true}); + } }