DOM

object

%charts

/phlo/resources/DOM/charts.phlo

Lightweight dependency-free SVG charts: sparkline, bars and donut. Use charts::spark/bars/donut.

Three small SVG charts rendered on the server, without a library and without a script, so they show up in a mail, a PDF and a page with a strict policy alike. They are meant for a number in context, not for exploring data: no axes, no legend, no tooltips.

chartsvgsparklinebarsdonutvisualization
static

charts :: spark ($values, $color = '#888', $w = 240, $h = 48, $label = null):string

line 10
Genereert een SVG-sparkline-grafiek op basis van de opgegeven waarden, waarbij de benodigde punten en het gebied voor weergave worden berekend.
$values = array_values(array_map('floatval', (array)$values))
if (!$values) return void
$max = max($values) ?: 1
$min = min($values)
$range = max(0.0001, $max - $min)
$count = count($values)
$step = $count > 1 ? $w / ($count - 1) : 0
$points = []
foreach ($values AS $i => $v){
	$x = round($i * $step, 1)
	$y = round($h - (($v - $min) / $range) * ($h - 6) - 3, 1)
	$points[] = "$x,$y"
}
$area = array_merge($points, [round($w, 1).comma.$h, '0,'.$h])
$id = 'g'.substr(md5(implode(comma, $points)), 0, 6)
$aria = $label !== null ? ' role="img" aria-label="'.esc($label).'"' : void
return '<svg viewBox="0 0 '.$w.' '.$h.'"'.$aria.' xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="none" style="display:block;width:100%;height:'.$h.'px"><defs><linearGradient id="'.$id.'" x1="0" x2="0" y1="0" y2="1"><stop offset="0%" stop-color="'.$color.'" stop-opacity=".4"/><stop offset="100%" stop-color="'.$color.'" stop-opacity="0"/></linearGradient></defs><polygon points="'.implode(space, $area).'" fill="url(#'.$id.')"/><polyline points="'.implode(space, $points).'" fill="none" stroke="'.$color.'" stroke-width="1.6" stroke-linejoin="round" stroke-linecap="round"/></svg>'
static

charts :: bars ($values, $color = '#888', $w = 240, $h = 48, $label = null):string

line 30
Genereert een SVG-balkdiagram op basis van de opgegeven waarden, waarbij de hoogte en positie van elke balk ten opzichte van de maximale waarde worden berekend.
$values = array_values(array_map('floatval', (array)$values))
if (!$values) return void
$max = max($values) ?: 1
$count = count($values)
$gap = 2
$bw = max(1, ($w - ($count - 1) * $gap) / $count)
$out = void
foreach ($values AS $i => $v){
	$bh = round(($v / $max) * ($h - 4), 1)
	$x = round($i * ($bw + $gap), 1)
	$y = round($h - $bh, 1)
	$out .= '<rect x="'.$x.'" y="'.$y.'" width="'.round($bw, 1).'" height="'.max(0.5, $bh).'" fill="'.$color.'" rx="1.5"/>'
}
$aria = $label !== null ? ' role="img" aria-label="'.esc($label).'"' : void
return '<svg viewBox="0 0 '.$w.' '.$h.'"'.$aria.' xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="none" style="display:block;width:100%;height:'.$h.'px">'.$out.'</svg>'
static

charts :: donut ($parts, $colors = null, $size = 120):string

line 48
Genereert een SVG donutgrafiek op basis van de opgegeven delen, waarbij de verhoudingen en kleuren voor elk segment worden berekend.
$parts = array_filter(array_map('floatval', (array)$parts))
$total = array_sum($parts)
if (!$total) return void
$colors ??= ['#FFC76B', '#6CC0FF', '#9D7BFF', '#6BE39E', '#FF8A5C', '#FF6B6B']
$cx = $size / 2
$cy = $size / 2
$r = $size * 0.4
$circumference = 2 * M_PI * $r
$out = void
$rotate = -90
$i = 0
foreach ($parts AS $value){
	$len = ($value / $total) * $circumference
	$color = $colors[$i % count($colors)]
	$out .= '<circle cx="'.$cx.'" cy="'.$cy.'" r="'.$r.'" fill="none" stroke="'.$color.'" stroke-width="'.($size * 0.18).'" stroke-dasharray="'.$len.space.$circumference.'" transform="rotate('.$rotate.space.$cx.space.$cy.')"/>'
	$rotate += ($value / $total) * 360
	$i++
}
return '<svg viewBox="0 0 '.$size.' '.$size.'" xmlns="http://www.w3.org/2000/svg" style="display:block;width:'.$size.'px;height:'.$size.'px">'.$out.'<circle cx="'.$cx.'" cy="'.$cy.'" r="'.($r - $size * 0.09).'" fill="rgba(255,255,255,.04)"/></svg>'
object

%cookiewall

/phlo/resources/DOM/cookiewall.phlo

Subtle GDPR cookie-consent banner. English by default; auto-translates when the lang system (en()) is loaded. Override prop labels for a fixed language, or prop translate to force it on/off.

Asks once and keeps the answer in a cookie, so it stays out of the way afterwards. canTrack and canAnalytics are what the rest of the app should ask before it loads anything; the banner itself blocks nothing. It translates itself when the language system is loaded, so it speaks the visitor's language without a second set of texts.

gdprconsentcookiesprivacy
static

cookiewall :: __handle

line 11
Deze methode beheert de cookie wall-functionaliteit en regelt de gebruikersconsent voor cookies.
null
prop

%cookiewall -> choice

line 13
Haal de waarde van 'cookieChoice' op uit het %cookies-object, en retourneer null als deze niet is ingesteld.
%cookies->cookieChoice ?? null
method

%cookiewall -> hasChosen:bool

line 14
Controleert of er een keuze is gemaakt door te verifiëren dat de keuze niet null is.
$this->choice !== null
method

%cookiewall -> canTrack:bool

line 15
Bepaalt of tracking is toegestaan op basis van de keuze van de gebruiker, specifiek als deze is ingesteld op 'all'.
$this->choice === 'all'
method

%cookiewall -> canAnalytics:bool

line 16
Controleert of de huidige keuze is ingesteld op 'all' om te bepalen of analytics kan worden ingeschakeld.
$this->choice === 'all'
prop

%cookiewall -> translate:bool

line 18
Controleert of de functie 'en' bestaat in de huidige scope.
function_exists('en')
prop

%cookiewall -> labels:array

line 19
Definieert een set labels voor een cookie-toestemmingsmuur, inclusief de titel van de regio, de tekst van de body en de knoppenlabels.
arr(
	region:    'Cookie choice',
	body:      'We use essential cookies to make this site work. With your permission we also use analytics to improve the site.',
	essential: 'Essential only',
	accept:    'Accept',
)
method

%cookiewall -> label ($key):string

line 25
Haal het label op dat is gekoppeld aan de opgegeven sleutel, en vertaal het indien vertaling is ingeschakeld.
$this->translate ? en($this->labels[$key]) : $this->labels[$key]
route

route async POST cookiewall accept all

line 27
Definieert een route die een cookie instelt waarin staat dat de gebruiker alle cookies heeft geaccepteerd en verwijdert de cookiewall view asynchroon.
%cookies->objSet('cookieChoice', 'all', ['expires' => time() + 60 * 60 * 24 * 365, 'httponly' => false])
apply(remove: '#cookiewall')
route

route async POST cookiewall accept essential

line 32
Deze route stelt een cookie in met de naam 'cookieChoice' met de waarde 'essential' die over een jaar verloopt en past vervolgens een verwijdering toe van het element met de ID 'cookiewall'.
%cookies->objSet('cookieChoice', 'essential', ['expires' => time() + 60 * 60 * 24 * 365, 'httponly' => false])
apply(remove: '#cookiewall')
view

%cookiewall -> banner

line 37
Toont een cookie-toestemmingsmuur die gebruikers vraagt om essentiële of alle cookies te accepteren als ze nog geen keuze hebben gemaakt.
<if !$this->hasChosen()>
	<div#cookiewall role=region aria-label="{{ $this->label('region') }}">
		<p>{{ $this->label('body') }}</p>
		<div.actions>
			<form.async method=post action=/cookiewall/accept/essential>
				<button.ghost type=submit>{{ $this->label('essential') }}</button>
			</form>
			<form.async method=post action=/cookiewall/accept/all>
				<button.primary type=submit>{{ $this->label('accept') }}</button>
			</form>
		</div>
	</div>
</if>
view

style

line 52
Definieert de CSS-stijlen voor de cookiewall-component, inclusief lay-out, kleuren en responsief ontwerp.
#cookiewall {
	background: #1a1a1a
	border-radius: 8px
	bottom: 16px
	box-shadow: 0 8px 32px #0004
	color: #fff
	font-size: 13px
	left: 16px
	line-height: 1.5
	max-width: 360px
	padding: 14px 16px
	position: fixed
	right: 16px
	z-index: 9999
	@media(min-width: 600px): right: auto
	p: margin: 0 0 10px
	.actions {
		display: flex
		gap: 8px
		justify-content: flex-end
	}
	button {
		border-radius: 4px
		border: 0
		cursor: pointer
		font-size: 12px
		padding: 6px 12px
	}
	button.ghost {
		background: #444
		color: #fff
	}
	button.primary {
		background: #fff
		color: #1a1a1a
		font-weight: 600
	}
}
object

%CSS_fixes

/phlo/resources/DOM/CSS.fixes.phlo

Single Page App basic CSS boilerplate fixes

The handful of corrections nearly every app makes anyway: border-box sizing, no tap delay on anything clickable, no spinners on a number field, collapsed table borders and a [hidden] that actually hides. Note that it also clears the focus outline on inputs and buttons, so give focus a visible state of your own or keyboard users lose their place.

cssfixesboilerplatereset
view

style

line 10
*, ::before, ::after: box-sizing: border-box
a, area, button, input, label, select, summary, textarea, [tabindex]: touch-action: manipulation
button:focus, input:focus, select:focus, textarea:focus, [contenteditable]:focus: outline: 0
input::-webkit-outer-spin-button, input::-webkit-inner-spin-button: -webkit-appearance: none
input[type="number"]: -moz-appearance: textfield
table: border-collapse: collapse
[hidden]: display: none !important
::-ms-expand: display: none
object

%CSS_var

/phlo/resources/DOM/CSS.var.phlo

CSS variable proxy via app.var

app.var reads and writes CSS custom properties as if they were an object, so the server can change a colour, a size or a spacing with a command instead of a stylesheet swap. It writes on the root element, so what you set applies everywhere that inherits it.

cssvariablesapp.varfrontend
view

script

line 10
Definieert een proxy voor het openen en wijzigen van CSS-aangepaste eigenschappen (variabelen) op het root-element van het document, waardoor dynamische updates en het ophalen van hun waarden mogelijk zijn.
Object.defineProperty(app, 'var', {get(){return new Proxy({}, {get(_, key){return getComputedStyle(document.documentElement).getPropertyValue(`--${key}`).trim()}, set(_, key, value){ return document.documentElement.style.setProperty(`--${key}`, value)}})}, configurable: true})
app.mod.setvar = (key, value) => app.var[key] = value
object

%datatags

/phlo/resources/DOM/datatags.phlo

Single Page App datatag plugin

Turn any element into a request without writing a handler: data-get, data-post, data-put, data-patch or data-delete holds the path, and with post, put and patch every other data attribute travels along as a field. That is why an element that also carries data-confirm is left alone here: the dialog resource asks first and clicks it again afterwards. Attribute names arrive dash-lowered as the browser gives them, so keep them one word.

domdatatagdatasetspaevents
view

script

line 11
Behandelt klikgebeurtenissen op elementen met data-attributen voor HTTP-methoden, voorkomt standaardacties en voert de bijbehorende methode uit met het opgegeven pad en gegevens.
on('click', '[data-get], [data-post], [data-put], [data-patch], [data-delete]', (el, e) => {
	if (el.dataset.confirm) return
	e.preventDefault()
	let method, path, data = null
	if ((path = el.dataset.get) !== undefined) method = 'get'
	else if ((path = el.dataset.delete) !== undefined) method = 'delete'
	else [data = {}, Object.keys(el.dataset).forEach(key => key === 'post' || key === 'put' || key === 'patch' ? [method = key, path = el.dataset[key]] : data[key] = el.dataset[key])]
	app[method](path, data)
})
object

%dialog

/phlo/resources/DOM/dialog.phlo

Single Page App dialog resource

Replaces alert, confirm and prompt with a real dialog element, so they no longer block the page and no longer break an automated session. They answer a promise, so await them. Put data-confirm on a link or a button to ask before it does anything: the question is asked once, then the original action runs.

domdialogmodalconfirmpromptalert
view

script

line 11
Maakt en beheert dialoogvensters voor waarschuwingen, bevestigingen en prompts in een Phlo-applicatie, waardoor gebruikersinteractie via modale dialoogvensters mogelijk is.
window.alert = app.mod.alert = msg => phlo.dialog('alert', msg)
window.confirm = msg => phlo.dialog('confirm', msg)
window.prompt = (msg, defaultValue) => phlo.dialog('prompt', msg, defaultValue)

phlo.dialog = async (type, message, defaultValue = '') => new Promise(resolve => {
	app.mod.append('body', '<dialog id="phloDialog" class="phlo-dialog" role="dialog" aria-modal="true">\n<form method="dialog">\n<p class="phlo-dialog__message"></p>\n' + (type === 'prompt' ? '<input class="phlo-dialog__input" name="value">' : '') + '\n<menu class="phlo-dialog__actions">\n<button value="1" autofocus>OK</button>\n' + (type !== 'alert' ? '<button value="0">Cancel</button>' : '') + '\n</menu>\n</form>\n</dialog>')
	const dialog = obj('#phloDialog')
	const messageEl = dialog.querySelector('.phlo-dialog__message')
	messageEl && (messageEl.textContent = String(message ?? ''))
	if (type === 'prompt') dialog.querySelector('input').value = String(defaultValue ?? '')
	dialog.showModal()
	dialog.addEventListener('close', () => {
		const value = dialog.returnValue
		const input = dialog.querySelector('input')
		dialog.remove()
		if (type === 'alert') return resolve()
		if (type === 'confirm') return resolve(value === '1')
		if (type === 'prompt') return resolve(value === '1' ? input.value : null)
	})
})

on('click', '[data-confirm]', async (el, e) => {
	e.preventDefault()
	if (!await window.confirm(el.dataset.confirm)) return
	delete el.dataset.confirm
	app.update()
	el.click()
})
object

%exists

/phlo/resources/DOM/exists.phlo

onExist helper for dynamic SPA elements

on() binds to what is there now, which is why it does not survive a page swap; onExist runs your callback the first time an element appears and only then, however it got there. That makes it the right hook for anything a plugin has to prepare once, and it is what the store and the numpad use themselves.

domonexistspalifecycle
view

script

line 11
Deze functie houdt elementen en hun bijbehorende callbacks bij en voert de callbacks uit voor elementen die bestaan maar nog niet eerder zijn geregistreerd.
phlo.exist = []
phlo.existing = new WeakMap

const onExist = (els, cb) => phlo.exist.push({els, cb})

app.updates.push(() => {
	const existing = []
	phlo.exist.forEach(item => objects(item.els).forEach(el => phlo.existing.has(el) || existing.push({el, cb: item.cb})))
	existing.forEach(item => [phlo.existing.has(item.el) || phlo.existing.set(item.el, 'exist'), item.cb(item.el)])
})
object

%ffmpeg

/phlo/resources/DOM/ffmpeg.phlo

ffmpeg-wasm for the DOM: encode a canvas timeline to MP4, decode source frames via WebCodecs (seek fallback), transcode/run arbitrary ffmpeg. Exposes the ready singleton `ffmpeg` (and class `Ffmpeg`).

Encodes and converts video in the browser through ffmpeg-wasm, so no file has to leave the machine and no server has to be equipped for it. That comes at a price: several megabytes of wasm on first use, and encoding costs real time and memory, so it fits a clip rather than an hour of video. It loads the multithreaded core only on a cross-origin isolated page and falls back to a single-threaded one otherwise, which still works but is markedly slower.

videoffmpegwasmcanvasencodetranscodewebcodecsmp4render
view

script

line 17
ffmpeg-wasm brought to the browser DOM.
class Ffmpeg {

	constructor(opts = {}){
		this.corePath = opts.corePath || '/ffmpeg/'
		this.mp4boxURL = opts.mp4boxURL || '/mp4box.js'
		this.muxerURL = opts.muxerURL || '/mp4-muxer.js'
		this.log = opts.log || (() => {})
		this.ffmpeg = null
		this.mode = null
		this.scripts = {}
		this.demuxKey = null
		this.demuxVal = null
	}

	loadScript(src){
		if (this.scripts[src]) return this.scripts[src]
		this.scripts[src] = new Promise((resolve, reject) => {
			const el = document.createElement('script')
			el.src = src
			const nonce = document.querySelector('meta[name="nonce"]')
			if (nonce) el.nonce = nonce.content
			el.onload = () => resolve()
			el.onerror = () => {
				delete this.scripts[src]
				reject(new Error('ffmpeg: failed to load ' + src))
			}
			document.head.appendChild(el)
		})
		return this.scripts[src]
	}

	async frameSource(src, opts = {}){
		const from = opts.from || 0
		const to = opts.to || 0
		const mp4boxURL = opts.mp4boxURL || this.mp4boxURL
		const log = opts.log || this.log
		const isFile = (typeof File !== 'undefined') && src instanceof File
		const seek = () => this.seekSource(isFile ? URL.createObjectURL(src) : src, isFile)
		if (!('VideoDecoder' in window)) return seek()
		let wc = null
		try {
			await this.loadScript(mp4boxURL)
			const size = await this.sourceSize(src)
			const key = isFile ? src.name + ':' + src.size + ':' + src.lastModified : src
			let demuxed
			if (this.demuxKey === key) demuxed = this.demuxVal
			else {
				demuxed = await this.demux(src, size)
				this.demuxKey = key
				this.demuxVal = demuxed
			}
			const support = await VideoDecoder.isConfigSupported(demuxed.config)
			if (!support.supported){
				log('[ffmpeg] codec ' + demuxed.config.codec + ' not decodable, using seek')
				return seek()
			}
			log('[ffmpeg] WebCodecs ' + demuxed.config.codec + ', ' + demuxed.samples.length + ' frames, ' + Math.round(size / 1048576) + ' MiB streamed')
			const build = async acceleration => {
				const config = acceleration ? Object.assign({}, demuxed.config, {hardwareAcceleration: acceleration}) : demuxed.config
				const source = this.webCodecsSource(src, size, config, demuxed.samples, from, to)
				try {
					if (!await source.getFrame(from)) throw new Error('decoder produced no frames')
				}
				catch (err){
					source.close()
					throw err
				}
				return source
			}
			try {
				wc = await build()
			}
			catch (err){
				log('[ffmpeg] hardware decode failed (' + (err && err.message ? err.message : err) + '), retrying software')
				wc = await build('prefer-software')
			}
			return wc
		}
		catch (err){
			if (wc) try { wc.close() } catch (e){}
			log('[ffmpeg] WebCodecs unavailable (' + (err && err.message ? err.message : err) + '), using seek')
			return seek()
		}
	}

	async sourceSize(src){
		if ((typeof File !== 'undefined') && src instanceof File) return src.size
		const res = await fetch(src, {method: 'HEAD'})
		const length = res.headers.get('content-length')
		if (!length) throw new Error('source size unknown')
		return parseInt(length, 10)
	}

	async readRange(src, offset, length){
		if ((typeof File !== 'undefined') && src instanceof File) return new Uint8Array(await src.slice(offset, offset + length).arrayBuffer())
		const res = await fetch(src, {headers: {Range: 'bytes=' + offset + '-' + (offset + length - 1)}})
		if (res.status !== 206) throw new Error('no range support: ' + res.status)
		return new Uint8Array(await res.arrayBuffer())
	}

