diff --git a/src/ext/lib/uwui/UI.ts b/src/ext/lib/uwui/UI.ts index ece5402..bd60301 100644 --- a/src/ext/lib/uwui/UI.ts +++ b/src/ext/lib/uwui/UI.ts @@ -15,6 +15,11 @@ import alignmentIndicatorSvg from '!!raw-loader!@ui/res/img/alignment-indicators 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"); @@ -293,10 +298,16 @@ class UI { }] }, { - label: 'todo: open settings' + label: 'Extension settings', + action: () => this.createSettingsWindow(), }, { - label: 'todo: first-time "customize/hide menu" option' + label: 'Cropping issues?', + action: () => this.createSettingsWindow('ui-settings'), + }, + { + label: 'Report a problem', + action: () => this.createSettingsWindow('about'), } ] } @@ -393,6 +404,28 @@ class UI { } } + 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; } diff --git a/src/ext/lib/uwui/UwuiWindow.ts b/src/ext/lib/uwui/UwuiWindow.ts new file mode 100644 index 0000000..be7bb9b --- /dev/null +++ b/src/ext/lib/uwui/UwuiWindow.ts @@ -0,0 +1,336 @@ +import extensionCss from '@src/main.css?inline'; + + +type WindowContent = string | HTMLElement; +type WindowBackgroundMode = 'transparent' | 'blur' | 'solid'; + +interface WindowOptions { + title?: string; + content: WindowContent; + background?: WindowBackgroundMode; + width?: number; + height?: number; + x?: number; + y?: number; + extraStyles?: string[]; + onFocus?: () => void; + onClose?: () => void; +} + +export class UwuiWindow { + private static baseZ = 1000000; + private static instances: UwuiWindow[] = []; + + private host: HTMLDivElement; + private shadow: ShadowRoot; + private windowEl: HTMLDivElement; + private titleBar: HTMLDivElement; + + private dragging = false; + private resizing = false; + private dragOffsetX = 0; + private dragOffsetY = 0; + + private background: WindowBackgroundMode = 'blur'; + private extraStyles?: string[]; + + private onFocus?: () => void; + private onClose?: () => void; + + + constructor(options: WindowOptions) { + this.onFocus = options.onFocus; + this.onClose = options.onClose; + + this.host = document.createElement('div'); + this.host.style.position = 'fixed'; + this.host.style.inset = '0'; + this.host.style.pointerEvents = 'none'; + + this.background = options.background ?? 'blur'; + this.extraStyles = options.extraStyles; + + document.body.appendChild(this.host); + + this.shadow = this.host.attachShadow({ mode: 'closed' }); + this.shadow.innerHTML = this.template(options); + + + this.windowEl = this.shadow.querySelector('.uw-window')!; + this.titleBar = this.shadow.querySelector('.uw-titlebar')!; + + this.setGeometry( + options.x ?? 100, + options.y ?? 100, + options.width ?? 320, + options.height ?? 220 + ); + + this.injectContent(options.content); + this.bindEvents(); + + UwuiWindow.instances.unshift(this); + UwuiWindow.recomputeZIndices(); + + this.applyBackground(); + + this.bindBackgroundMenu(); + } + + + /** + * Sets window stacking order + * @param order stacking order (0 = topmost) + */ + setOrder(order: number) { + const list = UwuiWindow.instances; + + // Remove this window from current position + const currentIndex = list.indexOf(this); + if (currentIndex !== -1) { + list.splice(currentIndex, 1); + } + + // Clamp target index + const clamped = Math.max(0, Math.min(order, list.length)); + + // Insert at new position + list.splice(clamped, 0, this); + + // Re-assign z-indexes + UwuiWindow.recomputeZIndices(); + } + + destroy() { + this.onClose?.(); + + const list = UwuiWindow.instances; + const index = list.indexOf(this); + if (index !== -1) { + list.splice(index, 1); + UwuiWindow.recomputeZIndices(); + } + + this.host.remove(); + } + + + private static recomputeZIndices() { + const base = UwuiWindow.baseZ; + + UwuiWindow.instances.forEach((win, index) => { + // index 0 = topmost → highest z-index + win.host.style.zIndex = String(base + (UwuiWindow.instances.length - index)); + }); + } + + private bringToFront = () => { + this.setOrder(0); + this.onFocus?.(); + }; + + //#region setup + private template(options: WindowOptions): string { + + return ` + + +
+
+ ${options.title ?? ''} + +
+
+ + + +
+ + + +
+
+ + +
+
+ +
+
+
+ `; + } + + private generateStyles() { + let css = extensionCss + .trim() + .replace(/'html, body \{/g, ':host {') + .replace(/'body \{/g, ':host {') + ; + + // this is bad but I can't be bothered to do it the proper way + const cssArr: string[] = css.split('@font-face'); + const cssRemainder = cssArr[cssArr.length - 1].split('}').slice(1).join('}'); + + css = ` + ${cssArr[0]} + @font-face { + font-family: 'Heebo'; + src: url(__FONT_HEEBO__) format('truetype-variations'); + font-weight: 100 900; + font-stretch: 75% 125%; + font-style: normal; + font-display: swap; + } + + @font-face { + font-family: 'Source Code Pro'; + src: url(__FONT_SCP__) format('truetype-variations'); + font-weight: 200 900; + font-style: normal; + font-display: swap; + } + + @font-face { + font-family: 'Source Code Pro'; + src: url(__FONT_SCPI__) format('truetype-variations'); + font-weight: 200 900; + font-style: italic; + font-display: swap; + } + ${cssRemainder}; + `; + + return css + .replace('__FONT_HEEBO__', chrome.runtime.getURL('/ui/res/fonts/Heebo.ttf')) + .replace('__FONT_SCP__', chrome.runtime.getURL('/ui/res/fonts/SourceCodePro.ttf')) + .replace('__FONT_SCPI__', chrome.runtime.getURL('/ui/res/fonts/SourceCodePro-Italic.ttf')) + .replaceAll('html,', '') + ; + } + + private injectContent(content: WindowContent) { + const container = this.shadow.querySelector('.uw-content')!; + if (typeof content === 'string') { + container.innerHTML = content; + } else { + container.appendChild(content); + } + } + //#endregion + + //#region interactivity + private bindEvents() { + this.windowEl.addEventListener('mousedown', this.bringToFront); + + this.titleBar.addEventListener('mousedown', this.startDrag); + + this.shadow.querySelector('.uw-close')! + .addEventListener('click', () => this.destroy()); + + this.shadow.querySelector('.uw-resize-handle')! + .addEventListener('mousedown', this.startResize); + + window.addEventListener('mousemove', this.onMouseMove); + window.addEventListener('mouseup', this.stopActions); + } + + private startDrag = (e: MouseEvent) => { + const rect = this.windowEl.getBoundingClientRect(); + this.dragging = true; + this.dragOffsetX = e.clientX - rect.left; + this.dragOffsetY = e.clientY - rect.top; + e.preventDefault(); + }; + + private startResize = (e: MouseEvent) => { + this.resizing = true; + e.preventDefault(); + }; + + private onMouseMove = (e: MouseEvent) => { + const rect = this.windowEl.getBoundingClientRect(); + + if (this.dragging) { + const x = Math.min( + window.innerWidth - rect.width, + Math.max(0, e.clientX - this.dragOffsetX) + ); + const y = Math.min( + window.innerHeight - rect.height, + Math.max(0, e.clientY - this.dragOffsetY) + ); + + this.windowEl.style.left = `${x}px`; + this.windowEl.style.top = `${y}px`; + } + + if (this.resizing) { + const width = Math.max(160, e.clientX - rect.left); + const height = Math.max(120, e.clientY - rect.top); + + this.windowEl.style.width = `${width}px`; + this.windowEl.style.height = `${height}px`; + } + }; + + private stopActions = () => { + this.dragging = false; + this.resizing = false; + }; + + private setGeometry(x: number, y: number, w: number, h: number) { + Object.assign(this.windowEl.style, { + left: `${x}px`, + top: `${y}px`, + width: `${w}px`, + height: `${h}px`, + }); + } + //#endregion interactivity + + //#region bakcground handling + setBackground(mode: WindowBackgroundMode) { + this.background = mode; + this.applyBackground(); + } + + private applyBackground() { + this.windowEl.classList.remove( + 'uw-window-bg-transparent', + 'uw-window-bg-blurred', + 'uw-window-bg-solid' + ); + + switch (this.background) { + case 'transparent': + this.windowEl.classList.add('uw-window-bg-transparent'); + break; + case 'blur': + this.windowEl.classList.add('uw-window-bg-blurred'); + break; + case 'solid': + this.windowEl.classList.add('uw-window-bg-solid'); + break; + } + } + + private bindBackgroundMenu() { + this.shadow.querySelectorAll('[data-bg]') + .forEach(el => { + el.addEventListener('click', () => { + const mode = el.getAttribute('data-bg') as WindowBackgroundMode; + this.setBackground(mode); + }); + }); + } + //#endregion +} diff --git a/src/main.css b/src/main.css index d849c14..47faf5a 100644 --- a/src/main.css +++ b/src/main.css @@ -7,14 +7,15 @@ @import '@ui/res/styles/buttons.css'; @import '@ui/res/styles/player-menu.css'; +@import '@ui/res/styles/player-window.css'; :host { - @apply bg-stone-950 text-stone-300; + @apply bg-transparent text-stone-300; font-size: 16px; } html, body { - @apply bg-stone-950 text-stone-300; + @apply bg-transparent text-stone-300; font-size: 16px; } diff --git a/src/ui/components/SettingsWindowContent.vue b/src/ui/components/SettingsWindowContent.vue index 63cbfb8..1a031f1 100644 --- a/src/ui/components/SettingsWindowContent.vue +++ b/src/ui/components/SettingsWindowContent.vue @@ -95,6 +95,15 @@ > + +