import ExtensionMode from '@src/common/enums/ExtensionMode.enum'; import { ClientMenu } from './ClientMenu'; import EventBus from '../EventBus'; import PlayerData from '../video-data/PlayerData'; import { SiteSettings } from '../settings/SiteSettings'; import Settings from '../settings/Settings'; import { MenuPosition as MenuPosition } from '@src/common/interfaces/ClientUiMenu.interface'; import { CommandInterface } from '@src/common/interfaces/SettingsInterface'; import { SiteSupportLevel } from '@src/common/enums/SiteSupportLevel.enum'; import { ComponentLogger } from '../logging/ComponentLogger'; import { setupVideoAlignmentIndicatorInteraction } from '@ui/utils/video-alignment-indicator-handling'; import alignmentIndicatorSvg from '!!raw-loader!@ui/res/img/alignment-indicators.svg'; import lockBarIndicatorSvg from '!!raw-loader!@ui/res/img/lock-bar-indicators.svg'; import VideoAlignmentType from '../../../common/enums/VideoAlignmentType.enum'; import { setVideoAlignmentIndicatorState } from '../../../ui/utils/video-alignment-indicator-handling'; import { UwuiWindow } from './UwuiWindow'; import { createApp } from 'vue'; import SettingsWindowContent from '@components/SettingsWindowContent.vue'; // import jsonEditorCSS from 'vanilla-jsoneditor/themes/jse-theme-dark.css?inline' if (process.env.CHANNEL !== 'stable'){ console.info("Loading: UI"); } class UI { isGlobal: boolean; isIframe: boolean; private eventBus: EventBus; private playerData: PlayerData; private uiSettings: any; private settings: Settings; private siteSettings: SiteSettings; private extensionMenu: ClientMenu; private logger: ComponentLogger; private uiState = { lockXY: true, zoom: { // log2 scale — 100% is 0 x: 0, y: 0, }, videoAlignment: { x: VideoAlignmentType.Default, y: VideoAlignmentType.Default } } constructor( public interfaceId, public uiConfig, // {parentElement?, eventBus?, isGlobal?, playerData} ) { this.isGlobal = uiConfig.isGlobal ?? false; this.isIframe = window.self !== window.top; this.eventBus = uiConfig.eventBus; this.playerData = uiConfig.playerData; this.uiSettings = uiConfig.uiSettings; this.siteSettings = uiConfig.siteSettings; this.settings = uiConfig.settings; // UI will be initialized when setUiVisibility is called if (!this.isGlobal) { this.init(); } } async init() { this.destroy(); if (!this.logger) { this.logger = new ComponentLogger(this.settings.logAggregator, 'UI'); } this.createExtensionMenu(); this.eventBus.subscribeMulti({ 'uw-config-broadcast': { function: (message) => { console.log('UI.ts: received uw-config-broadcast', message); if (message.type === 'aard-error') { console.log('received: warning element:', this.extensionMenu.root.querySelector('#uw-cors-warning')); this.updateMenuWarnings(message); } else if (message.type === 'drm-status') { this.updateMenuWarnings(message); } } } }); } executeCommand(x: CommandInterface) { this.eventBus.send(x.action, x.arguments); } updateMenuWarnings(errors: {aardErrors?: any, hasDrm?: boolean}) { try { if (errors.aardErrors) { if (errors.aardErrors.cors) { this.extensionMenu.root.querySelector('#uw-cors-warning').classList.remove('uw-hidden'); } if (errors.aardErrors.drm) { this.extensionMenu.root.querySelector('#uw-drm-warning').classList.remove('uw-hidden'); } if (errors.aardErrors.webglError) { this.extensionMenu.root.querySelector('#uw-webgl-warning').classList.remove('uw-hidden'); } } if (errors.hasDrm) { this.extensionMenu.root.querySelector('#uw-drm-warning').classList.remove('uw-hidden'); } } catch (e) { this.logger.error("UI.ts: failed to update menu warnings:", e); } } private getSiteSupportLevelForDisplay() { const siteSupportLevel = (this.siteSettings ? this.siteSettings.data.type ?? SiteSupportLevel.Unknown : 'waiting') switch (siteSupportLevel) { case SiteSupportLevel.OfficialSupport: return {cssClass: 'uw-official', text: 'Verified', svg: 'check-decagram'}; case SiteSupportLevel.CommunitySupport: return {cssClass: 'uw-community', text: 'Community', svg: 'account-group'}; case SiteSupportLevel.Unknown: return {cssClass: 'uw-no-support', text: 'Untested', svg: 'help-circle-outline'}; case SiteSupportLevel.UserDefined: return {cssClass: 'uw-user-added', text: 'Modified by you', svg: 'account'}; case SiteSupportLevel.UserModified: return {cssClass: 'uw-user-added', text: 'Modified by you', svg: 'account'}; default: return {cssClass: 'uw-unknown', text: 'Unknown', svg: 'help-circle-outline'}; } } createExtensionMenu() { if (+this.siteSettings?.data.enableUI === ExtensionMode.Disabled || this.isGlobal) { return; // don't } if (this.extensionMenu) { this.extensionMenu.destroy(); } const svgMap = { 'check-decagram': 'check-decagram', 'account-group': 'account-group', 'help-circle-outline': 'help-circle-outline', 'account': 'account' }; const supportLevelInfo = this.getSiteSupportLevelForDisplay(); if (this.uiConfig.parentElement) { const menuConfig = { isGlobal: this.isGlobal, menuPosition: MenuPosition.Left, items: [ { customClassList: 'uw-site-info', customHTML: `
${this.siteSettings?.site ?? ''}
${svgMap[supportLevelInfo.svg]} ${supportLevelInfo.text}
` }, { customId: 'uw-drm-warning', customClassList: `uw-menu-warning ${this.playerData?.videoData.hasDrm ? '' : 'uw-hidden'}`, customHTML: ` alert
Autodetection not available
due to DRM
` }, { customId: 'uw-cors-warning', customClassList: 'uw-menu-warning uw-hidden', customHTML: ` alert
Autodetection not available
due to CORS error
` }, { customId: 'uw-webgl-warning', customClassList: 'uw-menu-warning uw-hidden', customHTML: ` alert
Autodetection not available
due to WEBGL error
Check if hardware acceleration is
enabled and working.
` }, { label: 'Crop', subitems: this.settings.active.commands.crop.map((x: CommandInterface) => { return { label: x.label, action: () => this.executeCommand(x) } }) }, { label: 'Stretch', subitems: this.settings.active.commands.stretch.map((x: CommandInterface) => { return { label: x.label, action: () => this.executeCommand(x) } }) }, { label: 'Zoom (presets)', subitems: [ ... this.settings.active.commands.zoom.map((x: CommandInterface) => { return { label: x.label, action: () => this.executeCommand(x) } }), ] }, { label: 'Zoom (free-form)', subitems: [ { customHTML: `
Width:
100%
Height:
100%
${lockBarIndicatorSvg}
restore
`, } ] }, { label: 'Align video to ...', subitems: [{ customId: 'videoAlignmentController', customClassList: '', customHTML: `
${alignmentIndicatorSvg}
`, }] }, { label: 'Extension settings', action: () => this.createSettingsWindow(), }, { label: 'Cropping issues?', action: () => this.createSettingsWindow('ui-settings'), }, { label: 'Report a problem', action: () => this.createSettingsWindow('about'), } ] } this.extensionMenu = new ClientMenu(menuConfig); this.extensionMenu.mount(this.uiConfig.parentElement); /** * SETUP MENU INTERACTIONS * ——————————————————————————————————— * Interactions are needed for the following things: * 0. [ ] both sliders. Needs to read the state of the sliders and update the labels * 1. [ ] lock X/Y button — needs to update icon state of the button and the linked bar * 2. [ ] reset button — just needs to reset, no icon changes necessary * 3. [X] alignment indicator — also needs to update indicator state */ const menuElement = this.extensionMenu.root; // 0. SLIDERS ————————————————————————————————————————————————————————————————————————————— const zoomWidthSlider: HTMLInputElement = menuElement.querySelector('#_input_zoom_slider'); const zoomHeightSlider: HTMLInputElement = menuElement.querySelector('#_input_zoom_slider_2'); const zoomWidthLabel: HTMLDivElement = menuElement.querySelector('#zoomWidth'); const zoomHeightLabel: HTMLDivElement = menuElement.querySelector('#zoomHeight'); const updateZoomDisplayValues = () => { zoomWidthLabel.textContent = `${Math.round((Math.exp(this.uiState.zoom.x) * 100))}%`; zoomHeightLabel.textContent = `${Math.round((Math.exp(this.uiState.zoom.y) * 100))}%`; zoomWidthSlider.value = this.uiState.zoom.x.toString(); zoomHeightSlider.value = this.uiState.zoom.y.toString(); } const updateZoom = (event: InputEvent, axis: 'x' | 'y') => { const parsedValue = parseFloat((event.currentTarget as HTMLInputElement).value); if (this.uiState.lockXY) { this.uiState.zoom = {x: parsedValue, y: parsedValue}; } else { this.uiState.zoom[axis] = parsedValue; } updateZoomDisplayValues(); this.eventBus?.send('set-zoom', { zoom: { x: Math.pow(2, this.uiState.zoom.x), y: Math.pow(2, this.uiState.zoom.y) } }); } zoomWidthSlider.addEventListener('input', (event: InputEvent) => updateZoom(event, 'x')); zoomHeightSlider.addEventListener('input', (event: InputEvent) => updateZoom(event, 'y')); // 1. LOCK X/Y ———————————————————————————————————————————————————————————————————————————— const lockXYButton = menuElement.querySelector('#_button_toggle_aspect_lock'); const sliderLockBar = menuElement.querySelector('#slider-lock'); lockXYButton.addEventListener('click', () => { this.uiState.lockXY = !this.uiState.lockXY; if (this.uiState.lockXY) { lockXYButton.classList.add('uw-linked'); lockXYButton.classList.remove('uw-unlinked'); sliderLockBar.classList.add('uw-linked'); sliderLockBar.classList.remove('uw-unlinked'); } else { lockXYButton.classList.add('uw-unlinked'); lockXYButton.classList.remove('uw-linked'); sliderLockBar.classList.add('uw-unlinked'); sliderLockBar.classList.remove('uw-linked'); } }); // 2. ZOOM RESET BUTTON ——————————————————————————————————————————————————————————————————— const zoomResetButton = menuElement.querySelector('#_button_reset_zoom'); zoomResetButton.addEventListener('click', () => { this.eventBus?.send('set-zoom', { zoom: {x: 1, y: 1} }); this.uiState.zoom = {x: 0, y: 0}; updateZoomDisplayValues(); }); // 3. VIDEO ALIGNMENT INDICATOR ——————————————————————————————————————————————————————————— const videoAlignmentIndicatorElement: SVGSVGElement = menuElement.querySelector('#_uw_ui_alignment_indicator'); setupVideoAlignmentIndicatorInteraction( videoAlignmentIndicatorElement, (x: VideoAlignmentType, y: VideoAlignmentType) => { setVideoAlignmentIndicatorState(videoAlignmentIndicatorElement, x, y); if (this.eventBus) { this.eventBus?.send('set-alignment', {x,y}); this.uiState.videoAlignment = {x,y}; } } ); } } createSettingsWindow(path?: string) { const iframe = document.createElement('iframe'); iframe.src = chrome.runtime.getURL(`ui/pages/settings/index.html#ui${path ? `/${path}` : ''}`); iframe.setAttribute('allowtransparency', 'true'); Object.assign(iframe.style, { width: '100%', height: '100%', border: 'none', background: 'transparent', // important }); new UwuiWindow({ title: 'Ultrawidify settings (iframe)', width: 1200, height: 800, x: 0, y: 0, content: iframe, }); } setUiVisibility(visible) { return; } async enable() { // if root element is not present, we need to init the UI. // if (!this.rootDiv) { // await this.init(); // } // otherwise, we don't have to do anything } disable() { // if (this.rootDiv) { // this.destroy(); // } } /** * Replaces ui config and re-inits the UI * @param {*} newUiConfig */ replace(newUiConfig) { this.uiConfig = newUiConfig; this.init(); } destroy() { if (this.extensionMenu) { this.extensionMenu.destroy(); } } } if (process.env.CHANNEL !== 'stable'){ console.info("UI.js loaded"); } export default UI;