	async demux(src, size){
		const mp4 = MP4Box.createFile()
		let info = null
		let error = null
		mp4.onReady = i => info = i
		mp4.onError = e => error = e
		const step = 1 << 20
		let next = 0
		while (!info && !error && next < size){
			const bytes = await this.readRange(src, next, Math.min(step, size - next))
			if (!bytes.length) break
			const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength)
			buffer.fileStart = next
			const parsed = mp4.appendBuffer(buffer)
			if (parsed == null || parsed <= next) break
			next = parsed
		}
		if (error) throw new Error('demux error: ' + error)
		const track = info && info.videoTracks && info.videoTracks[0]
		if (!track) throw new Error('no video track')
		const trak = mp4.getTrackById(track.id)
		let description = null
		for (const entry of trak.mdia.minf.stbl.stsd.entries){
			const box = entry.avcC || entry.hvcC || entry.vpcC || entry.av1C
			if (box){
				const stream = new DataStream(undefined, 0, DataStream.BIG_ENDIAN)
				box.write(stream)
				description = new Uint8Array(stream.buffer, 8)
				break
			}
		}
		const samples = (trak.samples || []).map(s => ({offset: s.offset, size: s.size, cts: s.cts, duration: s.duration, timescale: s.timescale, key: !!s.is_sync}))
		if (!samples.length) throw new Error('demux produced no samples')
		return {config: {codec: track.codec, codedWidth: track.video.width, codedHeight: track.video.height, description}, samples}
	}

	webCodecsSource(src, size, config, samples, startT, endT){
		const decoded = []
		let error = null
		let waiter = null
		let target = -Infinity
		const signal = () => {
			if (!waiter) return
			const w = waiter
			waiter = null
			w()
		}
		const prune = () => {
			let keep = 0
			for (let i = 0; i < decoded.length; i++){
				if (decoded[i].timestamp <= target + 1000) keep = i
				else break
			}
			for (let i = 0; i < keep; i++) decoded[i].close()
			if (keep) decoded.splice(0, keep)
		}
		const decoder = new VideoDecoder({
			output: f => {
				decoded.push(f)
				prune()
				signal()
			},
			error: e => {
				error = e
				signal()
			},
		})
		decoder.configure(config)

		const seconds = s => s.cts / s.timescale
		let fed = 0
		let last = samples.length - 1
		for (let i = 0; i < samples.length; i++) if (samples[i].key && seconds(samples[i]) <= startT) fed = i
		if (endT > startT){
			for (let i = samples.length - 1; i >= fed; i--) if (seconds(samples[i]) <= endT){
				last = Math.min(samples.length - 1, i + 2)
				break
			}
		}

		const blockSize = 8 << 20
		const self = this
		let block = null
		const ensure = async (offset, need) => {
			if (block && offset >= block.start && offset + need <= block.end) return
			const end = Math.min(size, Math.max(offset + need, offset + blockSize))
			block = {start: offset, end, bytes: await self.readRange(src, offset, end - offset)}
		}
		const nextChunk = async () => {
			const s = samples[fed++]
			await ensure(s.offset, s.size)
			const at = s.offset - block.start
			return new EncodedVideoChunk({
				type: s.key ? 'key' : 'delta',
				timestamp: Math.round(s.cts * 1e6 / s.timescale),
				duration: Math.round((s.duration || 0) * 1e6 / s.timescale),
				data: block.bytes.subarray(at, at + s.size),
			})
		}
		let flushed = false

		const pump = async tMicros => {
			target = tMicros
			while (!error){
				prune()
				if (decoded.length && decoded[decoded.length - 1].timestamp > tMicros) return
				if (fed <= last){
					while (fed <= last && decoder.decodeQueueSize < 4 && decoded.length < 4) decoder.decode(await nextChunk())
					await new Promise(r => {
						waiter = r
						setTimeout(signal, 100)
					})
				}
				else {
					if (!flushed){
						flushed = true
						await decoder.flush().catch(e => error = e)
					}
					return
				}
			}
		}

		return {
			mode: 'webcodecs',
			async getFrame(t){
				const tM = Math.round(t * 1e6)
				await Promise.race([pump(tM), new Promise((res, rej) => setTimeout(() => rej(new Error('WebCodecs decode timeout')), 20000))])
				if (error) throw error
				let idx = 0
				for (let i = 0; i < decoded.length; i++){
					if (decoded[i].timestamp <= tM + 1000) idx = i
					else break
				}
				for (let i = 0; i < idx; i++) decoded[i].close()
				decoded.splice(0, idx)
				return decoded[0] || null
			},
			close(){
				block = null
				for (const f of decoded) f.close()
				decoded.length = 0
				try {
					decoder.close()
				}
				catch (e){}
			},
		}
	}

	seekTo(rv, time){
		return new Promise((resolve, reject) => {
			const timer = setTimeout(() => reject(new Error('seek timeout')), 20000)
			rv.addEventListener('seeked', function once(){
				rv.removeEventListener('seeked', once)
				clearTimeout(timer)
				resolve()
			})
			rv.currentTime = time
		})
	}

	async seekSource(srcUrl, revoke){
		const rv = document.createElement('video')
		rv.muted = true
		rv.preload = 'auto'
		rv.src = srcUrl
		const self = this
		await new Promise((resolve, reject) => {
			const timer = setTimeout(() => reject(new Error('video load timeout')), 20000)
			rv.addEventListener('loadedmetadata', () => {
				clearTimeout(timer)
				resolve()
			}, {once: true})
			rv.addEventListener('error', () => {
				clearTimeout(timer)
				reject(new Error('Could not load video for rendering'))
			}, {once: true})
		})
		return {
			mode: 'seek',
			async getFrame(t){
				await self.seekTo(rv, Math.min(t, (rv.duration || t + 1) - 0.001))
				return rv
			},
			close(){
				rv.removeAttribute('src')
				rv.load()
				if (revoke) URL.revokeObjectURL(srcUrl)
			},
		}
	}

	drop(){
		if (!this.ffmpeg) return
		try {
			this.ffmpeg.terminate()
		}
		catch (e){}
		this.ffmpeg = null
	}

	async load(log){
		log = log || this.log
		await this.loadScript(this.corePath + 'ffmpeg.js')
		const mode = (window.crossOriginIsolated && !window.ffmpegForceST) ? 'mt' : 'st'
		if (this.ffmpeg && this.ffmpeg.loaded && this.mode === mode) return this.ffmpeg
		this.drop()
		log('[ffmpeg] loading core: ' + mode)
		this.ffmpeg = new FFmpegWASM.FFmpeg()
		this.ffmpeg.on('log', e => log('[ffmpeg] ' + e.message))
		this.mode = mode
		const base = new URL(this.corePath, location.origin).href
		await this.ffmpeg.load(mode === 'mt'
			? {coreURL: base + 'ffmpeg-core.js', wasmURL: base + 'ffmpeg-core.wasm', workerURL: base + 'ffmpeg-core.worker.js'}
			: {coreURL: base + 'ffmpeg-core-st.js', wasmURL: base + 'ffmpeg-core-st.wasm'})
		return this.ffmpeg
	}

	async exec(args, log){
		log = log || this.log
		const ff = this.ffmpeg
		log('[ffmpeg] $ ffmpeg ' + args.join(' '))
		let beat = performance.now()
		const bump = () => beat = performance.now()
		ff.on('log', bump)
		ff.on('progress', bump)
		const watchdog = setInterval(() => {
			if (performance.now() - beat > 25000) ff.terminate()
		}, 4000)
		try {
			await ff.exec(args)
		}
		finally {
			clearInterval(watchdog)
			ff.off('log', bump)
			ff.off('progress', bump)
		}
	}

	async withFallback(fn, onProgress, log){
		log = log || this.log
		try {
			return await fn()
		}
		catch (err){
			if (this.mode !== 'mt') throw err
			log('[ffmpeg] multithreaded stalled (' + (err && err.message ? err.message : err) + '); retrying single-threaded')
			window.ffmpegForceST = true
			this.drop()
			if (onProgress) onProgress('Multithreaded encoder stalled; retrying single-threaded...', 3)
			return fn()
		}
	}

	async run(opts){
		const log = opts.log || this.log
		return this.withFallback(async () => {
			const ff = await this.load(log)
			const inName = opts.inputName || 'input'
			const outName = opts.output || 'output'
			const cleanups = []
			if (opts.input != null){
				const src = opts.input
				const bytes = src instanceof Uint8Array ? src
					: (typeof Blob !== 'undefined' && src instanceof Blob ? new Uint8Array(await src.arrayBuffer())
					: new Uint8Array(await fetch(src).then(r => r.arrayBuffer())))
				await ff.writeFile(inName, bytes)
				cleanups.push(() => ff.deleteFile(inName).catch(() => {}))
			}
			try {
				await this.exec(opts.args, log)
				const out = await ff.readFile(outName)
				cleanups.push(() => ff.deleteFile(outName).catch(() => {}))
				return new Blob([out.buffer], {type: opts.type || 'video/mp4'})
			}
			finally {
				for (const c of cleanups) await c()
			}
		}, opts.onProgress, log)
	}

	async transcode(input, opts = {}){
		const to = opts.to || 'mp4'
		const inName = 'tc-in'
		const outName = 'tc-out.' + to
		const args = ['-i', inName]
		if (opts.args) args.push(...opts.args)
		else args.push('-c:v', 'libx264', '-preset', 'veryfast', '-crf', String(opts.crf || 20), '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-b:a', '192k', '-movflags', '+faststart')
		args.push(outName)
		return this.run({input, inputName: inName, output: outName, args, type: opts.type || 'video/' + to, log: opts.log, onProgress: opts.onProgress})
	}

	async encode(opts){
		this.demuxKey = null
		this.demuxVal = null
		const log = opts.log || this.log
		if ('VideoEncoder' in window && !window.ffmpegForceEncoder){
			try {
				return await this.encodeWebCodecs(opts)
			}
			catch (err){
				log('[ffmpeg] hardware encode failed (' + (err && err.message ? err.message : err) + '), retrying software')
				try {
					return await this.encodeWebCodecs(Object.assign({}, opts, {acceleration: 'prefer-software'}))
				}
				catch (err2){
					log('[ffmpeg] WebCodecs encode failed (' + (err2 && err2.message ? err2.message : err2) + '), using ffmpeg')
				}
			}
		}
		return this.withFallback(() => this.pass(opts), opts.onProgress, log)
	}

	async pickVideoCodec(width, height, fps, bitrate, acceleration){
		for (const codec of ['avc1.640028', 'avc1.4d0028', 'avc1.42001f']){
			const config = {codec, width, height, bitrate, framerate: fps}
			if (acceleration) config.hardwareAcceleration = acceleration
			try {
				const support = await VideoEncoder.isConfigSupported(config)
				if (support.supported) return codec
			}
			catch (e){}
		}
		return null
	}

	async encodeWebCodecs(opts){
		const log = opts.log || this.log
		const fps = opts.fps
		const from = opts.from || 0
		const to = opts.to
		const onProgress = opts.onProgress || (() => {})
		const audio = opts.audio || []
		const W = Math.round(opts.width) - (Math.round(opts.width) % 2)
		const H = Math.round(opts.height) - (Math.round(opts.height) % 2)
		const total = Math.max(1, Math.round((to - from) * fps))
		const bitrate = opts.bitrate || Math.round(W * H * fps * 0.15)

		await this.loadScript(this.muxerURL)
		const codec = await this.pickVideoCodec(W, H, fps, bitrate, opts.acceleration)
		if (!codec) throw new Error('no supported h264 encoder configuration')
		log('[ffmpeg] WebCodecs encode ' + W + 'x' + H + ' @' + fps + 'fps, ' + total + ' frames, ' + codec + ', ' + Math.round(bitrate / 1000) + ' kbps' + (opts.acceleration ? ', ' + opts.acceleration : ''))

		const target = new Mp4Muxer.ArrayBufferTarget()
		const muxer = new Mp4Muxer.Muxer({target, video: {codec: 'avc', width: W, height: H}, fastStart: 'in-memory'})
		let error = null
		const encoder = new VideoEncoder({
			output: (chunk, meta) => muxer.addVideoChunk(chunk, meta),
			error: e => error = e,
		})
		const config = {codec, width: W, height: H, bitrate, framerate: fps, avc: {format: 'avc'}}
		if (opts.acceleration) config.hardwareAcceleration = opts.acceleration
		encoder.configure(config)

		const canvas = opts.canvas || document.createElement('canvas')
		canvas.width = W
		canvas.height = H
		const ctx = canvas.getContext('2d', {willReadFrequently: false})
		const keyEvery = Math.max(1, Math.round(fps * 2))

		let video
		if (opts.beforePass) await opts.beforePass()
		try {
			for (let i = 0; i < total; i++){
				if (error) throw error
				await opts.drawFrame(ctx, from + i / fps, W, H)
				const frame = new VideoFrame(canvas, {timestamp: Math.round(i * 1e6 / fps), duration: Math.round(1e6 / fps)})
				encoder.encode(frame, {keyFrame: i % keyEvery === 0})
				frame.close()
				while (encoder.encodeQueueSize > 8 && !error) await new Promise(r => setTimeout(r, 0))
				onProgress('Rendering frame ' + (i + 1) + '/' + total, 5 + 85 * (i + 1) / total)
			}
			if (error) throw error
			await Promise.race([encoder.flush(), new Promise((res, rej) => setTimeout(() => rej(new Error('encoder flush timeout')), 30000))])
			if (error) throw error
			muxer.finalize()
			video = new Blob([target.buffer], {type: 'video/mp4'})
		}
		finally {
			try {
				encoder.close()
			}
			catch (e){}
			if (opts.afterPass) await opts.afterPass()
		}
		log('[ffmpeg] WebCodecs video ' + video.size + ' bytes')
		if (!audio.length) return video
		return this.muxAudio(video, audio, onProgress, log)
	}

	async muxAudio(video, audio, onProgress, log){
		log = log || this.log
		onProgress('Muxing audio...', 92)
		const ff = await this.load(log)
		await ff.writeFile('wcv.mp4', new Uint8Array(await video.arrayBuffer()))
		let prepared
		try {
			prepared = await this.prepareAudio(ff, audio)
		}
		catch (err){
			log('[ffmpeg] audio unavailable, keeping silent video: ' + (err && err.message ? err.message : err))
			await ff.deleteFile('wcv.mp4').catch(() => {})
			return video
		}
		const args = ['-i', 'wcv.mp4'].concat(prepared.args)
		const ins = prepared.inputs
		if (ins.length === 1 && ins[0].volume === 1 && !ins[0].delay) args.push('-map', '0:v', '-map', '1:a?', '-c', 'copy', '-shortest', 'out.mp4')
		else if (ins.length === 1 && !ins[0].delay) args.push('-map', '0:v', '-map', '1:a?', '-c:v', 'copy', '-filter:a', 'volume=' + ins[0].volume, '-c:a', 'aac', '-b:a', '192k', '-shortest', 'out.mp4')
		else {
			const parts = ins.map((a, i) => '[' + (i + 1) + ':a]' + (a.delay ? 'adelay=' + Math.round(a.delay) + ':all=1,' : '') + 'volume=' + a.volume + '[a' + i + ']')
			const mix = ins.map((a, i) => '[a' + i + ']').join('') + 'amix=inputs=' + ins.length + ':duration=longest:normalize=0[aout]'
			args.push('-filter_complex', parts.join(';') + ';' + mix, '-map', '0:v', '-map', '[aout]', '-c:v', 'copy', '-c:a', 'aac', '-b:a', '192k', '-shortest', 'out.mp4')
		}
		try {
			await this.exec(args, log)
			onProgress('Reading result...', 98)
			const out = await ff.readFile('out.mp4')
			return new Blob([out.buffer], {type: 'video/mp4'})
		}
		catch (err){
			log('[ffmpeg] audio mux failed, keeping silent video: ' + (err && err.message ? err.message : err))
			video.silent = true
			return video
		}
		finally {
			await ff.deleteFile('wcv.mp4').catch(() => {})
			await ff.deleteFile('out.mp4').catch(() => {})
			await prepared.cleanup()
		}
	}

	async prepareAudio(ff, audio){
		const args = []
		const inputs = []
		const cleanups = []
		let mountIdx = 0
		for (const a of audio){
			if (!a) continue
			let path
			if (a.file){
				const dir = '/ffaud' + (mountIdx++)
				await ff.createDir(dir).catch(() => {})
				await ff.mount(FFmpegWASM.FFFSType.WORKERFS, {files: [a.file]}, dir)
				path = dir + '/' + a.file.name
				cleanups.push(() => ff.unmount(dir).catch(() => {}))
			}
			else {
				const ext = String(a.name || a.url || 'a.mp4').split('?')[0].split('.').pop() || 'mp4'
				const name = a.name || ('ffaud' + inputs.length + '.' + ext)
				const bytes = a.bytes ? a.bytes : new Uint8Array(await fetch(a.url).then(r => r.arrayBuffer()))
				await ff.writeFile(name, bytes)
				path = name
				cleanups.push(() => ff.deleteFile(name).catch(() => {}))
			}
			if (a.seek) args.push('-ss', String(a.seek))
			args.push('-i', path)
			inputs.push({volume: a.volume == null ? 1 : a.volume, delay: a.delay || 0})
		}
		return {args, inputs, cleanup: async () => { for (const c of cleanups) await c() }}
	}

	async pass(opts){
		const log = opts.log || this.log
		const fps = opts.fps
		const from = opts.from || 0
		const to = opts.to
		const drawFrame = opts.drawFrame
		const audio = opts.audio || []
		const onProgress = opts.onProgress || (() => {})
		const quality = opts.quality || 0.9
		const crf = opts.crf || 20
		const segmentFrames = opts.segmentFrames || 120
		const W = Math.round(opts.width) - (Math.round(opts.width) % 2)
		const H = Math.round(opts.height) - (Math.round(opts.height) % 2)
		const ff = await this.load(log)
		const canvas = opts.canvas || document.createElement('canvas')
		canvas.width = W
		canvas.height = H
		const ctx = canvas.getContext('2d', {willReadFrequently: false})
		const total = Math.max(1, Math.round((to - from) * fps))
		log('[ffmpeg] encode ' + W + 'x' + H + ' @' + fps + 'fps, ' + total + ' frames, ' + from.toFixed(2) + '-' + to.toFixed(2) + 's, mode=' + this.mode)

		if (opts.beforePass) await opts.beforePass()
		const segments = []
		try {
			let frame = 0
			let segIdx = 0
			while (frame < total){
				const count = Math.min(segmentFrames, total - frame)
				for (let i = 0; i < count; i++){
					const t = from + (frame + i) / fps
					await drawFrame(ctx, t, W, H)
					const blob = await new Promise(res => canvas.toBlob(res, 'image/jpeg', quality))
					await ff.writeFile('f' + String(i).padStart(6, '0') + '.jpg', new Uint8Array(await blob.arrayBuffer()))
					onProgress('Rendering frame ' + (frame + i + 1) + '/' + total, 5 + 75 * (frame + i + 1) / total)
				}
				const segName = 'seg' + segIdx + '.mp4'
				onProgress('Encoding segment ' + (segIdx + 1) + '...', 5 + 75 * (frame + count) / total)
				await this.exec(['-threads', '4', '-framerate', String(fps), '-i', 'f%06d.jpg', '-frames:v', String(count), '-c:v', 'libx264', '-preset', 'veryfast', '-crf', String(crf), '-pix_fmt', 'yuv420p', segName], log)
				for (let i = 0; i < count; i++) await ff.deleteFile('f' + String(i).padStart(6, '0') + '.jpg').catch(() => {})
				segments.push(segName)
				frame += count
				segIdx++
			}
		}
		finally {
			if (opts.afterPass) await opts.afterPass()
		}

		onProgress('Muxing audio...', 85)
		await ff.writeFile('list.txt', new TextEncoder().encode(segments.map(s => "file '" + s + "'").join('\n') + '\n'))
		const args = ['-f', 'concat', '-safe', '0', '-i', 'list.txt']
		let prepared = {args: [], inputs: [], cleanup: async () => {}}
		if (audio.length){
			try {
				prepared = await this.prepareAudio(ff, audio)
			}
			catch (err){
				log('[ffmpeg] audio unavailable, rendering silent: ' + (err && err.message ? err.message : err))
			}
		}
		args.push(...prepared.args)
		const ins = prepared.inputs
		if (!ins.length) args.push('-map', '0:v', '-an', '-c:v', 'copy', 'out.mp4')
		else if (ins.length === 1 && !ins[0].delay){
			args.push('-map', '0:v', '-map', '1:a?', '-c:v', 'copy')
			if (ins[0].volume !== 1) args.push('-filter:a', 'volume=' + ins[0].volume)
			args.push('-c:a', 'aac', '-b:a', '192k', '-shortest', 'out.mp4')
		}
		else {
			const parts = ins.map((a, i) => '[' + (i + 1) + ':a]' + (a.delay ? 'adelay=' + Math.round(a.delay) + ':all=1,' : '') + 'volume=' + a.volume + '[a' + i + ']')
			const mix = ins.map((a, i) => '[a' + i + ']').join('') + 'amix=inputs=' + ins.length + ':duration=longest:normalize=0[aout]'
			args.push('-filter_complex', parts.join(';') + ';' + mix, '-map', '0:v', '-map', '[aout]', '-c:v', 'copy', '-c:a', 'aac', '-b:a', '192k', '-shortest', 'out.mp4')
		}
		await this.exec(args, log)

		onProgress('Reading result...', 96)
		const out = await ff.readFile('out.mp4')
		log('[ffmpeg] output ' + out.length + ' bytes (' + segments.length + ' segments)')
		for (const s of segments) await ff.deleteFile(s).catch(() => {})
		await ff.deleteFile('list.txt').catch(() => {})
		await ff.deleteFile('out.mp4').catch(() => {})
		await prepared.cleanup()
		return new Blob([out.buffer], {type: 'video/mp4'})
	}
}

