ultrawidify/src/ext/lib/uwui/UI.js

541 lines
17 KiB
JavaScript
Raw Normal View History

2025-04-22 02:37:36 +02:00
import ExtensionMode from '../../../common/enums/ExtensionMode.enum';
2024-06-05 01:08:50 +02:00
import { EventBusConnector } from '../EventBus';
2020-12-03 01:35:22 +01:00
if (process.env.CHANNEL !== 'stable'){
console.info("Loading: UI");
}
2024-06-05 01:08:50 +02:00
const csuiVersions = {
'normal': 'csui', // csui-overlay-normal.html, maps to csui.html
2025-01-25 21:08:09 +01:00
'light': 'csui-light', // csui-overlay-light.html, maps to csui-light.html
'dark': 'csui-dark' // csui-overlay-dark.html, maps to csui-dark.html
2024-06-05 01:08:50 +02:00
};
const MAX_IFRAME_ERROR_COUNT = 5;
class UI {
constructor(
interfaceId,
uiConfig, // {parentElement?, eventBus?, isGlobal?, playerData}
) {
this.interfaceId = interfaceId;
2020-12-03 01:35:22 +01:00
this.uiConfig = uiConfig;
this.lastProbeResponseTs = null;
this.isGlobal = uiConfig.isGlobal ?? false;
this.isIframe = window.self !== window.top;
2020-12-03 01:35:22 +01:00
this.eventBus = uiConfig.eventBus;
2024-06-05 01:08:50 +02:00
this.disablePointerEvents = false;
this.saveState = undefined;
this.playerData = uiConfig.playerData;
this.uiSettings = uiConfig.uiSettings;
2025-04-22 02:37:36 +02:00
this.siteSettings = uiConfig.siteSettings;
this.iframeErrorCount = 0;
this.iframeConfirmed = false;
2025-01-06 03:05:18 +01:00
this.iframeRejected = false;
2025-01-25 20:51:22 +01:00
this.delayedDestroyTimer = null;
2025-01-25 20:51:22 +01:00
// TODO: at some point, UI should be different for global popup and in-player UI
this.csuiScheme = this.getCsuiScheme();
const csuiVersion = this.getCsuiVersion(this.csuiScheme.contentScheme);
this.uiURI = chrome.runtime.getURL(`/csui/${csuiVersion}.html`);
this.extensionBase = chrome.runtime.getURL('').replace(/\/$/, "");
// UI will be initialized when setUiVisibility is called
2025-04-22 02:37:36 +02:00
console.log('ui config:', uiConfig);
this.init();
}
2025-04-22 02:37:36 +02:00
canRun() {
if (this.isGlobal) {
return true;
}
return this.siteSettings?.data.enableUI.fullscreen === ExtensionMode.Enabled
|| this.siteSettings?.data.enableUI.theater === ExtensionMode.Enabled
|| this.siteSettings?.data.enableUI.normal === ExtensionMode.Enabled;
}
async init() {
2025-04-22 02:37:36 +02:00
if (!this.canRun()) {
console.log('ui config: canRun returned false', this.siteSettings?.data.enableUI.fullscreen === ExtensionMode.Enabled, this.siteSettings?.data.enableUI.theater === ExtensionMode.Enabled, this.siteSettings?.data.enableUI.normal === ExtensionMode.Enabled)
return;
}
console.log('ui config: canRun returned truie', this.siteSettings?.data.enableUI.fullscreen === ExtensionMode.Enabled, this.siteSettings?.data.enableUI.theater === ExtensionMode.Enabled, this.siteSettings?.data.enableUI.normal === ExtensionMode.Enabled)
this.initUIContainer();
this.loadIframe();
2024-06-05 01:08:50 +02:00
this.initMessaging();
}
2025-01-25 20:51:22 +01:00
/**
* Returns color scheme we need to use.
*
* contentScheme is used to select the correct HTML template.
* iframeScheme gets applied to the iframe as style
* @returns {contentScheme: string, iframeScheme: string}
*/
getCsuiScheme() {
return {
contentScheme: window.getComputedStyle( document.body ,null).getPropertyValue('color-scheme'),
iframeScheme: document.documentElement.style.colorScheme || document.body.style.colorScheme || undefined
};
}
/**
* Returns correct template for given preferredScheme parameter
* @param {*} preferredScheme
* @returns
*/
getCsuiVersion(preferredScheme) {
return csuiVersions[preferredScheme] ?? csuiVersions.normal;
}
initUIContainer() {
const random = Math.round(Math.random() * 69420);
const uwid = `uw-ultrawidify-${this.interfaceId}-root-${random}`
const rootDiv = document.createElement('div');
if (this.uiConfig.additionalStyle) {
rootDiv.setAttribute('style', this.uiConfig.additionalStyle);
}
2020-12-05 03:30:43 +01:00
rootDiv.setAttribute('id', uwid);
rootDiv.classList.add('uw-ultrawidify-container-root');
rootDiv.style.width = "100%";
rootDiv.style.height = "100%";
2024-06-05 01:08:50 +02:00
rootDiv.style.position = this.isGlobal ? "fixed" : "absolute";
rootDiv.style.zIndex = this.isGlobal ? '90009' : '90000';
2022-06-14 00:26:59 +02:00
rootDiv.style.border = 0;
rootDiv.style.top = 0;
2022-06-15 00:53:43 +02:00
rootDiv.style.pointerEvents = 'none';
2020-12-04 02:02:25 +01:00
if (this.uiConfig?.parentElement) {
this.uiConfig.parentElement.appendChild(rootDiv);
} else {
document.body.appendChild(rootDiv);
}
this.rootDiv = rootDiv;
}
loadIframe() {
// in onMouseMove, we currently can't access this because we didn't
// do things the most properly
const uiURI = this.uiURI;
const iframe = document.createElement('iframe');
iframe.setAttribute('src', uiURI);
2024-06-05 01:08:50 +02:00
iframe.setAttribute("allowTransparency", 'true');
// iframe.style.width = "100%";
// iframe.style.height = "100%";
iframe.style.position = "absolute";
2024-06-05 01:08:50 +02:00
iframe.style.zIndex = this.isGlobal ? '90009' : '90000';
iframe.style.border = 0;
iframe.style.pointerEvents = 'none';
iframe.style.opacity = 0;
2024-06-05 01:08:50 +02:00
iframe.style.backgroundColor = 'transparent !important';
2025-01-25 20:51:22 +01:00
// If colorScheme is defined via CSS on the HTML or BODY elements, then we need to also
// put a matching style to the iframe itself. Using the correct UI template is not enough.
if (this.csuiScheme.iframeScheme) {
iframe.style.colorScheme = this.csuiScheme.iframeScheme;
}
/* so we have a problem: we want iframe to be clickthrough everywhere except
* on our actual overlay. There's no nice way of doing that, so we need some
* extra javascript to deal with this.
*
* There's a second problem: while iframe is in clickable mode, onMouseMove
* will not work (as all events will be hijacked by the iframe). This means
* that iframe also needs to run its own instance of onMouseMove.
*/
2023-07-11 00:48:34 +02:00
// set uiIframe for handleMessage
this.uiIframe = iframe;
2024-06-05 01:08:50 +02:00
// set not visible by default
// this.setUiVisibility(false);
2024-06-05 01:08:50 +02:00
2023-07-11 00:48:34 +02:00
const fn = (event) => {
// remove self on fucky wuckies
if (!iframe?.contentWindow ) {
document.removeEventListener('mousemove', fn, true);
return;
}
2024-12-26 17:41:00 +01:00
const rect = this.uiIframe.getBoundingClientRect();
2024-12-27 23:12:41 +01:00
2024-12-26 17:41:00 +01:00
const offsets = {
top: window.scrollY + rect.top,
left: window.scrollX + rect.left
};
2023-07-11 00:48:34 +02:00
const coords = {
2024-12-26 17:41:00 +01:00
x: event.pageX - offsets.left,
y: event.pageY - offsets.top,
frameOffset: offsets,
2023-07-11 00:48:34 +02:00
};
const playerData = this.canShowUI(coords);
2023-07-11 00:48:34 +02:00
// ask the iframe to check whether there's a clickable element
this.uiIframe.contentWindow.postMessage(
{
action: 'uwui-probe',
coords,
playerDimensions: playerData.playerDimensions,
canShowUI: playerData.canShowUI,
ts: +new Date() // this should be accurate enough for our purposes,
2023-07-11 00:48:34 +02:00
},
uiURI
);
}
2024-06-05 01:08:50 +02:00
// NOTE: you cannot communicate with UI iframe inside onload function.
// onload triggers after iframe is initialized, but BEFORE vue finishes
// setting up all the components.
// If we need to have any data inside the vue component, we need to
// request that data from vue components.
iframe.onload = function() {
2023-07-11 00:48:34 +02:00
document.addEventListener('mousemove', fn, true);
2020-12-04 02:02:25 +01:00
}
this.eventBus.forwardToIframe(
this.uiIframe,
(action, payload) => {
this.sendToIframe(action, payload, {})
}
);
this.rootDiv.appendChild(iframe);
}
unloadIframe() {
this.eventBus.cancelIframeForwarding(this.uiIframe);
window.removeEventListener('message', this.messageHandlerFn);
this.uiIframe?.remove();
delete this.uiIframe;
2024-06-05 01:08:50 +02:00
}
2024-06-05 01:08:50 +02:00
initMessaging() {
2023-07-11 00:48:34 +02:00
// subscribe to events coming back to us. Unsubscribe if iframe vanishes.
window.addEventListener('message', this.messageHandlerFn);
/* set up event bus tunnel from content script to UI necessary if we want to receive
* like current zoom levels & current aspect ratio & stuff. Some of these things are
* necessary for UI display in the popup.
*/
this.eventBus.subscribeMulti(
{
'uw-config-broadcast': {
function: (config, routingData) => {
this.sendToIframe('uw-config-broadcast', config, routingData);
}
},
'uw-set-ui-state': {
function: (config, routingData) => {
if (config.globalUiVisible !== undefined && !this.isIframe) {
if (this.isGlobal) {
this.setUiVisibility(config.globalUiVisible);
} else {
this.setUiVisibility(!config.globalUiVisible);
}
2024-06-05 01:08:50 +02:00
}
this.sendToIframe('uw-set-ui-state', {...config, isGlobal: this.isGlobal}, routingData);
2024-06-05 01:08:50 +02:00
}
},
'uw-get-page-stats': {
function: (config, routingData) => {
console.log('got get page stats!');
this.eventBus.send(
'uw-page-stats',
{
pcsDark: window.matchMedia('(prefers-color-scheme: dark)').matches,
pcsLight: window.matchMedia('(prefers-color-scheme: light)').matches,
colorScheme: window.getComputedStyle( document.body ,null).getPropertyValue('color-scheme')
},
{
comms: {
forwardTo: 'popup'
}
}
);
}
},
'uw-restore-ui-state': {
function: (config, routingData) => {
if (!this.isGlobal) {
this.setUiVisibility(true);
this.sendToIframe('uw-restore-ui-state', config, routingData);
}
2024-06-05 01:08:50 +02:00
}
}
},
this
);
}
messageHandlerFn = (message) => {
if (!this.uiIframe?.contentWindow) {
window.removeEventListener('message', this.messageHandlerFn);
return;
}
this.handleMessage(message);
2022-03-20 20:40:11 +01:00
}
2024-06-05 01:08:50 +02:00
setUiVisibility(visible) {
// console.log('uwui - setting ui visibility!', visible, this.isGlobal ? 'global' : 'page', this.uiIframe, this.rootDiv);
// if (!this.uiIframe || !this.rootDiv) {
// this.init();
// }
2024-06-05 01:08:50 +02:00
if (visible) {
this.rootDiv.style.width = '100%';
this.rootDiv.style.height = '100%';
2024-06-05 01:08:50 +02:00
this.uiIframe.style.width = '100%';
this.uiIframe.style.height = '100%';
// if (this.delayedDestroyTimer) {
// clearTimeout(this.delayedDestroyTimer);
// }
2024-06-05 01:08:50 +02:00
} else {
this.rootDiv.style.width = '0px';
this.rootDiv.style.height = '0px';
2024-06-05 01:08:50 +02:00
this.uiIframe.style.width = '0px';
this.uiIframe.style.height = '0px';
// destroy after 30 seconds of UI being hidden
// this.delayedDestroyTimer = setTimeout( () => this.unloadIframe(), 30000);
2024-06-05 01:08:50 +02:00
}
}
2023-07-11 00:48:34 +02:00
async enable() {
// if root element is not present, we need to init the UI.
if (!this.rootDiv) {
2023-07-11 00:48:34 +02:00
await this.init();
}
// otherwise, we don't have to do anything
}
disable() {
if (this.rootDiv) {
2023-07-11 00:48:34 +02:00
this.destroy();
}
}
/**
* Checks whether mouse is moving over either:
* * <video> element
* * player element ()
* * uwui-clickable element
*/
2024-12-27 23:12:41 +01:00
canShowUI() {
const playerCssClass = 'uw-ultrawidify-player-css';
const result = {
playerDimensions: undefined,
2024-12-27 23:12:41 +01:00
canShowUI: false,
}
2025-04-22 02:37:36 +02:00
if (this.playerData?.environment && this.siteSettings.data.enableUI[this.playerData?.environment] !== ExtensionMode.Enabled) {
return result;
}
if (this.playerData?.dimensions) {
result.playerDimensions = this.playerData.dimensions;
}
// if player is not wide enough, we do nothing
if (
!this.isGlobal && // this.isGlobal is basically 'yes, do as I say'
!document.fullscreenElement && // if we are in full screen, we allow it in every case as player detection is not 100% reliable,
result.playerDimensions?.width && // which makes playerDimensions.width unreliable as well (we assume nobody uses browser in
// fullscreen mode unless watching videos)
result.playerDimensions.width < window.screen.width * (this.uiSettings.inPlayer.minEnabledWidth ?? 0)
) {
return result;
}
2024-12-27 23:12:41 +01:00
result.canShowUI = true;
return result;
}
/**
* Handles events received from the iframe.
* @param {*} event
*/
handleMessage(event) {
if (event.origin === this.extensionBase) {
2024-06-05 01:08:50 +02:00
switch(event.data.action) {
case 'uwui-clickable':
if (event.data.ts < this.lastProbeResponseTs) {
return;
}
if (this.disablePointerEvents) {
return;
}
this.lastProbeResponseTs = event.data.ts;
// If iframe returns 'yes, we are clickable' and iframe is currently set to pointerEvents=auto,
// but hasMouse is false, then UI is attached to the wrong element. This probably means our
// detected player element is wrong. We need to perform this check if we aren't in global UI
/**
* action: 'uwui-clickable',
* clickable: isClickable,
* hoverStats: {
* isOverTriggerZone,
* isOverMenuTrigger,
* isOverUIArea,
* hasMouse: !!document.querySelector(':hover'),
* },
* ts: +new Date()
*/
if (!this.global) {
if (
this.uiIframe.style.pointerEvents === 'auto'
) {
if (
2025-01-06 03:05:18 +01:00
event.data.hoverStats.isOverMenuTrigger
&& !event.data.hoverStats.hasMouse
) {
if (!this.iframeConfirmed) {
2025-01-06 03:05:18 +01:00
if (this.iframeErrorCount++ > MAX_IFRAME_ERROR_COUNT && !this.iframeRejected) {
this.iframeRejected = true;
this.eventBus.send('change-player-element');
return;
}
}
2025-01-06 03:05:18 +01:00
} else if (event.data.hoverStats.isOverMenuTrigger && event.data.hoverStats.hasMouse) {
this.iframeConfirmed = true;
}
}
}
2024-06-05 01:08:50 +02:00
this.uiIframe.style.pointerEvents = event.data.clickable ? 'auto' : 'none';
this.uiIframe.style.opacity = event.data.opacity || this.isGlobal ? '100' : '0';
2024-06-05 01:08:50 +02:00
break;
case 'uw-bus-tunnel':
const busCommand = event.data.payload;
this.eventBus.send(
busCommand.action,
busCommand.config,
{
...busCommand?.context,
borderCrossings: {
...busCommand?.context?.borderCrossings,
iframe: true,
}
}
);
2024-06-05 01:08:50 +02:00
break;
case 'uwui-get-role':
this.sendToIframeLowLevel('uwui-set-role', {role: this.isGlobal ? 'global' : 'player'});
break;
case 'uwui-interface-ready':
this.setUiVisibility(!this.isGlobal);
break;
case 'uwui-hidden':
this.uiIframe.style.opacity = event.data.opacity || this.isGlobal ? '100' : '0';
break;
2024-06-05 01:08:50 +02:00
case 'uwui-global-window-hidden':
if (!this.isGlobal) {
return; // This shouldn't even happen in non-global windows
}
this.setUiVisibility(false);
this.eventBus.send('uw-restore-ui-state', {});
}
}
}
/**
2024-06-05 01:08:50 +02:00
* Sends messages to iframe. Messages sent with this function _generally_
* bypass eventBus on the receiving end.
* @param {*} action
* @param {*} payload
* @param {*} uiURI
*/
2024-06-05 01:08:50 +02:00
sendToIframeLowLevel(action, payload, uiURI = this.uiURI) {
// because existence of UI is not guaranteed — UI is not shown when extension is inactive.
// If extension is inactive due to "player element isn't big enough to justify it", however,
// we can still receive eventBus messages.
if (this.rootDiv && this.uiIframe) {
2024-06-05 01:08:50 +02:00
this.uiIframe.contentWindow?.postMessage(
{
2024-06-05 01:08:50 +02:00
action,
payload
},
uiURI
)
};
}
2024-06-05 01:08:50 +02:00
/**
* Sends message to iframe. Messages sent with this function will be routed to eventbus.
*/
sendToIframe(action, actionConfig, routingData, uiURI = this.uiURI) {
// if (routingData) {
// if (routingData.crossedConnections?.includes(EventBusConnector.IframeBoundaryIn)) {
// console.warn('Denied message propagation. It has already crossed INTO an iframe once.');
// return;
// }
// }
// if (!routingData) {
// routingData = { };
// }
// if (!routingData.crossedConnections) {
// routingData.crossedConnections = [];
// }
// routingData.crossedConnections.push(EventBusConnector.IframeBoundaryIn);
this.sendToIframeLowLevel(
'uw-bus-tunnel',
{
action,
config: actionConfig,
routingData
},
uiURI
);
}
/**
* Replaces ui config and re-inits the UI
2021-10-22 00:30:36 +02:00
* @param {*} newUiConfig
*/
replace(newUiConfig) {
this.uiConfig = newUiConfig;
2023-07-11 00:48:34 +02:00
if (this.rootDiv) {
this.destroy();
2023-07-11 00:48:34 +02:00
this.init();
}
}
destroy() {
this.unloadIframe();
this.eventBus.unsubscribeAll(this);
2020-12-04 02:02:25 +01:00
// this.comms?.destroy();
this.rootDiv?.remove();
2023-07-11 00:48:34 +02:00
delete this.uiIframe;
delete this.rootDiv;
}
}
2020-12-03 01:35:22 +01:00
if (process.env.CHANNEL !== 'stable'){
console.info("UI.js loaded");
}
2020-12-03 01:16:57 +01:00
export default UI;