Mercator Studio for Google Meet

Change how you look on Google Meet.

// ==UserScript==
// @name	Mercator Studio for Google Meet
// @version	2.2.1
// @description	Change how you look on Google Meet.
// @author	Xing <dev@x-ing.space> (https://x-ing.space)
// @copyright	2020-2021, Xing (https://x-ing.space)
// @license	MIT License; https://x-ing.space/mercator/LICENSE
// @namespace	https://x-ing.space
// @homepageURL	https://x-ing.space/mercator
// @icon	https://x-ing.space/mercator/icon.png
// @match	https://meet.google.com/*
// @grant	none
// ==/UserScript==
(async function mercator_studio() {
	'use strict'

	// Create shadow root

	const host = document.createElement('aside')
	host.style.position = 'absolute'
	host.style.zIndex = 10
	host.style.pointerEvents = 'none'
	const shadow = host.attachShadow({ mode: 'open' })

	const isFirefox = navigator.userAgent.includes('Firefox')

	// Create form

	const main = document.createElement('main')
	const style = document.createElement('style')
	const body_fonts = 'Roboto, RobotDraft, Helvetica, sans-serif, serif'
	const display_fonts = '"Google Sans", ' + body_fonts
	style.textContent = `
a, button {
	all: unset;
	cursor: pointer;
	text-align: center;
}
main, main *, a, button {
	box-sizing: border-box;
	transition-duration: 200ms;
	transition-property: opacity, background, transform, padding, border-radius, border-color;

	color: inherit;
	font-family: inherit;
	font-size: inherit;
	font-weight: inherit;
}
:not(input) {
	user-select: none;
}
@media (prefers-reduced-motion) {
	* {
		transition-duration: 0s;
	}
}
:focus {
	outline: 0;
}

/* -- */

main {
	--bg: #3C4042;
	--bg-x: #434649;
	--bg-xx: #505457;
	--txt: white;	

	font-family: ${display_fonts};
	font-size: 0.9rem;
	width: 25rem;
	max-width: 100vw;
	height: 100vh;
	position: fixed;
	bottom: 0;
	left: 0;
	padding: 0.5rem;
	display: flex;
	flex-direction: column-reverse;
	overflow: hidden;
	pointer-events: none;
}
main > * {
	color: var(--txt);
}
#fields,
#bar,
#labels > * {
	border-radius: .5rem;
	box-shadow: 0 .1rem .25rem #0004;
	pointer-events: all;
}
:not(.edit)>#fields{
	display: none;
	opacity: 0;
}
:not(.edit)>#bar{
	border-radius: 1.5rem;
	flex-basis: 4rem;
}
#text:hover, #text:focus,
#presets:hover,
#bar>:hover, #bar>:focus,
#tips > * {
	background: var(--bg-x);
}
#text:hover:focus,
#presets:hover,
#bar>:hover:focus {
	background: var(--bg-xx);
}

/* -- */

#tips {
	position: relative;
	font-family: ${body_fonts};
	font-size: 0.8rem;
	line-height: 1rem;
	z-index: 10;
}
#tips > * {
	display: block;
	position: absolute;
	bottom: 0rem;
	height: 1.5rem;
	padding: 0.25rem;
	border-radius: 0.25rem;
}
#tips > :not(.show) {
	opacity: 0;
}
#tips > [for="minimize"] {
	left: 0;
}
#tips > [for="previews"] {
	left: 50%;
	transform: translateX(-50%);
}
#tips > [for="donate"] {
	right: 0;
}
.edit > #tips > * {
	top: 1rem;
}

/* -- */

#bar {
	margin-top: .5rem;
	overflow: hidden;
	flex: 0 0 auto;
	display: flex;
}
.minimize #bar {
	width: 1rem;
}
#bar > * {
	background: var(--bg);
}
#bar #minimize,
#bar #donate {
	font-size: .5rem;
	flex: 0 0 1.5rem;
	width: var(--radius);
	text-align: center;
	line-height: 4rem;
	height: 100%;
	overflow-wrap: anywhere;
}
.edit #bar #minimize,
.edit #bar #donate,
.edit #bar h2,
.minimize #bar :not(#minimize) {
	display: none;
}
:not(.minimize) #minimize:hover,
.minimize #minimize:not(:hover) {
	padding-right: 2px;
}
#donate:hover {
	padding-left: 2px;
}
.minimize #minimize{
	flex-basis: 1rem;
}
#previews {
	flex: 1 0 0;
	width: 0;
	display: flex;
}
#previews video,
#previews canvas {
	width: auto;
	height: auto;
	background-image: linear-gradient(90deg,
		hsl( 18, 100%, 68%) 16.7%,	hsl(-10, 100%, 80%) 16.7%,
		hsl(-10, 100%, 80%) 33.3%,	hsl(5,90%, 72%) 33.3%,
		hsl(5,90%, 72%) 50%,	hsl( 48, 100%, 75%) 50%,
		hsl( 48, 100%, 75%) 66.7%,	hsl( 36, 100%, 70%) 66.7%,
		hsl( 36, 100%, 70%) 83.3%,	hsl( 20,90%, 70%) 83.3%
	);
}
.edit #previews video,
.edit #previews canvas {
	height: auto;
	max-width: 50%;
	object-fit: contain;
}
#previews>h2 {
	flex-grow: 1;
	font-size: .9rem;
	line-height: 1.4;
	display: flex;
	text-align: center;
	align-items: center;
	justify-content: center;
}
#previews:hover>h2 {
	transform: translateY(-2px);
}

/* -- */

#fields {
	display: flex;
	flex-direction: column;
	overflow: hidden scroll;
	padding: 1rem;
	flex: 0 1 auto;
	background: var(--bg);
}
#presets,
#fields > label {
	display: flex;
	justify-content: space-between;
	align-items: center;
}
#fields > label+label {
	margin-top: 0.5rem;
}
#fields > label:focus-within{
	font-weight: bold;
}
#fields > label > * {
	width: calc(100% - 4.5rem);
	height: 1rem;
	border-radius: 0.5rem;
	border: 0.15rem solid var(--bg-x);
	font-size: 0.8rem;
}
#presets:focus-within,
#fields > label > :focus,
#fields > label > :hover {
	border-width: 0.15rem;
	border-color: var(--txt);
}
#fields > label > #presets {
	overflow: hidden;
	height: auto;
	margin-bottom: -0.15rem;
}
#presets>* {
	border: 0;
	border-radius: 0;
	background: transparent;
	flex-grow: 1;
	height: 1.3rem;
	font-weight: normal;
}
#presets>:first-child {
	border-radius: 0.25rem 0 0 0.25rem;
}
#presets>:last-child {
	border-radius: 0 0.25rem 0.25rem 0;
}
#presets>:hover {
	background: var(--bg);
}
#presets>:focus {
	background: var(--txt);
	color: var(--bg);
}
#fields > label > #text {
	text-align: center;
	font-weight: bold;
	resize: none;
	line-height: 1.1;
	overflow: hidden scroll;
	background: var(--bg);
	height: auto;
}
#text::placeholder {
	color: inherit;
}
#text::selection {
	color: var(--bg);
	background: var(--txt);
}
input[type=checkbox] {
	cursor: pointer;
}
input[type=range] {
	-webkit-appearance: none;
	cursor: ew-resize;
	--gradient: transparent, transparent;
	--rainbow: hsl(0, 80%, 75%), hsl(30, 80%, 75%), hsl(60, 80%, 75%), hsl(90, 80%, 75%), hsl(120, 80%, 75%), hsl(150, 80%, 75%), hsl(180, 80%, 75%), hsl(210, 80%, 75%), hsl(240, 80%, 75%), hsl(270, 80%, 75%), hsl(300, 80%, 75%), hsl(330, 80%, 75%);
	background: linear-gradient(90deg, var(--gradient)), linear-gradient(90deg, var(--rainbow));
}
input[type=range]::-webkit-slider-thumb {
	-webkit-appearance: none;
	transition: inherit;
	background: var(--bg);
	width: 1rem;
	height: 1rem;
	border: 0.1rem solid var(--txt);
	transform: scale(1.5);
	border-radius: 100%;
}
input[type=range]:hover::-webkit-slider-thumb {
	background: var(--bg-x);
}
input[type=range]:focus::-webkit-slider-thumb {
	border-color: var(--bg);
	background: var(--txt);
}
input[type=range]::-moz-range-thumb {
	transition: inherit;
	background: var(--bg);
	width: 1rem;
	height: 1rem;
	border: 0.1rem solid var(--txt);
	transform: scale(1.5);
	border-radius: 100%;
	box-sizing: border-box;
}
input[type=range]:hover::-moz-range-thumb {
	background: var(--bg-x);
}
input[type=range]:focus::-moz-range-thumb {
	border-color: var(--bg);
	background: var(--txt);
}
input#light,
input#fade,
input#vignette {
	--gradient: black, #8880, white
}
input#contrast {
	--gradient: gray, #8880
}
input#warmth,
input#tilt {
	--gradient: #88f, #8880, #ff8
}
input#tint,
input#pan {
	--gradient: #f8f, #8880, #8f8
}
input#sepia {
	--gradient: #8880, #aa8
}
input#hue,
input#rotate {
	background: linear-gradient(90deg, hsl(0, 80%, 75%), hsl(60, 80%, 75%), hsl(120, 80%, 75%), hsl(180, 80%, 75%), hsl(240, 80%, 75%), hsl(300, 80%, 75%), hsl(0, 80%, 75%), hsl(60, 80%, 75%), hsl(120, 80%, 75%), hsl(180, 80%, 75%), hsl(240, 80%, 75%), hsl(300, 80%, 75%), hsl(0, 80%, 75%))
}
input#saturate {
	--gradient: gray, #8880 50%, blue, magenta
}
input#blur {
	--gradient: #8880, gray
}
input#scale,
input#pillarbox,
input#letterbox {
	--gradient: black, white
}
`

	// Translate labels
	// Top languages of users: English, Portuguese, Spanish, Italian, Polish

	const i18n = {
		light:	{ en: 'light',	es: 'brillo',	fr: 'lumin',	it: 'lumin',	pt: 'brilho',	zh: '亮度' },
		contrast:	{ en: 'contrast',	es: 'contraste',	fr: 'contraste',	it: 'contrasto',	pt: 'contraste',	zh: '对比度' },
		warmth:	{ en: 'warmth',	es: 'calor',	fr: 'chaleur',	it: 'calore',	pt: 'calor',	zh: '温度' },
		tint:	{ en: 'tint',	es: 'tinción',	fr: 'teinte',	it: 'tinta',	pt: 'verde',	zh: '色调' },
		sepia:	{ en: 'sepia',	es: 'sepia',	fr: 'sépia',	it: 'seppia',	pt: 'sépia',	zh: '泛黄' },
		hue:	{ en: 'hue',	es: 'tono',	fr: 'ton',	it: 'tonalità',	pt: 'matiz',	zh: '色相' },
		saturate:	{ en: 'saturate',	es: 'satura',	fr: 'sature',	it: 'saturare',	pt: 'satura',	zh: '饱和度' },
		blur:	{ en: 'blur',	es: 'difuminar',	fr: 'flou',	it: 'sfocatura',	pt: 'enevoa',	zh: '模糊' },
		fade:	{ en: 'fade',	es: 'fundido',	fr: 'fondu',	it: 'svanisci',	pt: 'fundido',	zh: '淡出' },
		vignette:	{ en: 'vignette',	es: 'viñeta',	fr: 'vignette',	it: 'vignetta',	pt: 'vinheta',	zh: '虚光照' },
		rotate:	{ en: 'rotate',	es: 'rota',	fr: 'pivote',	it: 'ruoti',	pt: 'rota',	zh: '旋转' },
		scale:	{ en: 'scale',	es: 'zoom',	fr: 'zoom',	it: 'scala',	pt: 'zoom',	zh: '大小' },
		pan:	{ en: 'pan',	es: 'panea',	fr: 'pan',	it: 'sposti-h',	pt: 'panea',	zh: '左右移动' },
		tilt:	{ en: 'tilt',	es: 'inclina',	fr: 'incline', 	it: 'sposti-v',	pt: 'empina',	zh: '上下移动' },
		pillarbox:	{ en: 'pillarbox',	es: 'recorta-h',	fr: 'taille-h',	it: 'tagli-h',	pt: 'recorta-h', zh: '左右裁剪' },
		letterbox:	{ en: 'letterbox',	es: 'recorta-v',	fr: 'taille-v',	it: 'tagli-v',	pt: 'recorta-v', zh: '上下裁剪' },
		text:	{ en: 'text',	es: 'texto',	fr: 'texte',	it: 'testo',	pt: 'texto',	zh: '文字' },
		mirror:	{ en: 'mirror',	es: 'refleja',	fr: 'réfléch',	it: 'rispecchi',	pt: 'refleja',	zh: '反射' },
		freeze:	{ en: 'freeze',	es: 'pausa',	fr: 'arrête',	it: 'pausa',	pt: 'pausa',	zh: '暂停' },
		presets:	{ en: 'presets',	es: 'estilos',	fr: 'styles',	it: 'stili',	pt: 'estilos',	zh: '预设' },
		preset:	{ en: 'preset: ',	es: 'estilo: ',	fr: 'style: ',	it: 'stile: ',	pt: 'estilo: ',	zh: '预设:' },
		reset:	{ en: 'reset',	es: 'reini',	fr: 'réinit',	it: 'reset',	pt: 'reini',	zh: '重置'	},
		open_tip:	{ en: 'Open',	es: 'Abre',	fr: 'Ouvre',	it: 'Apri',	pt: 'Aberto',	zh: '打开' },
		close_tip:	{ en: 'Close',	es: 'Cierra',	fr: 'Ferme',	it: 'Chiudi',	pt: 'Feche',	zh: '合起' },
		minimize_tip:	{ en: 'Minimize',	es: 'Minimizas',	fr: 'Minimise',	it: 'Minimizzi',	pt: 'Minimiza',	zh: '合起' },
		previews_tip:	{ en: ' previews',	es: ' visualizaciones',	fr: ' aperçus',	it: ' anteprima',	pt: 'visualizações',	zh: '预览' },
		studio_tip:	{ en: ' studio',	es: ' estudio',	fr: ' studio',	it: ' studio',	pt: ' estúdio',	zh: '画室' },
		text_tip:	{ en: 'Write text here',	es: 'Escribe el texto aquí',	fr: 'Écrivez du texte ici',	it: 'Scrivi il testo qui',	pt: 'Escreva o texto aqui',	zh: '在这里写字' },
		donate_tip:	{ en: 'Donate to the dev',	es: 'Donas al dev',	fr: 'Fais un don au dev',	it: 'Donare al dev',	pt: 'Você doa para o dev',	zh: '捐款给作者' },
	}
	const langs = [ 'en', 'es', 'fr', 'it', 'pt', 'zh' ]
	main.lang = langs.find( x => x === navigator.language.split('-')[0] ) || 'en'
	for(const key in i18n) i18n[key] = i18n[key][main.lang]

	// Create inputs
	
	const fields = document.createElement('section')
	fields.id= 'fields'
	
	const types = {
		light: 'range',
		contrast: 'range',
		warmth: 'range',
		tint: 'range',
		sepia: 'range_positive',
		hue: 'range_loop',
		saturate: 'range',
		blur: 'range_positive',
		fade: 'range',
		vignette: 'range',
		rotate: 'range_loop',
		scale: 'range_positive',
		pan: 'range',
		tilt: 'range',
		pillarbox: 'range_positive',
		letterbox: 'range_positive',
		text: 'textarea',
		mirror: 'checkbox',
		freeze: 'checkbox',
		presets: 'radio',
	}
	const default_values = {
		light: 0,
		contrast: 0,
		warmth: 0,
		tint: 0,
		sepia: 0,
		hue: 0,
		saturate: 0,
		blur: 0,
		fade: 0,
		vignette: 0,
		rotate: 0,
		scale: 0,
		pan: 0,
		tilt: 0,
		pillarbox: 0,
		letterbox: 0,
		text: '',
		mirror: false,
		freeze: false,
		presets: 'reset',
	}
	const saved_values = JSON.parse(window.localStorage.getItem('mercator-studio-values-20')) || {}

	const preset_values = {
		reset: {},
		concorde: {
			contrast: 0.1,
			warmth: -0.25,
			tint: -0.05,
			saturate: 0.2,
		},
		mono: {
			light: 0.1,
			contrast: -0.1,
			sepia: 0.8,
			saturate: -1,
			vignette: -0.5,
		},
		matcha: {
			light: 0.1,
			tint: -0.75,
			sepia: 1,
			hue: 0.2,
			vignette: 0.3,
			fade: 0.3,
		},
		deepfry: {
			contrast: 1,
			saturate: 0.5,
		}
	}

	// Clone default values into updating object
	const values = {
		...default_values,
		...saved_values
	}

	const inputs = Object.fromEntries(
		Object.entries(values)
		.map(([key, value]) => {
			let input
			const type = types[key]
			switch (type) {
				case 'textarea':
					input = document.createElement('textarea')
					input.rows = 3
					input.placeholder = `\n🌈 ${i18n.text_tip} 🌦️`
					input.addEventListener('input', () => {
						// String substitution
						set_value(input, (input.value + '')
							.replace(/--/g, '―')
							.replace(/\\sqrt/g, '√')
							.replace(/\\pm/g, '±')
							.replace(/\\times/g, '×')
							.replace(/\\cdot/g, '·')
							.replace(/\\over/g, '∕')
							// Numbers starting with ^ (superscript) or _ (subscript)
							.replace(/(\^|\_)(\d+)/g, (_, sign, number) =>
								number.split('').map(digit =>
									String.fromCharCode(digit.charCodeAt(0) + (
										// Difference in character codes between subscript numbers and their regular equivalents.
										sign === '_' ? 8272 :
										// Superscript 1, 2 & 3 are in separate ranges.
										digit === '1' ? 136 :
										'23'.includes(digit) ? 128 : 8256
									))
								).join('')
							)
						)
					})
					break
				case 'checkbox':
					input = document.createElement('input')
					input.type = 'checkbox'
					input.addEventListener('change', () =>
						set_value(input, input.checked)
					)
					break
				case 'radio':
					input = document.createElement('label')
					input.append(...Object.keys(preset_values).map(key => {
						const button = document.createElement('button')
						button.textContent = ( key === 'reset' ) ? i18n.reset : key
						button.setAttribute('aria-label', i18n.preset + button.textContent)
						button.addEventListener('click', event => {
							event.preventDefault()
							Object.entries({...default_values,...preset_values[key]})
								.forEach(([key, value]) => set_value(inputs[key], value))
						})
						return button
					}))
					break
				default:
					input = document.createElement('input')
					input.type = 'range'

					// These inputs go from 0 to 1, the rest -1 to 1
					input.min = ( type === 'range_positive' ) - 1
					input.max = 1

					// Use 32 steps normally, 128 if CTRL, 512 if SHIFT
					const range = input.max - input.min
					input.step = range / 32
					input.addEventListener('keydown', ({ code, ctrlKey, shiftKey }) => {
						if(code === 'Digit0') reset_value(input)
						input.step = range / (shiftKey ? 512 : ctrlKey ? 128 : 32)
					})
					input.addEventListener('keyup', () =>
						input.step = range / 32
					)

					input.addEventListener('input', () => {
						input.focus()
						set_value(input, input.valueAsNumber)
					})

					// Scroll to change values
					input.addEventListener('wheel', event => {
						event.preventDefault()
						input.focus()
						const width = input.getBoundingClientRect().width
						const dx = -event.deltaX
						const dy = event.deltaY
						const ratio = (Math.abs(dx) > Math.abs(dy) ? dx : dy) / width
						const range = input.max - input.min
						const raw_value = input.valueAsNumber + ratio * range
						const clamped_value = Math.min(Math.max(raw_value, input.min), input.max)
						const stepped_value = Math.round(clamped_value / input.step) * input.step
						const value = stepped_value
						set_value(input, value)
					})

					// Right click to individually reset
					input.addEventListener('contextmenu', event => {
						event.preventDefault()
						reset_value(input)
					})
			}

			input.value = value
			input.id = key

			if (!(isFirefox && ['warmth', 'tint'].includes(key))) {
				// Disable the SVG filters for Firefox
				let label = document.createElement('label')
				label.textContent = i18n[key]

				label.append(input)
				fields.append(label)
			}
			return [key, input]
		})
	)

	function set_value(input, value) {
		values[input.id] = input.value = value
		window.localStorage.setItem('mercator-studio-values-20', JSON.stringify(values))
	}
	function reset_value(input) {
		set_value(input, default_values[input.id])
	}

	// Create color balance matrix
	const svgNS = 'http://www.w3.org/2000/svg'
	const svg = document.createElementNS(svgNS, 'svg')
	const filter = document.createElementNS(svgNS, 'filter')
	filter.id = 'filter'
	const component_transfer = document.createElementNS(svgNS, 'feComponentTransfer')
	const components = Object.fromEntries(
		['R', 'G', 'B'].map(hue => {
			const func = document.createElementNS(svgNS, 'feFunc' + hue)
			func.setAttribute('type', 'table')
			func.setAttribute('tableValues', '0 1')
			return [hue, func]
		}))
	component_transfer.append(...Object.values(components))
	filter.append(component_transfer)
	svg.append(filter)

	// Create labels
	
	const minimize_tip = document.createElement('label')
	minimize_tip.htmlFor = 'minimize'
	minimize_tip.dataset.off = `${i18n.minimize_tip}${i18n.previews_tip} (ctrl + shift + m)`
	minimize_tip.dataset.on = `${i18n.open_tip}${i18n.previews_tip} (ctrl + shift + m)`
	minimize_tip.textContent = minimize_tip.dataset.off

	const previews_tip = document.createElement('label')
	previews_tip.htmlFor = 'previews'
	previews_tip.dataset.off = `${i18n.open_tip}${i18n.studio_tip} (ctrl + m)`
	previews_tip.dataset.on = `${i18n.close_tip}${i18n.studio_tip} (ctrl + m)`
	previews_tip.textContent = previews_tip.dataset.off

	const donate_tip = document.createElement('label')
	donate_tip.htmlFor = 'donate'
	donate_tip.textContent = i18n.donate_tip

	const tips = document.createElement('section')
	tips.id = 'tips'
	tips.append(minimize_tip,previews_tip,donate_tip)

	// Mimic Google Meet tooltip behavior where hover gets priority over focused
	const update_tips = () => {
		tips.querySelectorAll('.show').forEach(tip=>tip.classList.remove('show'))
		const show = tips.querySelector('.hover') || tips.querySelector('.focus')
		if(show) show.classList.add('show')
	}
	const link_tip = ( original, tip ) => {
		original.addEventListener('mouseenter',()=>{
			tip.classList.add('hover')
			update_tips()
		})
		original.addEventListener('mouseleave',()=>{
			tip.classList.remove('hover')
			update_tips()
		})
		original.addEventListener('focus',()=>{
			tip.classList.add('focus')
			update_tips()
		})
		original.addEventListener('blur',()=>{
			tip.classList.remove('focus')
			update_tips()
		})
	}

	// create bottom bar

	const bar = document.createElement('section')
	bar.id = 'bar'

	const minimize = document.createElement('button')
	minimize.id = 'minimize'
	minimize.textContent = '◀'
	const toggleMinimize = () => {
		main.classList.remove('edit')
		main.classList.toggle('minimize')
		minimize.focus()
		const state = main.classList.contains('minimize')
		minimize.textContent = state ? '▶' : '◀'
		minimize_tip.textContent = minimize_tip.dataset[ state ? 'on' : 'off' ]
		minimize_tip.classList.remove('focus')
		update_tips()
	}
	minimize.addEventListener('click', toggleMinimize)
	link_tip(minimize,minimize_tip)

	const donate = document.createElement('a')
	donate.id = 'donate'
	donate.href = 'https://ko-fi.com/xingyzt'
	donate.target = '_blank'
	donate.textContent = '🤍'
	donate.setAttribute('aria-label',i18n.donate_tip)
	link_tip(donate,donate_tip)


	// Create previews
	const previews = document.createElement('button')
	previews.id = 'previews'
	const toggleEdit = () => {
		main.classList.remove('minimize')
		main.classList.toggle('edit')
		previews.focus()
		const state = main.classList.contains('edit')
		state ? Object.values(inputs)[0].focus() : previews.focus()
		previews_tip.textContent = previews_tip.dataset[state ? 'on' : 'off']
		previews_tip.classList.remove('focus')
		update_tips()
	}
	previews.addEventListener('click', toggleEdit)
	link_tip(previews,previews_tip)

	// Ctrl+m to toggle
	window.addEventListener('keydown', event => {
		if (event.code=='KeyM' && event.ctrlKey) {
			event.preventDefault()
			event.shiftKey ? toggleMinimize(event) : toggleEdit(event)
		}
	})

	// Create preview video
	const video = document.createElement('video')
	video.setAttribute('playsinline', '')
	video.setAttribute('autoplay', '')
	video.setAttribute('muted', '')

	// Create canvases
	const canvases = Object.fromEntries(['buffer', 'freeze', 'display'].map(name => {
		const element = document.createElement('canvas')
		const context = element.getContext('2d')
		return [name, {
			element,
			context
		}]
	}))

	// Create title
	const title = document.createElement('h2')
	title.id = 'title'
	title.innerText = 'Mercator\nStudio'

	previews.append(video, title, canvases.buffer.element)
	bar.append(minimize, previews, donate)

	// Add UI to page
	main.append(bar, tips, fields)
	shadow.append(main, style, svg)
	document.body.append(host)

	// Define mappings of linear values
	const polynomial_map = (value, degree) => (value + 1) ** degree
	const polynomial_table = (factor, steps = 32) => Array(steps).fill(0)
		.map((_, index) => Math.pow(index / (steps - 1), 2 ** factor)).join(' ')
	const percentage = (value) => value * 100 + '%'

	const amp = 8

	let task = 0

	// Background Blur for Google Meet does this (hello@brownfoxlabs.com)

	class mercator_studio_MediaStream extends MediaStream {

		constructor(old_stream) {

			// Copy original stream settings

			super(old_stream)

			video.srcObject = old_stream

			const old_stream_settings = old_stream.getVideoTracks()[0].getSettings()

			const w = old_stream_settings.width
			const h = old_stream_settings.height
			const center = [w / 2, h / 2]
			Object.values(canvases).forEach(canvas => {
				canvas.element.width = w
				canvas.element.height = h
			})
			const canvas = canvases.buffer.buffer
			const context = canvases.buffer.context
			const freeze = {
				state: false,
				init: false,
				image: document.createElement('img'),
				canvas: canvases.freeze,
			}
			inputs.freeze.addEventListener('change', e => {
				freeze.state = freeze.init = e.target.checked
			})

			// Amp: for values that can range from 0 to +infinity, amp**value does the mapping.

			context.textAlign = 'center'
			context.textBaseline = 'middle'

			function draw() {

				context.clearRect(0, 0, w, h)

				// Get values

				inputs.hue.value %= 1
				inputs.rotate.value %= 1

				let v = values

				let light = percentage(polynomial_map(v.light, 2))
				let contrast = percentage(polynomial_map(v.contrast, 3))
				let warmth = isFirefox ? 0 : v.warmth
				let tint = isFirefox ? 0 : v.tint
				let sepia = percentage(v.sepia)
				let hue = 360 * v.hue + 'deg'
				let saturate = percentage(amp ** v.saturate)
				let blur = v.blur * w / 16 + 'px'
				let fade = v.fade
				let vignette = v.vignette
				let rotate = v.rotate * 2 * Math.PI
				let scale = polynomial_map(v.scale, 2)
				let mirror = v.mirror
				let move_x = v.pan * w
				let move_y = v.tilt * h
				let pillarbox = v.pillarbox * w / 2
				let letterbox = v.letterbox * h / 2
				let text = v.text.split('\n')

				// Color balance

				components.R.setAttribute('tableValues', polynomial_table(-warmth + tint / 2))
				components.G.setAttribute('tableValues', polynomial_table(-tint))
				components.B.setAttribute('tableValues', polynomial_table( warmth + tint / 2))

				// CSS filters

				context.filter = (`
					brightness(${light})
					contrast(${contrast})
					${'url(#filter)'.repeat(Boolean(warmth||tint))}
					sepia(${sepia})
					hue-rotate(${hue})
					saturate(${saturate})
					blur(${blur})
				`)

				// Linear transformations: rotation, scaling, translation
				context.translate(...center)
				if (rotate) context.rotate(rotate)
				if (scale - 1) context.scale(scale, scale)
				if (mirror) context.scale(-1, 1)
				if (move_x || move_y) context.translate(move_x, move_y)
				context.translate(-w / 2, -h / 2)

				// Apply CSS filters & linear transformations
				if (freeze.init) {
					freeze.canvas.context.drawImage(video, 0, 0, w, h)
					let data = freeze.canvas.element.toDataURL('image/png')
					freeze.image.setAttribute('src', data)
					freeze.init = false
				} else if (freeze.state) {
					// Draw frozen image
					context.drawImage(freeze.image, 0, 0, w, h)
				} else if (video.srcObject) {
					// Draw video
					context.drawImage(video, 0, 0, w, h)
				} else {
					// Draw preview stripes if video doesn't exist
					'18, 100%, 68%; -10,100%,80%; 5, 90%, 72%; 48, 100%, 75%; 36, 100%, 70%; 20, 90%, 70%'
					.split(';')
						.forEach((color, index) => {
							context.fillStyle = `hsl(${color})`
							context.fillRect(index * w / 6, 0, w / 6, h)
						})
				}

				// Clear transforms & filters
				context.setTransform(1, 0, 0, 1, 0, 0)
				context.filter = 'brightness(1)'

				// Fade: cover the entire image with a single color
				if (fade) {
					let fade_lum = Math.sign(fade) * 100
					let fade_alpha = Math.abs(fade)

					context.fillStyle = `hsla(0,0%,${fade_lum}%,${fade_alpha})`
					context.fillRect(0, 0, w, h)
				}

				// Vignette: cover the edges of the image with a single color
				if (vignette) {
					let vignette_lum = Math.sign(vignette) * 100
					let vignette_alpha = Math.abs(vignette)
					let vignette_gradient = context.createRadialGradient(
						...center, 0,
						...center, Math.sqrt((w / 2) ** 2 + (h / 2) ** 2)
					)

					vignette_gradient.addColorStop(0, `hsla(0,0%,${vignette_lum}%,0`)
					vignette_gradient.addColorStop(1, `hsla(0,0%,${vignette_lum}%,${vignette_alpha}`)

					context.fillStyle = vignette_gradient
					context.fillRect(0, 0, w, h)

				}

				// Pillarbox: crop width
				if (pillarbox) {
					context.clearRect(0, 0, pillarbox, h)
					context.clearRect(w, 0, -pillarbox, h)
				}

				// Letterbox: crop height
				if (letterbox) {
					context.clearRect(0, 0, w, letterbox)
					context.clearRect(0, h, w, -letterbox)
				}

				// Text:
				if (text) {

					// Find out the font size that just fits

					const vw = 0.9 * (w - 2 * pillarbox)
					const vh = 0.9 * (h - 2 * letterbox)

					context.font = `bold ${vw}px ${display_fonts}`

					let char_metrics = context.measureText('0')
					let line_height = char_metrics.actualBoundingBoxAscent + char_metrics.actualBoundingBoxDescent
					let text_width = text.reduce(
						(max_width, current_line) => Math.max(
							max_width,
							context.measureText(current_line).width
						), 0 // Accumulator starts at 0
					)

					const font_size = Math.min(vw ** 2 / text_width, vh ** 2 / line_height / text.length)

					// Found the font size. Time to draw!

					context.font = `bold ${font_size}px ${display_fonts}`

					char_metrics = context.measureText('0')
					line_height = 1.5 * (char_metrics.actualBoundingBoxAscent + char_metrics.actualBoundingBoxDescent)

					context.lineWidth = font_size / 8
					context.strokeStyle = 'black'
					context.fillStyle = 'white'

					text.forEach((line, index) => {
						let x = center[0]
						let y = center[1] + line_height * (index - text.length / 2 + 0.5)
						context.strokeText(line, x, y)
						context.fillText(line, x, y)
					})
				}

				canvases.display.context.clearRect(0, 0, w, h)
				canvases.display.context.drawImage(canvases.buffer.element, 0, 0)
			}
			clearInterval(task)
			task = setInterval(draw, 33)
			const new_stream = canvases.display.element.captureStream(30)
			new_stream.addEventListener('inactive', () => {
				old_stream.getTracks().forEach(track => {
					track.stop()
				})
				canvases.display.context.clearRect(0, 0, w, h)
				video.srcObject = null
			})
			return new_stream
		}
	}

	MediaDevices.prototype.old_getUserMedia = MediaDevices.prototype.getUserMedia
	MediaDevices.prototype.getUserMedia = async constraints =>
		(constraints && constraints.video && !constraints.audio) ?
		new mercator_studio_MediaStream(await navigator.mediaDevices.old_getUserMedia(constraints)) :
		navigator.mediaDevices.old_getUserMedia(constraints)
})()