const ffmpeg = new Ffmpeg()
object

%form

/phlo/resources/DOM/form.phlo

Single Page App form handler and input state saver

A form with class async submits over the same channel as the rest and answers with commands rather than a new page, using its own method attribute. Apart from that, this keeps the DOM honest: what a visitor types is written back into the attributes, so the state that is saved and restored on a back button matches what is on screen.

domforminputstatespa
view

script

line 11
Behandelt invoergebeurtenissen voor formelementen, werkt hun attributen bij op basis van gebruikersinteractie en dient het formulier asynchroon in met de opgegeven methode.
on('input change', 'input, select, textarea', input => {
	if (input.tagName === 'SELECT') input.querySelectorAll('option').forEach((option, index) => option.selected ? option.setAttribute('selected', '') : option.removeAttribute('selected'))
	if (input.type === 'checkbox') input.checked ? input.setAttribute('checked', '') : input.removeAttribute('checked')
	if (input.type === 'text' && input.value !== input.getAttribute('value')) input.setAttribute('value', input.value)
	if (input.type === 'textarea' && input.value !== input.innerHTML) input.innerHTML = input.value
	phlo.state.replace()
	return false
})
on('submit', 'form.async', (form, e) => [e.preventDefault(), app[(form.attributes.method?.value ?? 'GET').toLowerCase()](new URL(form.action).pathname.substr(1), new FormData(form))])
object

%image_resizer

/phlo/resources/DOM/image.resizer.phlo

Client-side file upload image resizer

Scales a picked file down in the browser before it is uploaded, so a phone photo of several megabytes leaves as a few hundred kilobytes. It only shrinks, keeps the aspect ratio, and gives a data URL back through the callback, which can go straight into a preview or a form field. The image field uses it, so an upload through the CMS is already covered.

domimageresizeuploadcanvas
view

script

line 10
Verkleint een afbeeldingsbestand tot opgegeven maximale afmetingen terwijl de beeldverhouding behouden blijft, en retourneert de verkleinde afbeelding als een data-URL via een callbackfunctie.
const imageResizer = (file, maxWidth, maxHeight, cb, quality = .8) => {
	const img = new Image
	img.onload = () => {
		let width = img.width, height = img.height
		const aspectRatio = width / height
		if (width > maxWidth || height > maxHeight){
			if (width > height){
				width = maxWidth
				height = Math.round(maxWidth / aspectRatio)
			}
			else {
				height = maxHeight
				width = Math.round(maxHeight * aspectRatio)
			}
		}
		const canvas = document.createElement('canvas')
		canvas.width = width
		canvas.height = height
		canvas.getContext('2d').drawImage(img, 0, 0, width, height)
		cb(canvas.toDataURL(file.type, quality))
	}
	img.src = URL.createObjectURL(file)
}
object

%keyboard

/phlo/resources/DOM/keyboard.phlo

On-screen keyboard for touch devices, with selectable layout

Put data-keyboard on a field to open a keyboard on focus; data-keyboard="azerty" picks the layout. Inside a dialog the keys mount in that dialog; data-keyboard-dock on an element there places them in the flow. phlo.keyboard.layouts takes extra layouts.

domkeyboardonscreentouchinputfrontend
view

script

line 16
Every event is delegated from the body.
phlo.keyboard = {
	layouts: {
		qwerty: ['qwertyuiop', 'asdfghjkl', 'zxcvbnm'],
		azerty: ['azertyuiop', 'qsdfghjklm', 'wxcvbn'],
		qwertz: ['qwertzuiop', 'asdfghjkl', 'yxcvbnm'],
	},
	digits: '1234567890',
	shifted: {',': ';', '.': ':', '-': '_', "'": '"'},
	labels: {shift: '&uarr;', back: '&larr;', space: '&nbsp;', enter: '&crarr;', close: '&times;'},
	open: null,
	caps: false,
	layout: el => phlo.keyboard.layouts[el?.dataset.keyboard] ? el.dataset.keyboard : (el?.closest('[data-keyboard-layout]')?.dataset.keyboardLayout || 'qwerty'),
	key: (value, label, cls) => `<button class="keyboard__key${cls ? ' ' + cls : ''}" type="button" data-keyboard-key="${value}">${label ?? value}</button>`,
	rows(name){
		const layout = phlo.keyboard.layouts[name] || phlo.keyboard.layouts.qwerty
		const cast = char => phlo.keyboard.caps ? (phlo.keyboard.shifted[char] ?? char.toUpperCase()) : char
		const key = phlo.keyboard.key
		let html = '<div class="keyboard__row">' + [...phlo.keyboard.digits].map(char => key(char)).join('') + key('back', phlo.keyboard.labels.back, 'keyboard__key--wide') + '</div>'
		layout.forEach((row, index) => {
			html += '<div class="keyboard__row">'
			if (index === 2) html += key('shift', phlo.keyboard.labels.shift, 'keyboard__key--wide' + (phlo.keyboard.caps ? ' keyboard__key--on' : ''))
			html += [...row].map(char => key(cast(char))).join('')
			if (index === 2) html += key('enter', phlo.keyboard.labels.enter, 'keyboard__key--wide')
			html += '</div>'
		})
		html += '<div class="keyboard__row">' + [key('-'), key(cast("'")), key(' ', phlo.keyboard.labels.space, 'keyboard__key--space'), key(cast(',')), key(cast('.')), key('close', phlo.keyboard.labels.close, 'keyboard__key--close')].join('') + '</div>'
		return html
	},
	host(target){
		const root = target?.closest('dialog[open]') || document.body
		return obj('[data-keyboard-dock]', root) || root
	},
	render(){
		const target = phlo.keyboard.open
		if (!target) return
		const host = phlo.keyboard.host(target)
		const docked = host !== document.body && host.matches('[data-keyboard-dock]')
		if (obj('#keyboard')?.parentElement !== host) obj('#keyboard')?.remove()
		obj('#keyboard') || app.mod.append(host, '<div id="keyboard" class="keyboard' + (docked ? ' keyboard--docked' : '') + '" role="group" aria-label="On-screen keyboard"></div>')
		app.mod.inner('#keyboard', phlo.keyboard.rows(phlo.keyboard.layout(target)))
	},
	show(target){
		phlo.keyboard.open = target
		phlo.keyboard.caps = false
		phlo.keyboard.render()
	},
	hide(){
		phlo.keyboard.open = null
		obj('#keyboard')?.remove()
	},
	write(target, insert, back = false){
		const start = target.selectionStart ?? target.value.length
		const end = target.selectionEnd ?? start
		const from = back && start === end ? Math.max(0, start - 1) : start
		target.value = target.value.slice(0, from) + insert + target.value.slice(end)
		const caret = from + insert.length
		target.setSelectionRange?.(caret, caret)
		target.dispatchEvent(new Event('input', {bubbles: true}))
	},
	press(name){
		const target = phlo.keyboard.open
		if (!target) return
		if (!target.isConnected) return phlo.keyboard.hide()
		if (name === 'close') return phlo.keyboard.hide()
		if (name === 'shift'){
			phlo.keyboard.caps = !phlo.keyboard.caps
			return phlo.keyboard.render()
		}
		if (name === 'back') return phlo.keyboard.write(target, '', true)
		if (name === 'enter'){
			const form = target.closest('form')
			phlo.keyboard.hide()
			target.dispatchEvent(new Event('change', {bubbles: true}))
			return form?.requestSubmit()
		}
		phlo.keyboard.write(target, name)
		if (phlo.keyboard.caps){
			phlo.keyboard.caps = false
			phlo.keyboard.render()
		}
	},
}

app.keyboard = {
	show: selector => phlo.keyboard.show(obj(selector)),
	hide: () => phlo.keyboard.hide(),
}

on('focusin', 'body', (body, e) => {
	if (e.target.matches?.('[data-keyboard]')) phlo.keyboard.show(e.target)
})

on('click', 'body', (body, e) => {
	const el = e.target.closest?.('[data-keyboard-key]')
	if (!el) return
	e.preventDefault()
	phlo.keyboard.press(el.dataset.keyboardKey)
	phlo.keyboard.open?.focus({preventScroll: true})
})

on('pointerdown', 'body', (el, e) => {
	if (!phlo.keyboard.open) return
	if (e.target.closest('#keyboard') || e.target === phlo.keyboard.open) return
	const surface = phlo.keyboard.open.closest('dialog[open]')
	if (surface && surface.contains(e.target)) return
	phlo.keyboard.hide()
})

on('keydown', 'body', (el, e) => {
	if (e.key === 'Escape') phlo.keyboard.hide()
})

on('close', 'dialog', dialog => {
	if (phlo.keyboard.open && dialog.contains(phlo.keyboard.open)) phlo.keyboard.hide()
})
view

style

