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': '',
'account-group': '',
'help-circle-outline': '',
'account': ''
};
const supportLevelInfo = this.getSiteSupportLevelForDisplay();
if (this.uiConfig.parentElement) {
const menuConfig = {
isGlobal: this.isGlobal,
menuPosition: MenuPosition.Left,
items: [
{
customClassList: 'uw-site-info',
customHTML: `
`
},
{
customId: 'uw-drm-warning',
customClassList: `uw-menu-warning ${this.playerData?.videoData.hasDrm ? '' : 'uw-hidden'}`,
customHTML: `
Autodetection not available
due to DRM
`
},
{
customId: 'uw-cors-warning',
customClassList: 'uw-menu-warning uw-hidden',
customHTML: `
Autodetection not available
due to CORS error
`
},
{
customId: 'uw-webgl-warning',
customClassList: 'uw-menu-warning uw-hidden',
customHTML: `
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: `
`,
}
]
},
{
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;