line 134
.keyboard {
	position: fixed
	left: 0
	right: 0
	bottom: 0
	z-index: 1000
	display: grid
	gap: var(--keyboard-gap, .3rem)
	padding: var(--keyboard-pad, .5rem)
	background: var(--keyboard-bg, #1c2029)
	color: var(--keyboard-color, #f2f2f2)
	box-shadow: 0 -2px 12px #0006
	touch-action: manipulation
	user-select: none
}
.keyboard--docked {
	position: static
	box-shadow: none
	padding: var(--keyboard-dock-pad, 0)
	background: var(--keyboard-dock-bg, transparent)
}
.keyboard__row {
	display: flex
	gap: var(--keyboard-gap, .3rem)
	justify-content: center
}
.keyboard__key {
	font: inherit
	font-size: var(--keyboard-size, 1.1rem)
	flex: 1 1 auto
	max-width: var(--keyboard-key, 4rem)
	padding: var(--keyboard-key-pad, .7rem .2rem)
	border: 1px solid var(--keyboard-border, #ffffff26)
	border-radius: var(--keyboard-radius, 6px)
	background: var(--keyboard-key-bg, #ffffff14)
	color: inherit
	cursor: pointer
	\:active: background: var(--keyboard-key-active, #ffffff2e)
}
.keyboard__key--wide {
	max-width: var(--keyboard-wide, 6rem)
}
.keyboard__key--space {
	max-width: var(--keyboard-space, 18rem)
}
.keyboard__key--on {
	background: var(--keyboard-key-on, #ffffff33)
}
object
/phlo/resources/DOM/link.phlo

Single Page App async link handler

A link with class async is fetched and swapped in instead of loading the page, and everything else keeps working: a target, a modifier click and an outside link go to the browser untouched. An anchor is remembered across the swap, so a deep link scrolls to the right place after the new content has arrived.

domlinkasyncnavigationspa
view

script

line 11
on('click', 'a', (a, e) => {
	if (e.ctrlKey || e.shiftKey || e.metaKey || a.target || a.dataset.confirm) return false
	const isAsync = a.classList.contains('async')
	const [uri, hash] = a.getAttribute('href').split('#')
	if (isAsync || hash) e.preventDefault()
	phlo.anchor = hash ? `#${hash}` : ''
	if (hash && (!uri || uri === location.pathname + location.search)) location.hash = phlo.anchor
	else if (isAsync) app.get(uri.substr(1))
})
object

%markdown

/phlo/resources/DOM/markdown.phlo

Client-side markdown parser

Parses markdown in the browser, for text that arrives after the page: a chat message, a preview while typing, an answer streaming in. It renders what it is given, so escape or clean anything a visitor wrote before showing it to someone else. Markdown that is already known at render time is cheaper to parse on the server.

dommarkdownparserfrontend
view

script

line 10
Parset Markdown-tekst naar HTML, met ondersteuning voor verschillende functies zoals GitHub Flavored Markdown, blokelementen, inline-elementen en referentielinks.
function parse_markdown(md, opts = {}){
  const o = {
    gfm: opts.gfm !== false,
    breaks: !!opts.breaks,
    headerIds: opts.headerIds !== false,
    headerPrefix: opts.headerPrefix || '',
    smartypants: !!opts.smartypants
  }
  const unnull = x => (x == null ? '' : String(x))
  let src = unnull(md).replace(/\r\n?/g, "\n")
  const escHtml = s => s.replace(/[&<>"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]))
  const trimEndNL = s => s.replace(/\s+$/,'')
  const isBlank = s => /^\s*$/.test(s)
  const slugmap = new Map()
  const slug = t => {
    let s = t.toLowerCase().replace(/<\/?[^>]+>/g, '').replace(/[^\p{L}\p{N}\- _]+/gu, '').trim().replace(/[\s_]+/g, '-')
    const base = o.headerPrefix + s
    let k = base, i = 1
    while (slugmap.has(k)) k = `${base}-${++i}`
    slugmap.set(k, true)
    return k
  }
  const smart = s => {
    if (!o.smartypants) return s
    return s.replace(/---/g, "-").replace(/--/g, "–").replace(/(^|[\s"(\[])(?=')/g, "$1‘").replace(/'/g, "’").replace(/(^|[\s(\[])(?=")/g, "$1“").replace(/"/g, "”").replace(/\.{3}/g, "…")
  }
  const refs = Object.create(null)
  src = src.replace(
    /^ {0,3}\[([^\]]+)\]:\s*<?([^\s>]+)>?(?:\s+(?:"([^"]*)"|'([^']*)'|\(([^)]+)\)))?\s*$/gm,
    (_, label, url, t1, t2, t3) => {
      const key = label.trim().replace(/\s+/g, ' ').toLowerCase()
      if (!refs[key]) refs[key] = { href: url, title: t1 || t2 || t3 || '' }
      return ''
    }
  )
  const tokens = []
  const lines = src.split("\n")
  function takeWhile(start, pred){
    let end = start
    while (end < lines.length && pred(lines[end], end)) end++
    return { start, end }
  }
  function pushParagraph(buf){
    const text = buf.join("\n").trimEnd()
    if (text) tokens.push({ type: "paragraph", text })
    buf.length = 0
  }
  function parseBlock(start = 0, end = lines.length){
    const para = []
    let l = start
    while (l < end){
      const line = lines[l]
      if (isBlank(line)){
        pushParagraph(para)
        l++
        continue
      }
      let m = line.match(/^ {0,3}(`{3,}|~{3,})([^\n]*)$/)
      if (m){
        pushParagraph(para)
        const fenceLen = m[1].length
        const info = (m[2] || '').trim()
        let body = []
        l++
        while (l < end){
          const s = lines[l]
          const close = s.match(new RegExp(`^ {0,3}${m[1][0]}{${fenceLen},}\\s*$`))
          if (close){
            l++
            break
          }
          body.push(s)
          l++
        }
        tokens.push({ type: "code", lang: info.split(/\s+/)[0] || '', text: trimEndNL(body.join("\n")) })
        continue
      }
      if (/^(?: {4}|\t)/.test(line)){
        pushParagraph(para)
        const { end: j } = takeWhile(l, s => /^(?: {4}|\t)/.test(s) || isBlank(s))
        const block = lines.slice(l, j).map(s => s.replace(/^(?: {4}|\t)/, '')).join("\n")
        tokens.push({ type: "code", lang: '', text: trimEndNL(block) })
        l = j
        continue
      }
      if (/^ {0,3}<(?:!--|\/?(?:html|head|body|pre|script|style|table|thead|tbody|tfoot|tr|td|th|div|p|h[1-6]|blockquote|ul|ol|li|section|article|aside|details|summary|figure|figcaption)\b)/i.test(line)){
        pushParagraph(para)
        const { end: j } = takeWhile(l, (s, idx) => !(idx > l && isBlank(lines[idx-1]) && isBlank(s)))
        const html = lines.slice(l, j).join("\n")
        tokens.push({ type: "html", text: html })
        l = j
        continue
      }
      if (/^ {0,3}(?:-+\s*|-{3,}|_{3,}|\*{3,})\s*$/.test(line)){
        pushParagraph(para)
        tokens.push({ type: "hr" })
        l++
        continue
      }
      m = line.match(/^ {0,3}(#{1,6})[ \t]*([^#\n]*?)[ \t#]*$/)
      if (m){
        pushParagraph(para)
        tokens.push({ type: "heading", depth: m[1].length, text: m[2].trim() })
        l++
        continue
      }
      if (l + 1 < end && /^[^\s].*$/.test(line) && /^ {0,3}(=+|-+)\s*$/.test(lines[l + 1])){
        pushParagraph(para)
        const depth = lines[l + 1].trim().startsWith("=") ? 1 : 2
        tokens.push({ type: "heading", depth, text: line.trim() })
        l += 2
        continue
      }
      if (/^ {0,3}>\s?/.test(line)){
        pushParagraph(para)
        const { end: j } = takeWhile(l, s => /^ {0,3}>\s?/.test(s) || isBlank(s))
        const inner = lines.slice(l, j).map(s => s.replace(/^ {0,3}>\s?/, '')).join("\n")
        const sub = parse_markdown(inner, { ...o })
        tokens.push({ type: "blockquote", html: sub })
        l = j
        continue
      }
      m = line.match(/^ {0,3}((?:[*+-])|\d{1,9}[.)])\s+/)
      if (m){
        pushParagraph(para)
        const bulletRe = /^ {0,3}((?:[*+-])|\d{1,9}[.)])\s+/
        const { end: j } = takeWhile(l, (s, idx) =>
          bulletRe.test(s) ||
          (/^(?: {4}|\t)/.test(s)) ||
          (!isBlank(s) && idx > l && !/^(?: {0,3}(?:[*+-]|\d{1,9}[.)])\s+)/.test(s))
        )
        const block = lines.slice(l, j)
        const ordered = /^\d/.test(m[1])
        const items = []
        let cur = []
        for (let k = 0; k < block.length; k++){
          const ln = block[k]
          const head = ln.match(bulletRe)
          if (head){
            if (cur.length) items.push(cur), cur = []
            cur.push(ln.replace(bulletRe, ''))
          } else {
            cur.push(ln.replace(/^(?: {4}|\t)/, ''))
          }
        }
        if (cur.length) items.push(cur)
        const parsedItems = items.map(linesArr => {
          let raw = linesArr.join("\n").replace(/\n\s+$/,'')
          let checked = null
          if (o.gfm){
            const t = raw.match(/^\[([ xX])\][ \t]+/)
            if (t){
              checked = t[1].toLowerCase() === 'x'
              raw = raw.replace(/^\[[ xX]\][ \t]+/, '')
            }
          }
          const html = parse_markdown(raw, o)
          return { html, checked }
        })
        tokens.push({ type: "list", ordered, items: parsedItems })
        l = j
        continue
      }
      if (o.gfm){
        const hdr = line
        const alignLn = lines[l + 1] || ''
        if (/\|/.test(hdr) && /^ {0,3}\|? *:?-+:? *(?:\| *:?-+:? *)*\|? *$/.test(alignLn)){
          pushParagraph(para)
          const aligns = alignLn
            .trim().replace(/^(\|)|(\|)$/g,'')
            .split("|").map(s => s.trim()).map(s => s.startsWith(":-") && s.endsWith("-:") ? "center" : s.endsWith("-:") ? "right" : s.startsWith(":-") ? "left" : null)
          const headerCells = hdr.trim().replace(/^(\|)|(\|)$/g,'').split("|").map(s => s.trim())
          l += 2
          const rows = []
          while (l < end && /\|/.test(lines[l]) && !isBlank(lines[l])){
            rows.push(lines[l].trim().replace(/^(\|)|(\|)$/g,'').split("|").map(s => s.trim()))
            l++
          }
          tokens.push({ type: "table", header: headerCells, aligns, rows })
          continue
        }
      }
      para.push(line)
      const next = lines[l + 1] || ''
      const endPara =
        isBlank(next) ||
        /^ {0,3}(?:`{3,}|~{3,})/.test(next) ||
        /^(?: {4}|\t)/.test(next) ||
        /^ {0,3}((?:[*+-])|\d{1,9}[.)])\s+/.test(next) ||
        /^ {0,3}(#{1,6})/.test(next) ||
        /^ {0,3}>\s?/.test(next) ||
        /^ {0,3}(?:-+\s*|-{3,}|_{3,}|\*{3,})\s*$/.test(next) ||
        (o.gfm && /\|/.test(next) && /^ {0,3}\|? *:?-+:? *(?:\| *:?-+:? *)*\|? *$/.test(lines[l + 2] || ''))
      if (endPara) pushParagraph(para)
      l++
    }
    pushParagraph(para)
  }
  parseBlock(0, lines.length)
  function renderInline(s){
    if (!s) return ''
    s = s.replace(/(`+)([^`]|[^`][\s\S]*?[^`])\1/g, (_, ticks, code) => `<code>${escHtml(code)}</code>`)
    s = s.replace(/!\[([^\]]*)\]\(\s*<?([^\s)<>]+)>?\s*(?:(?:"([^"]*)"|'([^']*)'|\(([^)]+)\)))?\s*\)/g,
      (_, alt, url, t1, t2, t3) => `<img src="${escHtml(url)}" alt="${escHtml(alt)}"${t1||t2||t3?` title="${escHtml(t1||t2||t3)}"`:''}>`)
    s = s.replace(/!\[([^\]]*)\]\[([^\]]*)\]/g, (_, alt, id) => {
      const ref = refs[(id || alt).trim().replace(/\s+/g,' ').toLowerCase()]
      return ref ? `<img src="${escHtml(ref.href)}" alt="${escHtml(alt)}"${ref.title?` title="${escHtml(ref.title)}"`:''}>` : _
    })
    s = s.replace(/\[([^\]]+)\]\(\s*<?([^\s)<>]+)>?\s*(?:(?:"([^"]*)"|'([^']*)'|\(([^)]+)\)))?\s*\)/g,
      (_, text, url, t1, t2, t3) => `<a href="${escHtml(url)}"${t1||t2||t3?` title="${escHtml(t1||t2||t3)}"`:''}>${text}</a>`)
    s = s.replace(/\[([^\]]+)\]\s*\[([^\]]*)\]/g, (_, text, id) => {
      const key = (id || text).trim().replace(/\s+/g,' ').toLowerCase()
      const ref = refs[key]
      return ref ? `<a href="${escHtml(ref.href)}"${ref.title?` title="${escHtml(ref.title)}"`:''}>${text}</a>` : _
    })
    s = s.replace(/<([a-zA-Z][a-zA-Z0-9+.-]{1,31}:[^ <>"']+)>/g, (_, url) => `<a href="${escHtml(url)}">${escHtml(url)}</a>`)
    s = s.replace(/<([A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,})>/g, (_, mail) => `<a href="mailto:${escHtml(mail)}">${escHtml(mail)}</a>`)
    if (o.gfm){
      s = s.replace(/(?:(?<=\s)|^)(https?:\/\/[^\s<]+)(?=\s|$)/g, '<a href="$1">$1</a>')
      s = s.replace(/(?:(?<=\s)|^)(www\.[^\s<]+)(?=\s|$)/g, '<a href="http://$1">$1</a>')
    }
    s = s.replace(/\*\*([\s\S]+?)\*\*/g, '<strong>$1</strong>').replace(/__([\s\S]+?)__/g, '<strong>$1</strong>')
    s = s.replace(/\*([^*\n]+?)\*/g, '<em>$1</em>').replace(/_([^_\n]+?)_/g, '<em>$1</em>')
    if (o.gfm) s = s.replace(/~~([\s\S]+?)~~/g, '<del>$1</del>')
    s = s.replace(/ {2,}\n/g, "<br>\n")
    if (o.breaks) s = s.replace(/\n/g, "<br>\n")
    s = s.replace(/&(?!#?\w+;)/g, "&amp;").replace(/<(?!\/?[A-Za-z][^>]*>)/g, "&lt;")
    return smart(s)
  }
  let out = ''
  for (const t of tokens){
    switch (t.type){
      case "paragraph":
        out += `<p>${renderInline(t.text)}</p>\n`
        break
      case "heading": {
        const text = renderInline(t.text)
        const id = o.headerIds ? slug(text.replace(/<[^>]+>/g, '')) : null
        out += id ? `<h${t.depth} id="${id}">${text}</h${t.depth}>\n` : `<h${t.depth}>${text}</h${t.depth}>\n`
        break
      }
      case "code": {
        const cls = t.lang ? ` class="language-${escHtml(t.lang)}"` : ''
        out += `<pre><code${cls}>${escHtml(t.text)}</code></pre>\n`
        break
      }
      case "blockquote":
        out += `<blockquote>\n${t.html.trim()}\n</blockquote>\n`
        break
      case "list": {
        const tag = t.ordered ? "ol" : "ul"
        out += `<${tag}>\n`
        for (const it of t.items){
          const task = it.checked === null ? '' : `<input ${it.checked ? 'checked="" ' : ''}disabled="" type="checkbox"> `
          const body = it.html.trim().replace(/^<p>/, task + "<p>")
          out += `<li>${body}</li>\n`
        }
        out += `</${tag}>\n`
        break
      }
      case "table": {
        const ths = t.header.map((h, i) => {
          const a = t.aligns[i]
          return a ? `<th align="${a}">${renderInline(h)}</th>` : `<th>${renderInline(h)}</th>`
        }).join("\n")
        let body = ''
        for (const row of t.rows){
          const tds = row.map((cell, i) => {
            const a = t.aligns[i]
            return a ? `<td align="${a}">${renderInline(cell)}</td>` : `<td>${renderInline(cell)}</td>`
          }).join("\n")
          body += `<tr>\n${tds}\n</tr>\n`
        }
        out += `<table>\n<thead>\n<tr>\n${ths}\n</tr>\n</thead>\n` + (body ? `<tbody>\n${body}</tbody>\n` : '') + `</table>\n`
        break
      }
      case "hr":
        out += "<hr>\n"
        break
      case "html":
        out += t.text + "\n"
        break
    }
  }
  return out.trim()
}
object

%numpad

/phlo/resources/DOM/numpad.phlo

On-screen numeric keypad for touch input, bound to a field

Put data-numpad="<selector>" on a container; empty containers get the standard keys, containers with their own data-numpad-key buttons keep them. Without a selector the pad writes to the first field of its own form.

domnumpadkeypadtouchinputposfrontend
view

script

line 17
Keys and focus are handled on the body rather than per element.
phlo.numpad = {
	layouts: {
		calculator: ['7', '8', '9', '4', '5', '6', '1', '2', '3'],
		phone: ['1', '2', '3', '4', '5', '6', '7', '8', '9'],
	},
	labels: {back: '&larr;', clear: 'C', enter: '&crarr;'},
	options: el => ({
		layout: el.dataset.numpadLayout || 'calculator',
		decimal: el.dataset.numpadDecimal ?? ',',
		extra: el.dataset.numpadExtra ?? '',
		keys: (el.dataset.numpadKeys ?? 'back,clear').split(',').map(key => key.trim()).filter(Boolean),
		fresh: el.dataset.numpadFresh !== undefined,
		max: parseInt(el.dataset.numpadMax) || 0,
	}),
	target: el => obj(el.dataset.numpad) || el.closest('form')?.querySelector('input, textarea') || null,
	keys(options){
		const key = (value, label, cls) => `<button class="numpad__key${cls ? ' ' + cls : ''}" type="button" data-numpad-key="${value}">${label ?? value}</button>`
		let html = (phlo.numpad.layouts[options.layout] || phlo.numpad.layouts.calculator).map(value => key(value)).join('')
		html += options.extra ? key(options.extra) : ''
		html += key('0')
		html += options.decimal ? key(options.decimal) : ''
		options.keys.forEach(name => html += key(name, phlo.numpad.labels[name] ?? name, 'numpad__key--' + name))
		return html
	},
	build(el){
		if (el.dataset.numpadReady) return
		el.dataset.numpadReady = '1'
		el.classList.add('numpad')
		if (!el.querySelector('[data-numpad-key]')) app.mod.inner(el, phlo.numpad.keys(phlo.numpad.options(el)))
	},
	write(target, value){
		target.value = value
		target.dispatchEvent(new Event('input', {bubbles: true}))
	},
	press(target, name, options){
		if (!target) return
		if (name === 'clear') return phlo.numpad.write(target, '')
		if (name === 'back') return phlo.numpad.write(target, target.value.slice(0, -1))
		if (name === 'enter'){
			const form = target.closest('form')
			target.dispatchEvent(new Event('change', {bubbles: true}))
			return form?.requestSubmit()
		}
		let value = target.value
		if (options.fresh && !target.dataset.numpadTyped){
			value = ''
			target.dataset.numpadTyped = '1'
		}
		if (options.decimal && name === options.decimal && value.includes(options.decimal)) return
		if (options.max && (value + name).length > options.max) return
		phlo.numpad.write(target, value + name)
	},
}

app.numpad = (selector, options = {}) => `<div class="numpad" data-numpad="${selector}"${Object.entries(options).map(([key, value]) => ` data-numpad-${key.toLowerCase()}="${value}"`).join('')}></div>`

onExist('[data-numpad]', el => phlo.numpad.build(el))

on('click', 'body', (body, e) => {
	const el = e.target.closest?.('[data-numpad-key]')
	const pad = el?.closest('[data-numpad]')
	if (!pad) return
	const target = phlo.numpad.target(pad)
	phlo.numpad.press(target, el.dataset.numpadKey, phlo.numpad.options(pad))
	target?.focus({preventScroll: true})
})

on('focusin', 'body', (body, e) => {
	if (e.relatedTarget?.closest?.('[data-numpad-key]')) return
	if (e.target.dataset?.numpadTyped) delete e.target.dataset.numpadTyped
})
view

style

line 91
.numpad {
	display: grid
	grid-template-columns: repeat(3, 1fr)
	gap: var(--numpad-gap, .4rem)
}
.numpad__key {
	font: inherit
	font-size: var(--numpad-size, 1.2rem)
	padding: var(--numpad-pad, .7rem)
	border: 1px solid var(--numpad-border, #0002)
	border-radius: var(--numpad-radius, 8px)
	background: var(--numpad-bg, #0000000a)
	color: inherit
	cursor: pointer
	touch-action: manipulation
	user-select: none
	\:active: background: var(--numpad-bg-active, #00000018)
}
.numpad__key--enter {
	grid-column: span 3
}
object

%presentation

/phlo/resources/DOM/presentation.phlo

Timeline presentation player for the DOM: timed image/video layers, transitions, subtitles and language alternatives from a presentation.json payload. Exposes the class `PresentationPlayer`; each transition carries its own pp-* CSS animation and the matching canvas curve for deterministic export rendering. boot() wires every .pp-embed, from an inline JSON script child or a data-src payload URL. A document keymap steers the fullscreen, focused or only player: space/k toggles, arrows seek and set volume, m mutes, c toggles subtitles, f fullscreen, home/end jump.

Plays a presentation from one JSON payload against its own clock, and seeks the audio and video to match it, so a slow machine drops frames rather than drifting out of step. That also means a presentation without audio runs perfectly well. Alternative languages live in the same payload, so switching language reloads nothing.

presentationplayertimelineaudiovideosubtitlestransitionscanvasrenderkeyboard
view

script

line 10
class PresentationPlayer {

	static bezier(x1, y1, x2, y2){
		const cx = 3 * x1
		const bx = 3 * (x2 - x1) - cx
		const ax = 1 - cx - bx
		const cy = 3 * y1
		const by = 3 * (y2 - y1) - cy
		const ay = 1 - cy - by
		const sampleX = t => ((ax * t + bx) * t + cx) * t
		const sampleY = t => ((ay * t + by) * t + cy) * t
		return x => {
			if (x <= 0) return 0
			if (x >= 1) return 1
			let t = x
			for (let i = 0; i < 8; i++){
				const err = sampleX(t) - x
				if (Math.abs(err) < 0.001) break
				const d = (3 * ax * t + 2 * bx) * t + cx
				if (Math.abs(d) < 0.000001) break
				t -= err / d
			}
			return sampleY(Math.min(1, Math.max(0, t)))
		}
	}

	static eases = {
		linear: p => p,
		ease: this.bezier(0.25, 0.1, 0.25, 1),
		easeIn: this.bezier(0.42, 0, 1, 1),
		easeOut: this.bezier(0, 0, 0.58, 1),
		easeInOut: this.bezier(0.42, 0, 0.58, 1),
		zoomB: this.bezier(0.25, 1, 0.5, 1),
		tvB: this.bezier(1, 0, 0, 1),
		cardsB: this.bezier(0.2, 0, 0.2, 1),
	}

	static instances = new Set()

	static clipRombus(q){
		return (ctx, x, y, w, h) => {
			const cx = x + w / 2
			const cy = y + h / 2
			const pts = [[0.5, -0.5], [1.5, 0.5], [0.5, 1.5], [-0.5, 0.5]]
			ctx.beginPath()
			pts.forEach(([px, py], i) => {
				const fx = x + px * w
				const fy = y + py * h
				const ix = cx + (fx - cx) * q
				const iy = cy + (fy - cy) * q
				i ? ctx.lineTo(ix, iy) : ctx.moveTo(ix, iy)
			})
			ctx.closePath()
		}
	}

	static clipCircle(r){
		return (ctx, x, y, w, h) => {
			ctx.beginPath()
			ctx.arc(x + w / 2, y + h / 2, Math.max(0, r * Math.hypot(w, h) / Math.SQRT2), 0, Math.PI * 2)
		}
	}

	static clipInset(l, r){
		return (ctx, x, y, w, h) => {
			ctx.beginPath()
			ctx.rect(x + l * w, y, Math.max(0, (1 - l - r) * w), h)
		}
	}

	static transitions = {
		none:      null,
		fade:      {in: 'pp-fade-in',      out: 'pp-fade-out',      ease: 'ease',
			curve: {ease: this.eases.ease,
				in: q => ({opacity: q}),
				out: q => ({opacity: 1 - q})}},
		zoom:      {in: 'pp-zoom-in',      out: 'pp-zoom-out',      ease: 'cubic-bezier(.25,1,.5,1)',
			curve: {ease: this.eases.zoomB,
				in: q => ({opacity: q, sx: 0.96 + 0.04 * q, sy: 0.96 + 0.04 * q}),
				out: q => ({opacity: 1 - q, sx: 1 + 0.05 * q, sy: 1 + 0.05 * q})}},
		glide:     {in: 'pp-glide-in',     out: 'pp-glide-out',     ease: 'ease-out',
			curve: {ease: this.eases.easeOut,
				in: (q, g) => ({tx: -g.W * (1 - q)}),
				out: (q, g) => ({opacity: 1 - q, tx: g.W * q})}},
		slide:     {in: 'pp-slide-in',     out: 'pp-slide-out',     ease: 'ease-in-out',
			curve: {ease: this.eases.easeInOut,
				in: (q, g) => ({opacity: q, tx: -20 * g.k * (1 - q)}),
				out: (q, g) => ({opacity: 1 - q, tx: 20 * g.k * q})}},
		drop:      {in: 'pp-drop-in',      out: 'pp-drop-out',      ease: 'ease',
			curve: {ease: this.eases.ease,
				in: (q, g) => ({opacity: q, ty: -50 * g.k * (1 - q)}),
				out: (q, g) => ({opacity: 1 - q, ty: 50 * g.k * q})}},
		skew:      {in: 'pp-skew-in',      out: 'pp-skew-out',      ease: 'ease-in',
			curve: {ease: this.eases.easeIn,
				in: q => ({opacity: q, skew: -15 * (1 - q) * Math.PI / 180}),
				out: q => ({opacity: 1 - q, skew: 15 * q * Math.PI / 180})}},
		tilt:      {in: 'pp-tilt-in',      out: 'pp-tilt-out',      ease: 'ease-in',
			curve: {ease: this.eases.easeIn,
				in: (q, g) => ({opacity: q, rot: -5 * (1 - q) * Math.PI / 180, ty: 20 * g.k * (1 - q)}),
				out: (q, g) => ({opacity: 1 - q, rot: 5 * q * Math.PI / 180, ty: -20 * g.k * q})}},
		spiral:    {in: 'pp-spiral-in',    out: 'pp-spiral-out',    ease: 'ease-out',
			curve: {ease: this.eases.easeOut,
				in: q => ({opacity: q, rot: -2 * Math.PI * (1 - q), sx: q, sy: q}),
				out: q => ({opacity: 1 - q, rot: 2 * Math.PI * q, sx: 1 - q, sy: 1 - q})}},
		ripple:    {in: 'pp-ripple-in',    out: 'pp-ripple-out',    ease: 'ease',
			curve: {ease: this.eases.ease,
				in: (q, g) => ({opacity: q, sx: 0.5 + 0.5 * q, sy: 0.5 + 0.5 * q, blur: 3 * g.k * (1 - q)}),
				out: (q, g) => ({opacity: 1 - q, sx: 1 + 0.5 * q, sy: 1 + 0.5 * q, blur: 3 * g.k * q})}},
		curtain:   {in: 'pp-curtain-in',   out: 'pp-curtain-out',   ease: 'ease-in',
			curve: {ease: this.eases.easeIn,
				in: q => ({opacity: q, sy: Math.max(0.001, q), origin: 'bottom'}),
				out: q => ({opacity: 1 - q, sy: Math.max(0.001, 1 - q), origin: 'top'})}},
		tv:        {in: 'pp-tv-in',        out: 'pp-tv-out',        ease: 'cubic-bezier(1,0,0,1)',
			curve: {ease: this.eases.tvB,
				in: q => q < 0.5
					? {opacity: 1.6 * q, sx: Math.max(0.001, q * 2), sy: Math.max(0.001, 0.04 * q)}
					: {opacity: 0.8 + 0.4 * (q - 0.5) * 2, sx: 1, sy: 0.02 + (q - 0.5) * 2 * 0.98},
				out: q => q < 0.5
					? {opacity: 1 - 0.4 * q * 2, sx: 1, sy: Math.max(0.001, 1 - q * 2 * 0.98)}
					: {opacity: 0.8 - 1.6 * (q - 0.5), sx: Math.max(0.001, 1 - (q - 0.5) * 2), sy: 0.02}}},
		flip:      {in: 'pp-flip-in',      out: 'pp-flip-out',      ease: 'ease-in-out',
			curve: {ease: this.eases.easeInOut,
				in: q => ({opacity: q < 0.5 ? 0 : 1, sx: Math.max(0.001, Math.abs(Math.cos((1 - q) * Math.PI)))}),
				out: q => ({opacity: q < 0.5 ? 1 : 0, sx: Math.max(0.001, Math.abs(Math.cos(q * Math.PI)))})}},
		cube:      {in: 'pp-cube-in',      out: 'pp-cube-out',      ease: 'ease-in-out', originIn: '0% 50%', originOut: '100% 50%',
			curve: {ease: this.eases.easeInOut,
				in: q => ({opacity: Math.min(1, q * 2), sx: Math.max(0.001, Math.cos((1 - q) * Math.PI / 2)), origin: 'left'}),
				out: q => ({opacity: 1 - q, sx: Math.max(0.001, Math.cos(q * Math.PI / 2)), origin: 'right'})}},
		diamond:   {in: 'pp-diamond-in',   out: 'pp-diamond-out',   ease: 'ease',
			curve: {ease: this.eases.ease,
				in: q => ({clip: PresentationPlayer.clipRombus(q)}),
				out: q => ({clip: PresentationPlayer.clipRombus(1 - q)})}},
		diaphragm: {in: 'pp-diaphragm-in', out: 'pp-diaphragm-out', ease: 'linear',
			curve: {ease: this.eases.linear,
				in: q => ({clip: PresentationPlayer.clipCircle(0.9 * q)}),
				out: q => ({clip: PresentationPlayer.clipCircle(0.9 * (1 - q))})}},
		spotlight: {in: 'pp-spotlight-in', out: 'pp-spotlight-out', ease: 'ease',
			curve: {ease: this.eases.ease,
				in: q => ({clip: PresentationPlayer.clipCircle(0.75 * q)}),
				out: q => ({clip: PresentationPlayer.clipCircle(0.75 * (1 - q))})}},
		wipe:      {in: 'pp-wipe-in',      out: 'pp-wipe-out',      ease: 'ease-out',
			curve: {ease: this.eases.easeOut,
				in: q => ({opacity: Math.min(1, 0.1 + q * 2.2), clip: PresentationPlayer.clipInset(0, 1 - q)}),
				out: q => ({opacity: Math.min(1, 1.1 - q), clip: PresentationPlayer.clipInset(q, 0)})}},
		glitch:    {in: 'pp-glitch-in',    out: 'pp-glitch-out',    ease: 'linear',
			curve: {ease: this.eases.linear,
				in: (q, g) => ({opacity: Math.min(1, q * 1.6), bands: q < 0.95 ? 5 * g.k * (1 - q) : 0, seed: q}),
				out: (q, g) => ({opacity: Math.max(0, 1 - q * 1.2), bands: q > 0.05 ? 5 * g.k * q : 0, seed: q})}},
		cards:     {in: 'pp-cards-in',     out: 'pp-cards-out',     ease: 'cubic-bezier(0.2,0,0.2,1)',
			curve: {ease: this.eases.cardsB,
				in: (q, g) => ({ty: g.h * (1 - q)}),
				out: (q, g) => ({opacity: 1 - q, ty: g.h * q})}},
	}

	static progress(tt, dur){
		if (dur <= 0) return 1
		return Math.min(1, Math.max(0, tt / dur))
	}

	static wave(tt, period){
		return (1 - Math.cos(Math.PI * 2 * ((tt % period) / period))) / 2
	}

	static dirVector(dir){
		const map = {left: [1, 0], right: [-1, 0], up: [0, 1], down: [0, -1], tl: [1, 1], tr: [-1, 1], bl: [1, -1], br: [-1, -1]}
		return map[dir] || [1, 0]
	}

	static durings = {
		none: null,
		push: {css: 'pp-scale', ease: 'linear', amount: 10, dir: '',
			vars(a, d){
				return {'--pp-s0': String(1 - a / 100)}
			},
			curve(tt, dur, g, a, d){
				const s = 1 - a / 100 * (1 - PresentationPlayer.progress(tt, dur))
				return {sx: s, sy: s}
			}},
		pull: {css: 'pp-scale', ease: 'linear', amount: 10, dir: '',
			vars(a, d){
				return {'--pp-s0': String(1 + a / 100)}
			},
			curve(tt, dur, g, a, d){
				const s = 1 + a / 100 * (1 - PresentationPlayer.progress(tt, dur))
				return {sx: s, sy: s}
			}},
		pan: {css: 'pp-drift', ease: 'linear', amount: 5, dir: 'left',
			vars(a, d){
				const [vx, vy] = PresentationPlayer.dirVector(d)
				return {'--pp-x0': vx * a + '%', '--pp-y0': vy * a + '%'}
			},
			curve(tt, dur, g, a, d){
				const [vx, vy] = PresentationPlayer.dirVector(d)
				const back = 1 - PresentationPlayer.progress(tt, dur)
				return {tx: vx * a / 100 * g.w * back, ty: vy * a / 100 * g.h * back}
			}},
		kenburns: {css: 'pp-kenburns', ease: 'linear', amount: 8, dir: 'tr',
			vars(a, d){
				const [vx, vy] = PresentationPlayer.dirVector(d)
				return {'--pp-s0': String(1 - a / 100), '--pp-x0': vx * a / 2 + '%', '--pp-y0': vy * a / 2 + '%'}
			},
			curve(tt, dur, g, a, d){
				const [vx, vy] = PresentationPlayer.dirVector(d)
				const back = 1 - PresentationPlayer.progress(tt, dur)
				const s = 1 - a / 100 * back
				return {sx: s, sy: s, tx: vx * a / 200 * g.w * back, ty: vy * a / 200 * g.h * back}
			}},
		float: {css: 'pp-float', ease: 'ease-in-out', amount: 3, dir: '', period: 4,
			vars(a, d){
				return {'--pp-y1': -a + '%'}
			},
			curve(tt, dur, g, a, d){
				return {ty: -a / 100 * g.h * PresentationPlayer.wave(tt, this.period)}
			}},
		sway: {css: 'pp-sway', ease: 'ease-in-out', amount: 1.5, dir: '', period: 5,
			vars(a, d){
				return {'--pp-r0': -a + 'deg', '--pp-r1': a + 'deg'}
			},
			curve(tt, dur, g, a, d){
				return {rot: a * (2 * PresentationPlayer.wave(tt, this.period) - 1) * Math.PI / 180}
			}},
		pulse: {css: 'pp-pulse', ease: 'ease-in-out', amount: 2, dir: '', period: 2.5,
			vars(a, d){
				return {'--pp-s1': String(1 + a / 100)}
			},
			curve(tt, dur, g, a, d){
				const s = 1 + a / 100 * PresentationPlayer.wave(tt, this.period)
				return {sx: s, sy: s}
			}},
	}

	constructor(root, data, opts = {}){
		this.root = root
		this.opts = opts
		this.mediaBase = opts.mediaBase || data.mediaBase || ''
		this.transcript = data.transcript || null
		const pres0 = data.presentation || {}
		this.lang = opts.lang ?? pres0.lang ?? (pres0.subtitles || {}).lang ?? null
		this.playing = false
		this.started = false
		this.deferMedia = !opts.edit
		this.mediaLoaded = !!opts.edit
		this.pendingMedia = []
		this.vol = Math.min(1, Math.max(0, parseFloat(localStorage.getItem('pp-vol') ?? '1') || 0))
		this.muted = localStorage.getItem('pp-muted') === '1'
		this.t = !opts.edit && pres0.poster ? Math.max(0, pres0.poster) : 0
		this.stateKey = 'pp:' + (pres0.title || 'presentation')
		this.resumePlay = false
		if (!opts.edit){
			const saved = this.readState()
			if (saved){
				this.t = saved.t
				this.started = true
				this.resumePlay = true
			}
		}
		PresentationPlayer.instances.add(this)
		this.clockStart = 0
		this.items = []
		this.videos = []
		this.audio = null
		this.audioStart = 0
		this.subIndex = -1
		this.raf = null
		this.blobCache = {}
		this.updateGen = 0
		this.loaded = false
		this.buildShell()
		this.update(data.presentation)
	}

	mediaJob(jobs, job){
		if (this.mediaLoaded) jobs.push(job())
		else this.pendingMedia.push(job)
	}

	loadMedia(){
		if (this.mediaLoaded) return Promise.resolve()
		this.mediaLoaded = true
		this.root.classList.add('pp-loading')
		return Promise.all(this.pendingMedia.splice(0).map(job => job())).then(() => {
			this.root.classList.remove('pp-loading')
			this.applyTime(this.t, true)
			this.updateTime()
		})
	}

	loadBlob(url){
		if (!this.blobCache[url]) this.blobCache[url] = fetch(url)
			.then(r => {
				if (!r.ok) throw Error(String(r.status))
				return r.blob()
			})
			.then(b => URL.createObjectURL(b))
			.catch(() => url)
		return this.blobCache[url]
	}

	guard(promise, ms){
		return Promise.race([promise, new Promise(res => setTimeout(res, ms))])
	}

	src(name){
		return this.mediaBase + name
	}

	srcFor(item){
		if (item && this.lang && Array.isArray(item.alts)){
			const alt = item.alts.find(a => a && a.src && a.lang === this.lang)
			if (alt) return alt.src
		}
		return item ? item.src : ''
	}

	readState(){
		try {
			const saved = JSON.parse(sessionStorage.getItem(this.stateKey))
			if (saved && saved.t > 0.5 && Date.now() - saved.ts < 600000) return saved
		}
		catch {}
		return null
	}

	saveState(){
		try { sessionStorage.setItem(this.stateKey, JSON.stringify({t: this.t, ts: Date.now()})) }
		catch {}
	}

	destroy(){
		if (this.playing && !this.opts.edit) this.saveState()
		this.playing = false
		clearTimeout(this.idleTimer)
		if (this.raf) cancelAnimationFrame(this.raf)
		if (this.ticker) clearInterval(this.ticker)
		if (this.audio) this.audio.pause()
		for (const v of this.videos) v.pause()
		this.audio = null
		this.videos = []
		for (const entry of Object.values(this.blobCache)) Promise.resolve(entry).then(url => typeof url === 'string' && url.startsWith('blob:') && URL.revokeObjectURL(url))
		this.blobCache = {}
	}

	clearState(){
		try { sessionStorage.removeItem(this.stateKey) }
		catch {}
	}

	effVol(){
		return this.muted ? 0 : this.vol
	}

	applyVolume(){
		const eff = this.effVol()
		if (this.audio) this.audio.volume = eff
		for (const v of this.videos) v.volume = (v._baseVol ?? 0) * eff
		if (this.volBtn) this.volBtn.innerHTML = this.icon(eff > 0 ? 'vol' : 'mute')
		if (this.volBtn) this.volBtn.setAttribute('aria-label', eff > 0 ? 'Mute' : 'Unmute')
		if (this.volEl && !this.muted) this.volEl.value = Math.round(this.vol * 100)
	}

	setVol(vol){
		this.vol = Math.min(1, Math.max(0, vol))
		this.muted = false
		try {
			localStorage.setItem('pp-vol', String(this.vol))
			localStorage.setItem('pp-muted', '0')
		}
		catch {}
		this.applyVolume()
	}

	toggleMute(){
		this.muted = !this.muted
		try { localStorage.setItem('pp-muted', this.muted ? '1' : '0') }
		catch {}
		this.applyVolume()
	}

	fullscreen(){
		if (document.fullscreenElement) document.exitFullscreen()
		else (this.root.closest('.pp-standalone') || this.root).requestFullscreen()
	}

	contentFor(item){
		if (item && this.lang && Array.isArray(item.alts)){
			const alt = item.alts.find(a => a && a.content && a.lang === this.lang)
			if (alt) return alt.content
		}
		return item && item.content ? item.content : ''
	}

	languages(){
		const set = new Set()
		const p = this.pres || {}
		const add = it => {
			if (!it) return
			for (const a of it.alts || []) if (a && a.lang) set.add(a.lang)
		}
		add(p.audio)
		for (const v of p.videos || []) add(v)
		for (const i of p.images || []) add(i)
		for (const t of p.texts || []) add(t)
		if (this.transcript && this.transcript.translations) for (const l of Object.keys(this.transcript.translations)) set.add(l)
		return [...set].filter(l => l && l !== p.lang)
	}

	setLang(lang){
		this.lang = lang || null
		this.update(this.pres)
	}

	get duration(){
		const p = this.pres
		let end = 0
		for (const img of p.images || []) end = Math.max(end, (img.start || 0) + (img.duration || 0))
		for (const tx of p.texts || []) end = Math.max(end, (tx.start || 0) + (tx.duration || 0))
		for (const v of this.videos) if (v.duration) end = Math.max(end, v._start + v.duration)
		if (p.audio && p.audio.duration) end = Math.max(end, (p.audio.start || 0) + p.audio.duration)
		return end || 10
	}

	buildShell(){
		this.root.classList.add('pp-root', 'pp-paused')
		if (this.opts.edit) this.root.classList.add('pp-edit')
		this.stage = document.createElement('div')
		this.stage.className = 'pp-stage'
		this.root.appendChild(this.stage)
		this.subsEl = document.createElement('div')
		this.subsEl.className = 'pp-subs'
		this.subsEl.style.display = 'none'
		this.root.appendChild(this.subsEl)
		if (this.opts.controls !== false && !this.opts.edit) this.buildControls()
		const fit = w => this.root.style.fontSize = Math.max(9, w / 48) + 'px'
		new ResizeObserver(entries => fit(entries[0].contentRect.width)).observe(this.root)
	}

	icon(name){
		const paths = {
			play: 'M8 5v14l11-7z',
			pause: 'M6 5h4v14H6zM14 5h4v14h-4z',
			cc: 'M4 5h16a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2zm4.5 5.5a2 2 0 0 0-2-1.5c-1.4 0-2.5 1.3-2.5 3s1.1 3 2.5 3a2 2 0 0 0 2-1.5H6.8a1 1 0 0 1-.8.5c-.7 0-1.5-.8-1.5-2s.8-2 1.5-2a1 1 0 0 1 .8.5zm8 0a2 2 0 0 0-2-1.5c-1.4 0-2.5 1.3-2.5 3s1.1 3 2.5 3a2 2 0 0 0 2-1.5h-1.7a1 1 0 0 1-.8.5c-.7 0-1.5-.8-1.5-2s.8-2 1.5-2a1 1 0 0 1 .8.5z',
			vol: 'M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z',
			mute: 'M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3 3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4 9.91 6.09 12 8.18V4z',
			full: 'M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z',
		}
		return '<svg viewBox="0 0 24 24"><path d="' + paths[name] + '"/></svg>'
	}

	buildControls(){
		this.root.tabIndex = 0
		this.bigPlay = document.createElement('button')
		this.bigPlay.className = 'pp-bigplay'
		this.bigPlay.setAttribute('aria-label', 'Play')
		this.bigPlay.innerHTML = this.icon('play')
		this.bigPlay.addEventListener('click', () => this.toggle())
		this.root.appendChild(this.bigPlay)
		const bar = document.createElement('div')
		bar.className = 'pp-controls'
		this.playBtn = document.createElement('button')
		this.playBtn.setAttribute('aria-label', 'Play')
		this.playBtn.innerHTML = this.icon('play')
		this.playBtn.addEventListener('click', () => this.toggle())
		this.timeEl = document.createElement('span')
		this.timeEl.className = 'pp-time'
		this.seekEl = document.createElement('input')
		this.seekEl.type = 'range'
		this.seekEl.className = 'pp-seek'
		this.seekEl.setAttribute('aria-label', 'Seek')
		this.seekEl.min = 0
		this.seekEl.max = 1000
		this.seekEl.value = 0
		this.seekEl.addEventListener('input', () => this.seek(this.seekEl.value / 1000 * this.duration))
		this.volBtn = document.createElement('button')
		this.volBtn.setAttribute('aria-label', 'Mute')
		this.volBtn.innerHTML = this.icon('vol')
		this.volBtn.addEventListener('click', () => this.toggleMute())
		this.volEl = document.createElement('input')
		this.volEl.type = 'range'
		this.volEl.className = 'pp-vol'
		this.volEl.setAttribute('aria-label', 'Volume')
		this.volEl.min = 0
		this.volEl.max = 100
		this.volEl.value = Math.round(this.vol * 100)
		this.volEl.addEventListener('input', () => this.setVol(this.volEl.value / 100))
		this.ccBtn = document.createElement('button')
		this.ccBtn.setAttribute('aria-label', 'Subtitles')
		this.ccBtn.innerHTML = this.icon('cc')
		this.ccBtn.addEventListener('click', () => this.setSubs(!this.showSubs))
		this.langSel = document.createElement('select')
		this.langSel.className = 'pp-lang'
		this.langSel.setAttribute('aria-label', 'Language')
		this.langSel.style.display = 'none'
		this.langSel.addEventListener('change', () => this.setLang(this.langSel.value === 'original' ? null : this.langSel.value))
		this.fsBtn = document.createElement('button')
		this.fsBtn.setAttribute('aria-label', 'Fullscreen')
		this.fsBtn.innerHTML = this.icon('full')
		this.fsBtn.addEventListener('click', () => this.fullscreen())
		bar.append(this.playBtn, this.timeEl, this.seekEl, this.volBtn, this.volEl, this.ccBtn, this.langSel, this.fsBtn)
		this.root.appendChild(bar)
		this.stage.addEventListener('click', e => {
			if (!e.target.closest('a')) this.toggle()
		})
		const wake = () => {
			this.root.classList.remove('pp-idle')
			clearTimeout(this.idleTimer)
			this.idleTimer = setTimeout(() => this.playing && this.root.classList.add('pp-idle'), 2800)
		}
		this.wake = wake
		this.root.addEventListener('mousemove', wake)
		this.root.addEventListener('touchstart', wake, {passive: true})
	}

	update(pres){
		this.pres = pres
		const gen = ++this.updateGen
		const jobs = []
		this.pendingMedia = []
		this.root.style.setProperty('--pp-ar', (pres.size.w / pres.size.h).toFixed(4))
		this.stage.innerHTML = ''
		this.items = []
		this.videos = []
		const wantAudio = pres.audio && pres.audio.src ? this.src(this.srcFor(pres.audio)) : ''
		this.audioStart = pres.audio && pres.audio.start ? pres.audio.start : 0
		if (this.audio && this.audioSrc !== wantAudio){
			this.audio.pause()
			this.audio = null
		}
		if (wantAudio && !this.audio){
			this.audioSrc = wantAudio
			this.audio = new Audio()
			this.audio.preload = 'auto'
			this.audio.volume = this.effVol()
			this.mediaJob(jobs, () => this.guard(this.loadBlob(this.audioSrc).then(url => new Promise(res => {
				this.audio.addEventListener('loadedmetadata', () => {
					const d = this.audio.duration
					if (Number.isFinite(d) && d > 0 && this.opts.onMeta) this.opts.onMeta(d)
					res()
				}, {once: true})
				this.audio.addEventListener('error', () => res(), {once: true})
				this.audio.src = url
			})), 20000))
		}
		for (const cfg of pres.videos || []){
			const v = document.createElement('video')
			v.className = 'pp-video'
			v.preload = 'auto'
			v.playsInline = true
			v._baseVol = Math.min(1, Math.max(0, (cfg.volume ?? 100) / 100))
			v.volume = v._baseVol * this.effVol()
			v.muted = !cfg.volume
			v.style.objectFit = cfg.fit || 'cover'
			v._start = cfg.start || 0
			v._inDur = cfg.in && cfg.in.dur ? cfg.in.dur : 0
			v._outDur = cfg.out && cfg.out.dur ? cfg.out.dur : 0
			if (cfg.x != null || cfg.y != null || cfg.w != null){
				v.classList.add('pp-video-box')
				v.style.left = (cfg.x || 0) + '%'
				v.style.top = (cfg.y || 0) + '%'
				v.style.width = (cfg.w || 30) + '%'
				v.style.zIndex = 10 + (cfg.z || 0)
			}
			else v.style.zIndex = 1000
			if (cfg.frame){
				v.style.borderRadius = '20px'
				v.style.boxShadow = '0 26px 70px rgba(0,0,0,.55)'
			}
			this.mediaJob(jobs, () => this.guard(this.loadBlob(this.src(this.srcFor(cfg))).then(url => new Promise(res => {
				v.addEventListener('loadedmetadata', () => res(), {once: true})
				v.addEventListener('error', () => res(), {once: true})
				v.src = url
			})), 30000))
			this.stage.appendChild(v)
			this.videos.push(v)
		}
		const sorted = (pres.images || []).map((cfg, idx) => ({cfg, idx})).sort((a, b) => (a.cfg.z || 0) - (b.cfg.z || 0))
		for (const {cfg, idx} of sorted){
			const el = document.createElement('div')
			el.className = 'pp-item'
			el.dataset.idx = idx
			el.style.left = (cfg.x || 0) + '%'
			el.style.top = (cfg.y || 0) + '%'
			el.style.width = (cfg.w || 30) + '%'
			el.style.zIndex = 10 + (cfg.z || 0)
			const du = cfg.during && PresentationPlayer.durings[cfg.during.name]
			if (du) for (const [key, val] of Object.entries(du.vars(cfg.during.amount ?? du.amount, cfg.during.dir || du.dir))) el.style.setProperty(key, val)
			const img = document.createElement('img')
			img.src = this.src(this.srcFor(cfg))
			img.alt = cfg.alt || ''
			img.draggable = false
			img.addEventListener('load', () => {
				img.width = img.naturalWidth
				img.height = img.naturalHeight
			}, {once: true})
			if (!img.complete) jobs.push(this.guard(new Promise(res => {
				img.addEventListener('load', () => res(), {once: true})
				img.addEventListener('error', () => res(), {once: true})
			}), 15000))
			if (cfg.link && cfg.link.href && !this.opts.edit){
				const a = document.createElement('a')
				a.href = cfg.link.href
				if (cfg.link.target === '_blank'){
					a.target = '_blank'
					a.rel = 'noopener'
				}
				a.appendChild(img)
				el.appendChild(a)
			}
			else el.appendChild(img)
			this.stage.appendChild(el)
			this.items.push({cfg, el, phase: null})
		}
		const sortedTexts = (pres.texts || []).map((cfg, idx) => ({cfg, idx})).sort((a, b) => (a.cfg.z || 0) - (b.cfg.z || 0))
		for (const {cfg, idx} of sortedTexts){
			const el = document.createElement('div')
			el.className = 'pp-text'
			el.dataset.idx = idx
			el.style.left = (cfg.x || 0) + '%'
			el.style.top = (cfg.y || 0) + '%'
			el.style.width = (cfg.w || 80) + '%'
			el.style.zIndex = 2000 + (cfg.z || 0)
			el.style.fontFamily = cfg.font || 'system-ui, sans-serif'
			el.style.fontSize = (cfg.size || 6) + 'cqh'
			el.style.color = cfg.color || '#ffffff'
			el.style.textAlign = cfg.align || 'center'
			if (cfg.bold) el.style.fontWeight = 'bold'
			const du = cfg.during && PresentationPlayer.durings[cfg.during.name]
			if (du) for (const [key, val] of Object.entries(du.vars(cfg.during.amount ?? du.amount, cfg.during.dir || du.dir))) el.style.setProperty(key, val)
			let words = null
			if (cfg.kinetic){
				words = []
				let wi = 0
				for (const tok of this.contentFor(cfg).split(/(\s+)/)){
					if (tok === '') continue
					if (/^\s+$/.test(tok)){
						el.appendChild(document.createTextNode(tok))
						continue
					}
					const span = document.createElement('span')
					span.className = 'pp-word'
					span.textContent = tok
					el.appendChild(span)
					words.push({span, i: wi})
					wi++
				}
			}
			else el.textContent = this.contentFor(cfg)
			this.stage.appendChild(el)
			this.items.push({cfg, el, phase: null, words})
		}
		this.showSubs = (pres.subtitles || {}).show !== false
		this.subIndex = -1
		this.loaded = false
		this.root.classList.add('pp-loading')
		Promise.all(jobs).then(() => {
			if (gen !== this.updateGen) return
			this.loaded = true
			this.root.classList.remove('pp-loading')
			this.applyTime(this.t, true)
			this.updateTime()
			if (this.resumePlay){
				this.resumePlay = false
				this.play()
				setTimeout(() => {
					if (this.playing && this.audio && this.audio.paused) this.pause()
				}, 350)
			}
			if (this.opts.onLoaded) this.opts.onLoaded()
		})
		this.applyTime(this.t, true)
		this.updateTime()
		this.refreshLangSel()
		if (this.ccBtn){
			this.ccBtn.style.display = (this.segments() || []).length ? '' : 'none'
			this.ccBtn.classList.toggle('pp-off', !this.showSubs)
		}
		this.applyVolume()
	}

	segments(){
		if (!this.transcript) return null
		const tr = this.transcript.translations || {}
		if (this.lang && tr[this.lang]) return tr[this.lang]
		return this.transcript.segments || null
	}

	setSubs(show){
		this.showSubs = show
		if (this.ccBtn) this.ccBtn.classList.toggle('pp-off', !show)
		this.subIndex = -1
		this.applySubs(this.t)
	}

	refreshLangSel(){
		if (!this.langSel) return
		const langs = this.opts.langSelector === false ? [] : this.languages()
		if (!langs.length){
			this.langSel.style.display = 'none'
			return
		}
		this.langSel.innerHTML = ''
		const opt = (value, label) => {
			const o = document.createElement('option')
			o.value = value
			o.textContent = label
			this.langSel.appendChild(o)
		}
		opt('original', (this.pres.lang || 'original').toUpperCase())
		for (const l of langs) opt(l, l.toUpperCase())
		this.langSel.value = (this.lang && this.lang !== this.pres.lang) ? this.lang : 'original'
		this.langSel.style.display = ''
	}

	toggle(){
		this.playing ? this.pause() : this.play()
	}

	play(){
		if (!this.loaded) return
		if (!this.mediaLoaded){
			this.loadMedia().then(() => this.play())
			return
		}
		if (!this.started && this.t > 0) this.seek(0)
		this.started = true
		if (this.t >= this.duration - 0.05) this.seek(0)
		this.playing = true
		this.root.classList.remove('pp-paused')
		if (this.wake) this.wake()
		if (this.bigPlay) this.bigPlay.style.display = 'none'
		if (this.playBtn) this.playBtn.innerHTML = this.icon('pause')
		if (this.playBtn) this.playBtn.setAttribute('aria-label', 'Pause')
		this.clockStart = performance.now() - this.t * 1000
		this.syncVideos(true)
		if (this.audio) this.syncAudio(true)
		for (const item of this.items) item.el.style.animationPlayState = 'running'
		const step = () => {
			if (!this.playing) return
			this.t = (performance.now() - this.clockStart) / 1000
			const tick = Math.floor(this.t)
			if (tick !== this.savedTick){
				this.savedTick = tick
				this.saveState()
			}
			if (this.t >= this.duration){
				this.t = this.duration
				this.pause(true)
			}
			else this.applyTime(this.t)
			this.updateTime()
			if (this.opts.onTime) this.opts.onTime(this.t)
			if (this.playing) this.raf = requestAnimationFrame(step)
		}
		this.raf = requestAnimationFrame(step)
		this.ticker = setInterval(() => { if (document.hidden) step() }, 250)
	}

	pause(ended){
		this.playing = false
		clearTimeout(this.idleTimer)
		this.root.classList.remove('pp-idle')
		if (!this.opts.edit) this.clearState()
		this.root.classList.add('pp-paused')
		if (this.raf) cancelAnimationFrame(this.raf)
		if (this.ticker) clearInterval(this.ticker)
		if (this.audio) this.audio.pause()
		for (const v of this.videos) v.pause()
		for (const item of this.items) item.el.style.animationPlayState = 'paused'
		if (this.bigPlay) this.bigPlay.style.display = ''
		if (this.playBtn) this.playBtn.innerHTML = this.icon('play')
		if (this.playBtn) this.playBtn.setAttribute('aria-label', 'Play')
		if (ended && this.opts.onEnded) this.opts.onEnded()
	}

	seek(t){
		this.t = Math.min(Math.max(0, t), this.duration)
		this.clockStart = performance.now() - this.t * 1000
		this.applyTime(this.t, true)
		this.updateTime()
		if (this.opts.onTime) this.opts.onTime(this.t)
	}

	applyTime(t, force){
		for (const item of this.items){
			const cfg = item.cfg
			const start = cfg.start || 0
			const end = start + (cfg.duration || 0)
			const inDur = cfg.in && PresentationPlayer.transitions[cfg.in.name] ? (cfg.in.dur || 0) : 0
			const outDur = cfg.out && PresentationPlayer.transitions[cfg.out.name] ? (cfg.out.dur || 0) : 0
			let phase = 'hidden'
			if (t >= start && t < end){
				if (t < start + inDur) phase = 'in'
				else if (t > end - outDur) phase = 'out'
				else phase = 'on'
			}
			if (phase !== item.phase || force) this.setPhase(item, phase, t)
		}
		for (const item of this.items){
			if (!item.words) continue
			const start = item.cfg.start || 0
			if (t < start || t >= start + (item.cfg.duration || 0)) continue
			for (const {span, i} of item.words){
				const st = PresentationPlayer.popState(t - start - i * 0.06)
				span.style.opacity = st.o
				span.style.transform = 'translateY(' + st.y + 'em) scale(' + st.s + ')'
			}
		}
		this.syncVideos(force)
		if (this.audio) this.syncAudio(force)
		this.applySubs(t)
	}

	static popState(dt){
		const D = 0.34
		if (dt <= 0) return {o: 0, s: 0.82, y: 0.28}
		if (dt >= D) return {o: 1, s: 1, y: 0}
		const p = dt / D
		const e = 1 + 2.70158 * Math.pow(p - 1, 3) + 1.70158 * Math.pow(p - 1, 2)
		return {o: Math.min(1, p * 1.8), s: 0.82 + 0.18 * e, y: 0.28 * (1 - e)}
	}

	setPhase(item, phase, t){
		const el = item.el
		const cfg = item.cfg
		item.phase = phase
		if (phase === 'hidden'){
			el.style.display = 'none'
			return
		}
		el.style.display = 'block'
		const start = cfg.start || 0
		const end = start + (cfg.duration || 0)
		const names = []
		const durs = []
		const delays = []
		const eases = []
		const counts = []
		el.style.transformOrigin = ''
		if (phase !== 'on'){
			const dir = phase === 'in' ? 'in' : 'out'
			const spec = PresentationPlayer.transitions[cfg[dir].name]
			const dur = cfg[dir].dur || 0.5
			const elapsed = dir === 'in' ? t - start : t - (end - dur)
			names.push(spec[dir])
			durs.push(dur + 's')
			delays.push((-Math.max(0, elapsed)).toFixed(3) + 's')
			eases.push(spec.ease)
			counts.push('1')
			el.style.transformOrigin = (dir === 'in' ? spec.originIn : spec.originOut) || ''
		}
		const du = cfg.during && PresentationPlayer.durings[cfg.during.name]
		if (du){
			names.push(du.css)
			durs.push((du.period || Math.max(0.1, end - start)) + 's')
			delays.push((-Math.max(0, t - start)).toFixed(3) + 's')
			eases.push(du.ease)
			counts.push(du.period ? 'infinite' : '1')
		}
		if (!names.length){
			el.style.animation = 'none'
			return
		}
		el.style.animationName = names.join(', ')
		el.style.animationDuration = durs.join(', ')
		el.style.animationDelay = delays.join(', ')
		el.style.animationTimingFunction = eases.join(', ')
		el.style.animationIterationCount = counts.join(', ')
		el.style.animationFillMode = 'both'
		el.style.animationPlayState = this.playing ? 'running' : 'paused'
	}

	syncVideos(force){
		for (const v of this.videos) this.syncMedia(v, v._start, force)
	}

	syncAudio(force){
		this.syncMedia(this.audio, this.audioStart, force)
	}

	syncMedia(el, start, force){
		const known = Number.isFinite(el.duration) && el.duration > 0
		const show = known && this.t >= start && this.t < start + el.duration
		if (el.style) el.style.display = show ? '' : 'none'
		if (el.style && el.tagName === 'VIDEO'){
			const end = start + (el.duration || 0)
			let op = 1
			if (el._inDur && this.t < start + el._inDur) op = (this.t - start) / el._inDur
			else if (el._outDur && this.t > end - el._outDur) op = (end - this.t) / el._outDur
			el.style.opacity = Math.max(0, Math.min(1, op)).toFixed(3)
		}
		if (el.readyState < 1) return
		if (!show){
			if (!el.paused) el.pause()
			return
		}
		const target = this.t - start
		if (force || Math.abs(el.currentTime - target) > 0.35) el.currentTime = Math.min(Math.max(0, target), el.duration || target)
		if (this.playing && el.paused) el.play().catch(() => {})
		if (!this.playing && !el.paused) el.pause()
	}

	applySubs(t){
		const segs = this.showSubs ? this.segments() : null
		if (!segs || !segs.length){
			this.subsEl.style.display = 'none'
			return
		}
		let current = null
		for (const seg of segs){
			if (t >= seg.start && t <= seg.end){
				current = seg
				break
			}
		}
		if (current){
			this.subsEl.textContent = current.text
			this.subsEl.style.display = ''
		}
		else this.subsEl.style.display = 'none'
	}

	fmt(t){
		const m = Math.floor(t / 60)
		const s = Math.floor(t - m * 60)
		return m + ':' + String(s).padStart(2, '0')
	}

	updateTime(){
		const shown = this.started ? this.t : 0
		if (this.timeEl) this.timeEl.textContent = this.fmt(shown) + ' / ' + this.fmt(this.duration)
		if (this.seekEl && !this.seeking) this.seekEl.value = this.duration ? Math.round(shown / this.duration * 1000) : 0
	}

	static sweep(){
		for (const player of this.instances){
			if (player.root.isConnected) continue
			player.destroy()
			this.instances.delete(player)
		}
		this.boot()
	}

	static boot(){
		for (const root of document.querySelectorAll('.pp-embed')){
			if (root.dataset.ppBooted) continue
			const data = root.querySelector('script[type="application/json"]')
			if (data){
				root.dataset.ppBooted = '1'
				this.wire(root, JSON.parse(data.textContent))
				continue
			}
			if (!root.dataset.src) continue
			root.dataset.ppBooted = '1'
			fetch(root.dataset.src)
				.then(r => r.json())
				.then(json => {
					if (!root.isConnected) return
					const payload = json.presentation ? json : {presentation: json, mediaBase: root.dataset.src.replace(/[^\/]*$/, '') + 'media/'}
					this.wire(root, payload)
				})
				.catch(e => console.warn('PresentationPlayer: failed to load', root.dataset.src, e))
		}
	}

	static wire(root, payload){
		const forced = 'lang' in root.dataset
		new PresentationPlayer(root, payload, {controls: true, lang: root.dataset.lang || null, langSelector: !forced})
	}

	static keys = {
		' ': player => player.toggle(),
		k: player => player.toggle(),
		m: player => player.toggleMute(),
		c: player => (player.segments() || []).length && player.setSubs(!player.showSubs),
		f: player => player.fullscreen(),
		home: player => player.seek(0),
		end: player => player.seek(player.duration),
		arrowleft: player => player.seek(player.t - 5),
		arrowright: player => player.seek(player.t + 5),
		arrowup: player => player.setVol(player.vol + 0.05),
		arrowdown: player => player.setVol(player.vol - 0.05),
	}

	static keyTarget(){
		const players = [...this.instances].filter(p => p.root.isConnected && p.playBtn)
		if (document.fullscreenElement){
			const full = players.find(p => document.fullscreenElement.contains(p.root))
			if (full) return full
		}
		return players.find(p => p.root.contains(document.activeElement)) || (players.length === 1 ? players[0] : null)
	}

	static key(e){
		if (e.defaultPrevented || e.altKey || e.ctrlKey || e.metaKey) return
		const action = this.keys[e.key.toLowerCase()]
		if (!action) return
		const el = e.target
		if (el.closest && el.closest('input, textarea, select, [contenteditable]') && !el.classList.contains('pp-seek') && !el.classList.contains('pp-vol')) return
		const player = this.keyTarget()
		if (!player) return
		e.preventDefault()
		action(player)
		if (player.wake) player.wake()
	}
}

if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => PresentationPlayer.boot())
else PresentationPlayer.boot()
new MutationObserver(() => PresentationPlayer.sweep()).observe(document.documentElement, {childList: true, subtree: true})
document.addEventListener('keydown', e => PresentationPlayer.key(e))
view

style

line 1022
.pp-root {
	position: relative
	background: #000
	overflow: hidden
	user-select: none
	font-family: system-ui, sans-serif
}
.pp-stage {
	position: absolute
	inset: 0
	overflow: hidden
	container-type: size
}
.pp-video {
	position: absolute
	inset: 0
	width: 100%
	height: 100%
	object-fit: cover
}
.pp-video-box {
	right: auto
	bottom: auto
	height: auto
}
.pp-item {
	position: absolute
	will-change: transform, opacity, clip-path
	img {
		display: block
		width: 100%
		height: auto
	}
	a {
		display: block
		cursor: pointer
	}
}
.pp-text {
	position: absolute
	will-change: transform, opacity
	white-space: pre-wrap
	overflow-wrap: break-word
	line-height: 1.25
	text-shadow: 0 1px 3px rgba(0,0,0,.55)
	.pp-word {
		display: inline-block
		will-change: transform, opacity
	}
}
.pp-subs {
	position: absolute
	left: 50%
	bottom: 5%
	transform: translateX(-50%)
	max-width: 84%
	padding: .3em .8em
	background: rgba(0,0,0,.55)
	color: #fff
	font-size: 1.5em
	line-height: 1.35
	text-align: center
	border-radius: .3em
	pointer-events: none
	z-index: 3000
}
.pp-bigplay {
	position: absolute
	left: 50%
	top: 50%
	transform: translate(-50%,-50%)
	width: 5em
	height: 5em
	border-radius: 50%
	background: rgba(0,0,0,.6)
	border: 2px solid rgba(255,255,255,.8)
	color: #fff
	cursor: pointer
	z-index: 3050
	display: flex
	align-items: center
	justify-content: center
	transition: transform .15s ease
	\:hover: transform: translate(-50%,-50%) scale(1.08)
	svg {
		width: 2em
		height: 2em
		fill: #fff
		margin-left: .3em
	}
}
.pp-controls {
	position: absolute
	left: 0
	right: 0
	bottom: 0
	display: flex
	align-items: center
	gap: .8em
	padding: 2.2em .9em .5em
	background: linear-gradient(transparent, rgba(0,0,0,.75))
	z-index: 3040
	opacity: 0
	transition: opacity .25s ease
	font-size: .9em
}
.pp-root:hover:not(.pp-idle) .pp-controls, .pp-root.pp-paused .pp-controls: opacity: 1
.pp-root.pp-idle: cursor: none
.pp-root:focus-visible {
	outline: 2px solid #FFB347
	outline-offset: -2px
}
.pp-controls button {
	background: none
	border: 0
	color: #fff
	cursor: pointer
	font-size: 1em
	padding: .2em .4em
	opacity: .9
	\:hover: opacity: 1
	svg {
		width: 1.4em
		height: 1.4em
		fill: #fff
		display: block
	}
}
.pp-controls button.pp-off: opacity: .35
.pp-controls input.pp-vol {
	accent-color: #FFB347
	cursor: pointer
	width: 64px
}
.pp-controls select.pp-lang {
	background: rgba(0,0,0,.45)
	color: #fff
	border: 1px solid rgba(255,255,255,.3)
	border-radius: .3em
	font: inherit
	font-size: .82em
	padding: .15em .35em
	cursor: pointer
	option: color: #000
}
.pp-time {
	color: #fff
	font-variant-numeric: tabular-nums
	font-size: .95em
	white-space: nowrap
}
.pp-seek {
	flex: 1
	accent-color: #FFB347
	cursor: pointer
	height: 1.2em
	margin: 0
}
.pp-loading .pp-bigplay {
	pointer-events: none
	opacity: .6
	border-top-color: #FFB347
	animation: pp-spin 1s linear infinite
	svg: opacity: .3
}
.pp-edit {
	outline: 1px solid rgba(108,192,255,.7)
	box-shadow: 0 0 0 1px rgba(0,0,0,.6), 0 0 0 4px rgba(108,192,255,.12), 0 10px 44px rgba(0,0,0,.6)
}
.pp-edit .pp-item {
	cursor: move
	outline: 1px dashed transparent
	\:hover: outline: 1px dashed rgba(255,179,71,.6)
}
.pp-edit .pp-item.pp-sel {
	outline: 2px solid #FFB347
	z-index: 899
}
.pp-handle {
	position: absolute
	right: -7px
	bottom: -7px
	width: 14px
	height: 14px
	background: #FFB347
	border-radius: 3px
	cursor: nwse-resize
	z-index: 901
}
.pp-edit .pp-text {
	cursor: move
	outline: 1px dashed transparent
	\:hover: outline: 1px dashed rgba(255,179,71,.6)
}
.pp-edit .pp-text.pp-sel {
	outline: 2px solid #FFB347
	z-index: 899
}
.pp-standalone {
	position: fixed
	inset: 0
	display: flex
	align-items: center
	justify-content: center
	background: #000
	.pp-root {
		width: 100vw
		height: 100vh
	}
	@supports (aspect-ratio: 16 / 9) {
		.pp-root {
			width: min(100vw, calc(100dvh * var(--pp-ar, 1.7778)))
			height: min(100dvh, calc(100vw / var(--pp-ar, 1.7778)))
		}
	}
}
@keyframes pp-spin {
	0%: transform: translate(-50%,-50%) rotate(0deg)
	100%: transform: translate(-50%,-50%) rotate(360deg)
}
@keyframes pp-fade-in {
	0%: opacity: 0
	100%: opacity: 1
}
@keyframes pp-fade-out {
	0%: opacity: 1
	100%: opacity: 0
}
@keyframes pp-zoom-in {
	0% {
		opacity: 0
		transform: scale(.96)
	}
	100% {
		opacity: 1
		transform: scale(1)
	}
}
@keyframes pp-zoom-out {
	0% {
		opacity: 1
		transform: scale(1)
	}
	100% {
		opacity: 0
		transform: scale(1.05)
	}
}
@keyframes pp-glide-in {
	0%: transform: translateX(-100dvw)
	100%: transform: translateX(0)
}
@keyframes pp-glide-out {
	0% {
		opacity: 1
		transform: translateX(0)
	}
	100% {
		opacity: 0
		transform: translateX(100dvw)
	}
}
@keyframes pp-slide-in {
	0% {
		opacity: 0
		transform: translateX(-20px)
	}
	100% {
		opacity: 1
		transform: translateX(0)
	}
}
@keyframes pp-slide-out {
	0% {
		opacity: 1
		transform: translateX(0)
	}
	100% {
		opacity: 0
		transform: translateX(20px)
	}
}
@keyframes pp-drop-in {
	0% {
		opacity: 0
		transform: translateY(-50px)
	}
	100% {
		opacity: 1
		transform: translateY(0)
	}
}
@keyframes pp-drop-out {
	0% {
		opacity: 1
		transform: translateY(0)
	}
	100% {
		opacity: 0
		transform: translateY(50px)
	}
}
@keyframes pp-skew-in {
	0% {
		opacity: 0
		transform: skewX(-15deg)
	}
	100% {
		opacity: 1
		transform: skewX(0deg)
	}
}
@keyframes pp-skew-out {
	0% {
		opacity: 1
		transform: skewX(0deg)
	}
	100% {
		opacity: 0
		transform: skewX(15deg)
	}
}
@keyframes pp-tilt-in {
	0% {
		opacity: 0
		transform: rotateZ(-5deg) translateY(20px)
	}
	100% {
		opacity: 1
		transform: rotateZ(0deg) translateY(0)
	}
}
@keyframes pp-tilt-out {
	0% {
		opacity: 1
		transform: rotateZ(0deg) translateY(0)
	}
	100% {
		opacity: 0
		transform: rotateZ(5deg) translateY(-20px)
	}
}
@keyframes pp-spiral-in {
	0% {
		opacity: 0
		transform: rotate(-360deg) scale(0)
	}
	100% {
		opacity: 1
		transform: rotate(0deg) scale(1)
	}
}
@keyframes pp-spiral-out {
	0% {
		opacity: 1
		transform: rotate(0deg) scale(1)
	}
	100% {
		opacity: 0
		transform: rotate(360deg) scale(0)
	}
}
@keyframes pp-ripple-in {
	0% {
		opacity: 0
		transform: scale(.5)
		filter: blur(3px)
	}
	100% {
		opacity: 1
		transform: scale(1)
		filter: blur(0px)
	}
}
@keyframes pp-ripple-out {
	0% {
		opacity: 1
		transform: scale(1)
		filter: blur(0px)
	}
	100% {
		opacity: 0
		transform: scale(1.5)
		filter: blur(3px)
	}
}
@keyframes pp-curtain-in {
	0% {
		opacity: 0
		transform: scaleY(0)
		transform-origin: bottom
	}
	100% {
		opacity: 1
		transform: scaleY(1)
		transform-origin: bottom
	}
}
@keyframes pp-curtain-out {
	0% {
		opacity: 1
		transform: scaleY(1)
		transform-origin: top
	}
	100% {
		opacity: 0
		transform: scaleY(0)
		transform-origin: top
	}
}
@keyframes pp-tv-in {
	0% {
		opacity: 0
		transform: scale(0, 0)
	}
	50% {
		opacity: 0.8
		transform: scale(1, 0.02)
	}
	100% {
		opacity: 1
		transform: scale(1, 1)
	}
}
@keyframes pp-tv-out {
	0% {
		opacity: 1
		transform: scale(1, 1)
	}
	50% {
		opacity: 0.8
		transform: scale(1, 0.02)
	}
	100% {
		opacity: 0
		transform: scale(0, 0)
	}
}
@keyframes pp-flip-in {
	0% {
		transform: perspective(1600px) rotateY(-180deg)
		opacity: 0
	}
	50%: opacity: 0
	50.1%: opacity: 1
	100% {
		transform: perspective(1600px) rotateY(0deg)
		opacity: 1
	}
}
@keyframes pp-flip-out {
	0% {
		transform: perspective(1600px) rotateY(0deg)
		opacity: 1
	}
	49.9%: opacity: 1
	50%: opacity: 0
	100% {
		transform: perspective(1600px) rotateY(180deg)
		opacity: 0
	}
}
@keyframes pp-cube-in {
	0% {
		transform: rotateY(90deg) translateZ(0)
		opacity: 0
	}
	100% {
		transform: rotateY(0deg) translateZ(0)
		opacity: 1
	}
}
@keyframes pp-cube-out {
	0% {
		transform: rotateY(0deg) translateZ(0)
		opacity: 1
	}
	100% {
		transform: rotateY(-90deg) translateZ(0)
		opacity: 0
	}
}
@keyframes pp-diamond-in {
	0%: clip-path: polygon(50% 50%,50% 50%,50% 50%,50% 50%)
	100%: clip-path: polygon(50% -50%,150% 50%,50% 150%,-50% 50%)
}
@keyframes pp-diamond-out {
	0%: clip-path: polygon(50% -50%,150% 50%,50% 150%,-50% 50%)
	100%: clip-path: polygon(50% 50%,50% 50%,50% 50%,50% 50%)
}
@keyframes pp-diaphragm-in {
	0%: clip-path: circle(0% at 50% 50%)
	100%: clip-path: circle(90% at 50% 50%)
}
@keyframes pp-diaphragm-out {
	0%: clip-path: circle(90% at 50% 50%)
	100%: clip-path: circle(0% at 50% 50%)
}
@keyframes pp-spotlight-in {
	0%: clip-path: circle(0% at 50% 50%)
	100%: clip-path: circle(75% at 50% 50%)
}
@keyframes pp-spotlight-out {
	0%: clip-path: circle(75% at 50% 50%)
	100%: clip-path: circle(0% at 50% 50%)
}
@keyframes pp-wipe-in {
	0% {
		clip-path: inset(0% 100% 0% 0%)
		opacity: 0
	}
	40% {
		clip-path: inset(0% 40% 0% 0%)
		opacity: .9
	}
	100% {
		clip-path: inset(0% 0% 0% 0%)
		opacity: 1
	}
}
@keyframes pp-wipe-out {
	0% {
		clip-path: inset(0% 0% 0% 0%)
		opacity: 1
	}
	60% {
		clip-path: inset(0% 0% 0% 60%)
		opacity: .3
	}
	100% {
		clip-path: inset(0% 0% 0% 100%)
		opacity: 0
	}
}
@keyframes pp-glitch-in {
	0% {
		clip-path: inset(100% 0 0 0)
		transform: translate(10px, 0)
		opacity: 0
	}
	20% {
		clip-path: inset(10% 0 60% 0)
		transform: translate(-10px, 5px)
	}
	40% {
		clip-path: inset(80% 0 5% 0)
		transform: translate(5px, -10px)
	}
	60% {
		clip-path: inset(0 0 20% 0)
		transform: translate(-5px, 5px)
	}
	80% {
		clip-path: inset(40% 0 40% 0)
		transform: translate(5px, 0)
	}
	100% {
		clip-path: inset(0 0 0 0)
		transform: translate(0)
		opacity: 1
	}
}
@keyframes pp-glitch-out {
	0% {
		clip-path: inset(0 0 0 0)
		transform: translate(0)
	}
	20% {
		clip-path: inset(20% 0 80% 0)
		transform: translate(-5px, 5px)
	}
	40% {
		clip-path: inset(80% 0 5% 0)
		transform: translate(5px, -5px)
	}
	60% {
		clip-path: inset(10% 0 60% 0)
		transform: translate(-5px, 0)
	}
	80% {
		clip-path: inset(50% 0 20% 0)
		transform: translate(5px, 5px)
	}
	100% {
		clip-path: inset(50% 50% 50% 50%)
		transform: translate(0)
		opacity: 0
	}
}
@keyframes pp-cards-in {
	0%: transform: translateY(100%)
	100%: transform: translateY(0)
}
@keyframes pp-cards-out {
	0% {
		transform: translateY(0)
		opacity: 1
	}
	100% {
		transform: translateY(100%)
		opacity: 0
	}
}
@keyframes pp-scale {
	0%: scale: var(--pp-s0, .9)
	100%: scale: 1
}
@keyframes pp-drift {
	0%: translate: var(--pp-x0, 5%) var(--pp-y0, 0%)
	100%: translate: 0% 0%
}
@keyframes pp-kenburns {
	0% {
		scale: var(--pp-s0, .92)
		translate: var(--pp-x0, -4%) var(--pp-y0, 4%)
	}
	100% {
		scale: 1
		translate: 0% 0%
	}
}
@keyframes pp-float {
	0%: translate: 0% 0%
	50%: translate: 0% var(--pp-y1, -3%)
	100%: translate: 0% 0%
}
@keyframes pp-sway {
	0%: rotate: var(--pp-r0, -1.5deg)
	50%: rotate: var(--pp-r1, 1.5deg)
	100%: rotate: var(--pp-r0, -1.5deg)
}
@keyframes pp-pulse {
	0%: scale: 1
	50%: scale: var(--pp-s1, 1.02)
	100%: scale: 1
}
object

%recorder

/phlo/resources/DOM/recorder.phlo

Record the screen, a camera/mic or a canvas via MediaRecorder -> Blob. Optionally transcodes to MP4 with the DOM/ffmpeg resource when it is loaded. Exposes the ready singleton `recorder` (and class `Recorder`).

Records the screen, a camera or a canvas through MediaRecorder and answers with a blob. The browser decides the container, which is usually WebM and on Safari is not, so transcode when the file has to be played anywhere; loading the ffmpeg resource gives you MP4. Recording needs a real gesture from the visitor and a secure origin, so it cannot be started from a script alone.

videorecordermediarecorderscreencapturegetdisplaymediawebcamcanvas
view

script

line 11
class Recorder {

	constructor(opts = {}){
		this.mimeType = opts.mimeType || ''
		this.log = opts.log || (() => {})
	}

	supported(){
		return typeof MediaRecorder !== 'undefined' && !!(navigator.mediaDevices)
	}

	pickMime(prefs){
		if (typeof MediaRecorder === 'undefined') return ''
		for (const m of prefs) if (m && MediaRecorder.isTypeSupported(m)) return m
		return ''
	}

	mixed(video, audioStreams){
		const ctx = new AudioContext()
		const dest = ctx.createMediaStreamDestination()
		const sources = []
		for (const s of audioStreams){
			if (!s.getAudioTracks().length) continue
			ctx.createMediaStreamSource(s).connect(dest)
			sources.push(...s.getAudioTracks())
		}
		const out = new MediaStream([...video.getVideoTracks(), ...dest.stream.getAudioTracks()])
		out.phloSources = sources
		out.phloCtx = ctx
		return out
	}

	async stream(opts = {}){
		if (opts.stream) return opts.stream
		if (opts.canvas){
			const s = opts.canvas.captureStream(opts.fps || 30)
			if (opts.mic){
				const m = await navigator.mediaDevices.getUserMedia({audio: true})
				m.getAudioTracks().forEach(t => s.addTrack(t))
			}
			return s
		}
		if (opts.screen){
			const s = await navigator.mediaDevices.getDisplayMedia({video: opts.video == null ? true : opts.video, audio: opts.audio == null ? false : opts.audio})
			if (opts.mic){
				const m = await navigator.mediaDevices.getUserMedia({audio: true})
				if (s.getAudioTracks().length) return this.mixed(s, [s, m])
				m.getAudioTracks().forEach(t => s.addTrack(t))
			}
			return s
		}
		return navigator.mediaDevices.getUserMedia({video: opts.video == null ? true : opts.video, audio: opts.audio == null ? true : opts.audio})
	}

	async record(opts = {}){
		if (!this.supported()) throw new Error('MediaRecorder not supported here')
		const stream = await this.stream(opts)
		const ownTracks = !opts.stream
		const mime = opts.mimeType || this.mimeType || this.pickMime(['video/webm;codecs=vp9,opus', 'video/webm;codecs=vp8,opus', 'video/webm', 'video/mp4'])
		const recOpts = {}
		if (mime) recOpts.mimeType = mime
		if (opts.videoBitsPerSecond) recOpts.videoBitsPerSecond = opts.videoBitsPerSecond
		if (opts.audioBitsPerSecond) recOpts.audioBitsPerSecond = opts.audioBitsPerSecond
		const rec = new MediaRecorder(stream, recOpts)
		const chunks = []
		let stopResolve, stopReject
		const stopped = new Promise((res, rej) => {
			stopResolve = res
			stopReject = rej
		})
		rec.ondataavailable = e => {
			if (e.data && e.data.size){
				chunks.push(e.data)
				if (opts.onData) opts.onData(e.data)
			}
		}
		rec.onstop = () => {
			if (ownTracks){
				for (const t of stream.getTracks()) t.stop()
				for (const t of stream.phloSources || []) t.stop()
				if (stream.phloCtx) stream.phloCtx.close().catch(() => {})
			}
			stopResolve(new Blob(chunks, {type: (rec.mimeType || mime || 'video/webm').split(';')[0]}))
		}
		rec.onerror = e => stopReject((e && e.error) || new Error('recording error'))
		stream.getVideoTracks().forEach(t => t.addEventListener('ended', () => { if (rec.state !== 'inactive') rec.stop() }, {once: true}))
		rec.start(opts.timeslice || undefined)
		this.log('[recorder] started ' + (rec.mimeType || mime || 'default'))
		return {
			stream,
			recorder: rec,
			get state(){ return rec.state },
			pause(){ if (rec.state === 'recording') rec.pause() },
			resume(){ if (rec.state === 'paused') rec.resume() },
			async stop(o = {}){
				if (rec.state !== 'inactive') rec.stop()
				const blob = await stopped
				if (o.mp4 && typeof ffmpeg !== 'undefined' && blob.type.indexOf('mp4') === -1){
					return ffmpeg.transcode(blob, {to: 'mp4', crf: o.crf, args: o.args, onProgress: o.onProgress, log: o.log})
				}
				return blob
			},
		}
	}
}

const recorder = new Recorder
object

%shorthands

/phlo/resources/DOM/shorthands.phlo

onChange, onClick and onInput event shorthands

Nothing more than onClick, onChange and onInput for the three most common cases of on(). Same behaviour and the same limitation: they bind to what exists at that moment, so use onExist for anything that appears later.

domeventsshorthandfrontend
view

script

line 11
Definieert afkortingsfuncties voor veelvoorkomende gebeurtenisluisteraars: onChange, onClick en onInput, die het proces van het koppelen van gebeurtenisbehandelaars aan elementen vereenvoudigen.
function onChange(els, cb){ on('change', els, cb) }
function onClick(els, cb){ on('click', els, cb) }
function onInput(els, cb){ on('input', els, cb) }
object

%store

/phlo/resources/DOM/store.phlo

Stateful binding engine

Bind an element to a value with data-bind and it follows every change, in both directions on an input. data-bind-attr does the same for an attribute, data-each repeats a template over a list, and app.calc holds values derived from others and recalculated on their own. app.persist keeps a path across a reload and app.sync keeps it equal across tabs or over a websocket. On a first render the DOM wins over an empty store, so server-rendered content is not blanked before the store has been filled.

domstorebindingstatesignalscalcreactiveeachpersistwebsocketsync
view

script

line 11
phlo.store = {
	signals: {},
	listeners: {},
	calcs: {},
	calcDeps: {},
	calcVals: {},
	calcTick: false,
	formats: {},
	persists: {},
	syncs: {},
	quiet: 0,
	scope: 'page',
	split: path => path.replace(/\]/g, '').split(/\.|\[/),
	get(path){
		if (!path) return undefined
		let ctx = phlo.store.signals
		const keys = phlo.store.split(path)
		for (let i = 0; i < keys.length; i++){
			if (ctx == null) return undefined
			ctx = ctx[keys[i]]
		}
		return ctx
	},
	setPath(path, value){
		let keys = phlo.store.split(path)
		let ctx = phlo.store.signals
		while (keys.length > 1){
			const k = keys.shift()
			ctx[k] ??= isNaN(keys[0]) ? {} : []
			ctx = ctx[k]
		}
		const k = keys[0]
		const old = ctx[k]
		if (old === value) return false
		ctx[k] = value
		return true
	},
	set(path, value){
		if (!phlo.store.setPath(path, value)) return
		phlo.store.notify(path, phlo.store.get(path))
		phlo.store.recalc(path)
		phlo.store.schedule()
		phlo.store.save(path)
		if (!phlo.store.quiet) phlo.store.push(path)
	},
	on(path, cb, el = null){
		(phlo.store.listeners[path] ??= new Set).add(cb)
		if (el) phlo.store.owners.set(cb, el)
	},
	off(path, cb){
		phlo.store.listeners[path]?.delete(cb)
		phlo.store.owners.delete(cb)
	},
	owners: new WeakMap,
	sweep(){
		Object.keys(phlo.store.listeners).forEach(path => {
			phlo.store.listeners[path].forEach(cb => {
				const el = phlo.store.owners.get(cb)
				if (el && !el.isConnected) phlo.store.off(path, cb)
			})
			if (!phlo.store.listeners[path].size) delete phlo.store.listeners[path]
		})
	},
	reset(prefix = ''){
		if (!prefix){
			phlo.store.signals = {}
			phlo.store.listeners = {}
			phlo.store.calcs = {}
			phlo.store.calcDeps = {}
			phlo.store.calcVals = {}
			phlo.store.calcTick = false
			return
		}
		const keys = phlo.store.split(prefix)
		let ctx = phlo.store.signals
		for (let i = 0; i < keys.length - 1; i++) ctx = ctx?.[keys[i]]
		if (ctx) delete ctx[keys[keys.length - 1]]
		phlo.store.notify(prefix, undefined)
		Object.keys(phlo.store.listeners).forEach(path => phlo.store.match(prefix, path) && delete phlo.store.listeners[path])
	},
	replace(path, value){
		const keys = phlo.store.split(path)
		let ctx = phlo.store.signals
		for (let i = 0; i < keys.length - 1; i++) ctx = ctx?.[keys[i]]
		if (ctx) delete ctx[keys[keys.length - 1]]
		phlo.store.set(path, value)
	},
	signal(path, initial){
		if (phlo.store.get(path) === undefined) phlo.store.set(path, initial)
		return { subscribe: cb => phlo.store.on(path, cb), unsubscribe: cb => phlo.store.off(path, cb) }
	},
	notify(path, val){
		Object.keys(phlo.store.listeners).forEach(dep => {
			if (!phlo.store.match(dep, path)) return
			const set = phlo.store.listeners[dep]
			if (!set) return
			const value = dep === path ? val : phlo.store.get(dep)
			;[...set].forEach(cb => {
				try { cb(value) }
				catch(e){ phlo.log('store binding', dep, e) }
			})
		})
	},
	match(dep, changed){
		if (!dep) return false
		if (dep === changed) return true
		return changed.startsWith(dep + '.') || changed.startsWith(dep + '[') || dep.startsWith(changed + '.') || dep.startsWith(changed + '[')
	},
	depsReady(list){
		const arr = Array.isArray(list) ? list : (list ? [list] : [])
		return arr.every(d => phlo.store.get(d) !== undefined)
	},
	evalCalc(name){
		const fn = phlo.store.calcs[name]
		if (!fn) return
		let deps = []
		let val
		try {
			const out = fn()
			if (Array.isArray(out) && out.length === 2) deps = out[0], val = out[1]
			else val = out
		}
		catch(e){
			deps = []
			val = undefined
		}
		const list = Array.isArray(deps) ? deps : (deps ? [deps] : [])
		phlo.store.calcDeps[name] = list
		if (!phlo.store.depsReady(list)) return
		const old = phlo.store.calcVals[name]
		if (old !== val){
			phlo.store.calcVals[name] = val
			const p = `calc.${name}`
			phlo.store.setPath(p, val)
			phlo.store.notify(p, val)
		}
	},
	recalc(changed){
		const names = Object.keys(phlo.store.calcs)
		for (let i = 0; i < names.length; i++){
			const name = names[i]
			const deps = phlo.store.calcDeps[name] || []
			for (let j = 0; j < deps.length; j++){
				if (phlo.store.match(deps[j], changed)){
					phlo.store.evalCalc(name)
					break
				}
			}
		}
	},
	recalcAll(){
		const names = Object.keys(phlo.store.calcs)
		for (let i = 0; i < names.length; i++) phlo.store.evalCalc(names[i])
	},
	schedule(){
		if (phlo.store.calcTick) return
		phlo.store.calcTick = true
		setTimeout(() => {
			phlo.store.calcTick = false
			phlo.store.recalcAll()
		})
	},

	format(name, value){
		const fn = phlo.store.formats[name]
		return fn ? fn(value) : value
	},

	adapters: {
		local: {
			read: key => { try { return JSON.parse(localStorage.getItem('phlo.' + key)) } catch(e){ return undefined } },
			write: (key, value) => { try { localStorage.setItem('phlo.' + key, JSON.stringify(value)) } catch(e){} },
		},
		session: {
			read: key => { try { return JSON.parse(sessionStorage.getItem('phlo.' + key)) } catch(e){ return undefined } },
			write: (key, value) => { try { sessionStorage.setItem('phlo.' + key, JSON.stringify(value)) } catch(e){} },
		},
	},
	persist(path, adapter = 'local'){
		const store = typeof adapter === 'string' ? phlo.store.adapters[adapter] : adapter
		if (!store) return
		phlo.store.persists[path] = store
		const saved = store.read(path)
		if (saved !== undefined && saved !== null) app.mod.store(path, saved)
	},
	save(changed){
		Object.keys(phlo.store.persists).forEach(path => {
			if (phlo.store.match(path, changed)) phlo.store.persists[path].write(path, phlo.store.get(path))
		})
	},

	sync(path, options = {}){
		phlo.store.syncs[path] = {ws: options.ws ?? true, post: options.post ?? null, delay: options.delay ?? 200}
	},
	push(changed){
		Object.keys(phlo.store.syncs).forEach(path => {
			if (!phlo.store.match(path, changed)) return
			const sync = phlo.store.syncs[path]
			delay('store-' + path, sync.delay, () => {
				const value = phlo.store.get(path)
				if (sync.ws && app.websocket?.ready) app.websocket.send({sync: {[path]: value}})
				if (sync.post) app.post(sync.post, {path, value}, false)
			})
		})
	},
	mirror(path, value){
		phlo.store.quiet++
		try { app.mod.store(path, value) }
		finally { phlo.store.quiet-- }
	},

	proxy(base){
		return new Proxy({}, {
			get(t, k){
				if (typeof k === 'symbol') return undefined
				const seg = /^\d+$/.test(k) ? `[${k}]` : (base ? `.${k}` : String(k))
				const path = base + seg
				const v = phlo.store.get(path)
				if (v !== undefined && (typeof v !== 'object' || v === null)) return v
				return phlo.store.proxy(path)
			},
			set(t, k, v){
				const seg = /^\d+$/.test(k) ? `[${k}]` : (base ? `.${k}` : String(k))
				phlo.store.set(base + seg, v)
				return true
			},
			has(t, k){ return phlo.store.get(base + (base ? '.' : '') + String(k)) !== undefined },
			ownKeys(){ return Object.keys(phlo.store.get(base) || {}) },
			getOwnPropertyDescriptor(){ return { enumerable: true, configurable: true } }
		})
	}
}

app.store = phlo.store.proxy('')

app.mod.store = (key, value) => {
	if (JSON.stringify(phlo.store.get(key)) === JSON.stringify(value)) return
	const walk = (base, obj) => {
		if (Array.isArray(obj)) return phlo.store.set(base, obj)
		if (typeof obj !== 'object' || obj === null) return phlo.store.set(base, obj)
		Object.entries(obj).forEach(([k, v]) => walk(isNaN(k) ? `${base}.${k}` : `${base}[${k}]`, v))
	}
	walk(key, value)
}

app.mod.sync = (key, value) => phlo.store.mirror(key, value)

phlo.calc = new Proxy({}, {
	set(t, k, fn){
		if (typeof fn !== 'function') return false
		phlo.store.calcs[k] = fn
		phlo.store.evalCalc(k)
		setTimeout(() => phlo.store.evalCalc(k))
		return true
	},
	get(t, k){ return phlo.store.calcs[k] },
	has(t, k){ return k in phlo.store.calcs },
	deleteProperty(t, k){
		delete phlo.store.calcs[k]
		delete phlo.store.calcDeps[k]
		delete phlo.store.calcVals[k]
		return true
	}
})

app.calc = new Proxy({}, {
	get(t, k){ return phlo.store.calcVals[k] },
	has(t, k){ return k in phlo.store.calcVals },
	ownKeys(){ return Object.keys(phlo.store.calcVals) },
	getOwnPropertyDescriptor(){ return { enumerable: true, configurable: true } }
})

app.format = (name, fn) => phlo.store.formats[name] = fn
app.persist = (path, adapter) => phlo.store.persist(path, adapter)
app.sync = (path, options) => phlo.store.sync(path, options)
app.push = path => phlo.store.push(path)
app.replace = (path, value) => phlo.store.replace(path, value)

onExist('[data-bind]', el => {
	const not = el.dataset.bind.startsWith('!')
	const key = not ? el.dataset.bind.slice(1) : el.dataset.bind
	const isCalc = key.startsWith('calc.')
	const isInput = el.tagName === 'INPUT' || el.tagName === 'SELECT' || el.tagName === 'TEXTAREA'
	const fromDom = isInput ? el.value : el.textContent
	const fromStore = phlo.store.get(key)
	const domNonEmpty = (fromDom ?? '').trim() !== ''
	const storeEmpty = fromStore === undefined || (typeof fromStore === 'string' && fromStore.trim() === '')
	const domLeads = !isCalc && domNonEmpty && storeEmpty
	const format = el.dataset.bindFormat
	const S = v => v == null ? '' : (typeof v === 'object' ? '' : String(v))
	const apply = raw => {
		const v = not ? !raw : raw
		const s = S(format ? phlo.store.format(format, v) : v)
		if (isInput) el.value = s
		else el.textContent = s
	}
	phlo.store.on(key, apply, el)
	if (domLeads) phlo.store.set(key, fromDom)
	const initial = domLeads ? fromDom : fromStore
	apply(initial)
	if (!isCalc && isInput) el.oninput = e => phlo.store.set(key, e.target.value)
})

onExist('[data-bind-attr]', el => {
	const spec = el.getAttribute('data-bind-attr')
	if (!spec) return
	const BOOL = new Set(['disabled','checked','hidden','required','readonly','selected','autofocus','multiple'])
	let meta = phlo.existing.get(el)
	if (!meta || typeof meta !== 'object'){
		meta = { exist: true }
		phlo.existing.set(el, meta)
	}
	meta.attr || (meta.attr = {})
	meta.attr.cls || (meta.attr.cls = [])
	const format = el.dataset.bindFormat
	const owned = (el.dataset.bindClass || '').split(/\s+/).filter(Boolean)
	spec.split(/\s*,\s*/).filter(Boolean).forEach(pair => {
		const m = pair.match(/^\s*([^:]+)\s*:\s*(.+)\s*$/)
		if (!m) return
		const name = m[1]
		const not = m[2].startsWith('!')
		const path = not ? m[2].slice(1) : m[2]
		const isCalc = path.startsWith('calc.')
		const isInputVal = name === 'value' && (el.tagName === 'INPUT' || el.tagName === 'SELECT' || el.tagName === 'TEXTAREA')
		const domVal =
			name === 'text' ? el.textContent :
			name === 'html' ? el.innerHTML :
			name === 'value' ? el.value :
			(name === 'class' ? null : el.getAttribute(name))
		const fromStore = phlo.store.get(path)
		const domNonEmpty = (domVal ?? '').trim() !== ''
		const storeEmpty = fromStore === undefined || (typeof fromStore === 'string' && fromStore.trim() === '')
		const domLeads = !isCalc && name !== 'class' && domNonEmpty && storeEmpty
		const S = v => v == null ? '' : (typeof v === 'object' ? '' : String(v))
		const apply = raw => {
			const v = not ? !raw : raw
			if (name === 'text') el.textContent = S(format ? phlo.store.format(format, v) : v)
			else if (name === 'html') app.mod.inner(el, S(v))
			else if (name === 'value') app.mod.value(el, S(format ? phlo.store.format(format, v) : v))
			else if (name === 'class'){
				const next = Array.isArray(v) ? v : (v && typeof v === 'object') ? Object.keys(v).filter(k => v[k]) : String(v ?? '').split(/\s+/)
				const uniq = [...new Set(next.filter(Boolean))]
				const prev = [...new Set([...meta.attr.cls, ...owned])]
				for (let i = 0; i < prev.length; i++) el.classList.remove(prev[i])
				for (let i = 0; i < uniq.length; i++) el.classList.add(uniq[i])
				meta.attr.cls = uniq
			}
			else if (BOOL.has(name)){
				const on = !!v
				app.mod.attr(el, { [name]: on ? '' : null })
				if (name in el) el[name] = on
			}
			else app.mod.attr(el, { [name]: S(v) })
		}
		phlo.store.on(path, apply, el)
		if (domLeads) phlo.store.set(path, domVal)
		const initial = domLeads ? domVal : fromStore
		apply(initial)
		if (!isCalc && isInputVal) el.oninput = e => phlo.store.set(path, e.target.value)
	})
})

onExist('[data-each]', el => {
	const path = el.dataset.each
	const templates = [...objects('template', el)]
	if (!templates.length) return
	const key = el.dataset.key ? (el.dataset.key.startsWith('.') ? el.dataset.key : '.' + el.dataset.key) : ''
	const pick = el.dataset.eachTemplate ? (el.dataset.eachTemplate.startsWith('.') ? el.dataset.eachTemplate : '.' + el.dataset.eachTemplate) : ''
	const fallback = templates.find(t => t.dataset.template === undefined) || templates[0]
	const template = index => {
		if (!pick) return fallback
		const name = String(phlo.store.get(`${path}[${index}]${pick}`) ?? '')
		return templates.find(t => t.dataset.template === name) || fallback
	}
	const rows = new Map
	const address = (node, index) => {
		const base = `${path}[${index}]`
		const bound = [...objects('[data-bind], [data-bind-attr]', node)]
		if (node.matches('[data-bind], [data-bind-attr]')) bound.push(node)
		bound.forEach(item => {
			if (item.dataset.bind !== undefined && item.dataset.eachBind === undefined) item.dataset.eachBind = item.dataset.bind
			const attr = item.getAttribute('data-bind-attr')
			if (attr !== null && item.dataset.eachBindAttr === undefined) item.dataset.eachBindAttr = attr
			if (item.dataset.eachBind !== undefined){
				const not = item.dataset.eachBind.startsWith('!') ? '!' : ''
				const rel = not ? item.dataset.eachBind.slice(1) : item.dataset.eachBind
				item.dataset.bind = not + (rel === '.' ? base : base + rel)
			}
			if (item.dataset.eachBindAttr !== undefined) item.setAttribute('data-bind-attr', item.dataset.eachBindAttr.replace(/:\s*(!?)\./g, (all, not) => ': ' + not + base + '.'))
		})
	}
	const draw = () => {
		const list = phlo.store.get(path)
		const items = Array.isArray(list) ? list : (list && typeof list === 'object' ? Object.values(list) : [])
		let changed = false
		items.forEach((item, index) => {
			const id = key ? String(phlo.store.get(`${path}[${index}]${key}`) ?? index) : String(index)
			const tpl = template(index)
			const row = rows.get(index)
			if (row && row.id === id && row.tpl === tpl) return
			row?.node.remove()
			const node = tpl.content.firstElementChild.cloneNode(true)
			address(node, index)
			rows.set(index, {id, tpl, node})
			el.appendChild(node)
			changed = true
		})
		for (const [index, row] of [...rows]){
			if (index < items.length) continue
			row.node.remove()
			rows.delete(index)
			changed = true
		}
		if (changed) app.update()
	}
	phlo.store.on(path, draw, el)
	draw()
})

onExist('[data-store-post]', el => {
	const path = el.dataset.bind || el.dataset.storePath
	if (!path) return
	phlo.store.sync(path, {ws: el.dataset.storeWs !== undefined, post: el.dataset.storePost, delay: parseInt(el.dataset.storeDelay) || 200})
})

app.updates.push(() => phlo.store.sweep())
addEventListener('popstate', () => phlo.store.reset(phlo.store.scope))
object

%template

/phlo/resources/DOM/template.phlo

Single Page App client-side templating

Add cb's to the templates object and output via apply(template: [$name => $rows, $name2 => $rows2, etc])

domtemplatespafrontendrender
view

script

line 10
Definieert een functie die een opgegeven sjabloon toepast op elke rij gegevens, waarbij het sjabloon wordt aangeroepen met de waarden uit de rij.
app.mod.template = (template, rows) => rows.forEach(row => templates[template](...Object.values(row)))
const templates = {}
object

%timestamps

/phlo/resources/DOM/timestamps.phlo

DOM live timestamps

Create an app.tsLabels array to overwrite the tsBase labels in any language

domtimestampstimelivefrontend
view

script

line 10
Werk de tekstinhoud van elementen met een 'data-ts'-attribuut bij om de verstreken tijd sinds een tijdstempel in een leesbaar formaat weer te geven, elke seconde vernieuwend.
app.tsBase = {seconds: 60, minutes: 60, hours: 24, days: 7, weeks: 4, months: 13, years: 1}
const tsUpdate = () => (ranges = app.tsLabels && (tsValues = Object.values(app.tsBase)) ? Object.fromEntries(app.tsLabels.map((k, i) => [k, tsValues[i]])) : app.tsBase) && objects('[data-ts]').forEach(el => {
	let age = Math.round(Date.now() / 1000) - Number(el.dataset.ts), text = ''
	const future = age < 0
	if (future) age = -age
	for (const [range, multiplier] of Object.entries(ranges)){
		if (text) continue
		if (age / multiplier < 1.6583) text = `${Math.round(age)} ${range}`
		age /= multiplier
	}
	text ||= `${Math.round(age)} ${Object.keys(ranges).at(-1)}`
	text = `${future ? '-' : ''}${text}`
	el.innerText === text || (el.innerText = text)
})
setInterval(() => document.hidden || tsUpdate(), 1000)
setTimeout(tsUpdate, 1)
object

%toasts

/phlo/resources/DOM/toasts.phlo

Simple toast resource

app.mod.toast(msg) for a short message that needs no answer, gone after four seconds or on a click. Because it is a command, the server can raise one from a route without a line of frontend code. Anything a visitor must confirm belongs in the dialog resource instead.

domtoastnotificationfrontend
view

script

line 11
app.mod.toast = msg => {
	obj('#toasts') || app.mod.append('body', '<div id="toasts"></div>')
	const toast = document.createElement('div')
	toast.textContent = msg
	toast.onclick = () => toast.remove()
	obj('#toasts').insertAdjacentElement('beforeend', toast)
	setTimeout(() => toast.remove(), 4000)
}
view

style

line 22
Definieert de CSS-stijlen voor toastmeldingen, die vast in de rechterbovenhoek worden gepositioneerd met specifieke achtergrondkleur, randradius en tekststijl.
#toasts {
	position: fixed
	right: 10px
	top: 5px
	z-index: 1001
	> * {
		background-color: #000A
		border-radius: 10px
		clear: both
		color: white
		cursor: zoom-out
		float: right
		margin-top: 5px
		padding: 3px 6px
	}
}
object

%visible

/phlo/resources/DOM/visible.phlo

onVisible and onVisibleIn helpers for DOM visibility

Reacts to an element entering or leaving the viewport at ten percent visible, which is what you want for lazy loading, counting a view or starting an animation at the right moment. Give only cbOut and it fires once and stops watching, so a one-off costs nothing afterwards. onVisibleIn watches inside a scrolling container rather than the window.

domvisibleintersectionobserverfrontend
view

script

line 11
Stelt een IntersectionObserver in om callback-functies uit te voeren wanneer opgegeven elementen zichtbaar of verborgen worden in het viewport.
phlo.observe = []
phlo.observing = new WeakMap

const onVisible = (els, cbIn, cbOut) => onVisibleIn(els, null, cbIn, cbOut)
const onVisibleIn = (els, root, cbIn, cbOut) => phlo.observe.push({els, root, cbIn, cbOut})

app.updates.push(() => {
	const observers = []
	phlo.observe.forEach(item => objects(item.els).forEach(el => phlo.observing.has(el) || observers.push({el, root: item.root, cbIn: item.cbIn, cbOut: item.cbOut})))
	observers.forEach(item => [phlo.observing.has(item.el) || phlo.observing.set(item.el, 'observe'), (observer = new IntersectionObserver(entries => entries.forEach(entry => entry.isIntersecting ? !item.cbIn && item.cbOut ? [observer.unobserve(entry.target), item.cbOut(entry.target)] : item.cbIn(entry.target) : item.cbIn && item.cbOut && item.cbOut(entry.target)), {root: obj(item.root), threshold: .1})).observe(item.el)])
})
object

%websocket

/phlo/resources/DOM/websocket.phlo

Client-side WebSocket handler

Opens the connection and keeps it open, reconnecting on its own, and treats what arrives as commands, so the server can update a page with the same instructions a route uses. It survives a page swap, which a hand-bound on() does not. A token belongs in a cookie rather than in the URL, since a URL ends up in logs.

websocketrealtimefrontenddom
view

script

line 11
app.websocket = {
	get open(){
		app.options.contains('wss') && delay('websocket', app.websocket.retry, () => {
			phlo.wss?.close()
			phlo.wss = new WebSocket(`wss://${location.host}/${app.websocket.path}`)
			phlo.wss.onmessage = e => [{trans, state, ...cmds} = JSON.parse(e.data), apply(cmds, trans, state)]
			phlo.wss.onopen = e => [app.websocket.emit('connect', e), phlo.log('🖧 Websocket connected', e), app.websocket.retry = 333]
			phlo.wss.onerror = e => [app.websocket.emit('error', e), phlo.log('🖧 Websocket error', e)]
			phlo.wss.onclose = e => [app.websocket.emit('close', e), phlo.log('🖧 Websocket close', e), app.websocket.retry && [app.websocket.open, app.websocket.retry *= 3]]
		})
	},
	path: 'websocket',
	get ready(){ return phlo.wss?.readyState === 1 },
	subs: {},
	on(event, cb, el = null){
		(app.websocket.subs[event] ??= []).push({cb, el})
		event === 'connect' && app.websocket.ready && cb()
		return () => app.websocket.off(event, cb)
	},
	off(event, cb){ app.websocket.subs[event] = (app.websocket.subs[event] || []).filter(sub => sub.cb !== cb) },
	emit(event, e){
		const single = app.websocket[event]
		single && single(e)
		app.websocket.subs[event] = (app.websocket.subs[event] || []).filter(sub => !sub.el || sub.el.isConnected)
		app.websocket.subs[event].forEach(sub => {
			try { sub.cb(e) }
			catch(err){ phlo.log('🖧 Websocket ' + event, err) }
		})
	},
	send: data => phlo.wss?.readyState === 1 ? [phlo.log('🖧 app.websocket.send', '\n', data), phlo.wss.send(JSON.stringify(data))] : phlo.error('🖧 Could not send websocket data over closed socket'),
	retry: 333,
}
app.websocket.open

Laatst bijgewerkt op 07-08-2026

We gebruiken essentiële cookies om deze site te laten werken. Met uw toestemming gebruiken we ook analytics om de site te verbeteren.