DOM

object

%charts

/phlo/resources/DOM/charts.phlo
version 1.0
creator q-ai.nl
summary Lightweight dependency-free SVG charts: sparkline, bars and donut. Use charts::spark/bars/donut.
package DOM
frontend true
backend true
tags chart svg sparkline bars donut visualization
static

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

line 9
Generates an SVG sparkline chart based on the provided values, calculating the necessary points and area for rendering.
$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)

line 29
Generates an SVG bar chart based on the provided values, calculating the height and position of each bar relative to the maximum value.
$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)

line 47
Generates an SVG donut chart based on the provided parts, calculating the proportions and colors for each segment.
$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
version 1.0
creator q-ai.nl
summary 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.
package privacy
frontend true
backend true
requires @cookies
tags gdpr consent cookies privacy
static

cookiewall :: __handle

line 10
This method handles the cookie wall functionality, managing user consent for cookies.
null
prop

%cookiewall -> choice

line 12
Retrieves the value of 'cookieChoice' from the %cookies object, returning null if it is not set.
%cookies->cookieChoice ?? null
method

%cookiewall -> hasChosen

line 13
Checks if a choice has been made by verifying that the choice is not null.
$this->choice !== null
method

%cookiewall -> canTrack

line 14
Determines if tracking is allowed based on the user's choice, specifically if it is set to 'all'.
$this->choice === 'all'
method

%cookiewall -> canAnalytics

line 15
Checks if the current choice is set to 'all' to determine if analytics can be enabled.
$this->choice === 'all'
prop

%cookiewall -> translate

line 17
Checks if the function 'en' exists in the current scope.
function_exists('en')
prop

%cookiewall -> labels

line 18
Defines a set of labels for a cookie consent wall, including the region title, body text, and button labels.
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)

line 24
Retrieves the label associated with the specified key, translating it if translation is enabled.
$this->translate ? en($this->labels[$key]) : $this->labels[$key]
route

route async POST cookiewall accept all

line 26
Defines a route that sets a cookie indicating the user has accepted all cookies and removes the cookiewall view asynchronously.
%cookies->objSet('cookieChoice', 'all', ['expires' => time() + 60 * 60 * 24 * 365, 'httponly' => false])
apply(remove: '#cookiewall')
route

route async POST cookiewall accept essential

line 31
This route sets a cookie named 'cookieChoice' with the value 'essential' that expires in one year and then applies a removal of the element with the ID 'cookiewall'.
%cookies->objSet('cookieChoice', 'essential', ['expires' => time() + 60 * 60 * 24 * 365, 'httponly' => false])
apply(remove: '#cookiewall')
view

%cookiewall -> banner

line 36
Displays a cookie consent wall prompting users to accept essential or all cookies if they have not already made a choice.
<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 51
Defines the CSS styles for the cookiewall component, including layout, colors, and responsive design.
#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
version 1.0
creator q-ai.nl
summary Single Page App basic CSS boilerplate fixes
package css
frontend true
backend false
tags css fixes boilerplate reset
view

style

line 9
Applies global CSS styles to ensure consistent box-sizing, touch actions, focus outlines, and appearance for various HTML elements.
*, ::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
::-ms-expand: display: none
object

%CSS_var

/phlo/resources/DOM/CSS.var.phlo
version 1.0
creator q-ai.nl
summary CSS variable proxy via app.var
package css
frontend true
backend false
tags css variables app.var frontend
view

script

line 9
Defines a proxy for accessing and modifying CSS custom properties (variables) on the document's root element, allowing for dynamic updates and retrieval of their values.
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
version 1.0
creator q-ai.nl
summary Single Page App datatag plugin
package dom
frontend true
backend false
requires @DOM
tags dom datatag dataset spa events
view

script

line 10
Handles click events on elements with data attributes for HTTP methods, preventing default actions and executing the corresponding method with the specified path and data.
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
version 1.0
creator q-ai.nl
summary Single Page App dialog resource
package dom
frontend true
backend false
requires @DOM
tags dom dialog modal confirm prompt alert
view

script

line 10
Creates and manages dialog boxes for alerts, confirmations, and prompts in a Phlo application, allowing user interaction through modal dialogs.
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
version 1.0
creator q-ai.nl
summary onExist helper for dynamic SPA elements
package dom
frontend true
backend false
requires @DOM
tags dom onexist spa lifecycle
view

script

line 10
This function tracks elements and their associated callbacks, executing the callbacks for elements that exist but have not been previously registered.
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
version 1.0
creator q-ai.nl
summary 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`).
package DOM
frontend true
backend false
tags video ffmpeg wasm canvas encode transcode webcodecs mp4 render
view

script

line 9
// ffmpeg-wasm brought to the browser DOM. Use the ready singleton `ffmpeg`
// (or `new Ffmpeg({corePath})`). Only Ffmpeg + ffmpeg enter the bundle scope.
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
	}

	// self-contained script loader (works in runtime-less ns bundles too)
	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]
	}

	// -----------------------------------------------------------------------
	// Frame source: WebCodecs (MP4Box demux + VideoDecoder) with a seek fallback.
	// src is a File or a URL string; getFrame(t) returns a drawable (VideoFrame or
	// <video>). Falls back to seek for non-MP4 containers or unsupported codecs.
	// The demux is cached in one slot so a multithreaded->single-threaded retry
	// does not re-read or re-demux the same source.
	// -----------------------------------------------------------------------
	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
		// only materialise an object URL when the seek fallback actually needs one
		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')
			// probe one frame so a decoder that configures but never decodes falls back to seek
			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)
				// a stuck decoder must not hang the render forever; fail cleanly instead
				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)
			},
		}
	}

	// -----------------------------------------------------------------------
	// Core: ffmpeg-wasm load (multithreaded core with single-threaded fallback),
	// watched exec and a fallback wrapper. `log` is passed per call (this.log is
	// only the default) so concurrent operations do not fight over one logger.
	// -----------------------------------------------------------------------
	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)
		}
	}

	// run fn; if the multithreaded core stalls, retry once single-threaded
	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()
		}
	}

	// -----------------------------------------------------------------------
	// High level.
	// -----------------------------------------------------------------------
	// generic ffmpeg run: writes `input` (Blob | Uint8Array | url) as inputName,
	// runs args, reads `output` -> Blob. `args` should reference inputName/output.
	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)
	}

	// transcode a Blob/Uint8Array/url (e.g. a MediaRecorder webm) to mp4 (default)
	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})
	}

	// encode a drawn canvas timeline to an MP4 Blob (WebCodecs encoder, ffmpeg fallback)
	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) args.push('-map', '0:v', '-map', '1:a?', '-c', 'copy', '-shortest', 'out.mp4')
		else if (ins.length === 1) 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]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})
		}
		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){
			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]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
version 1.0
creator q-ai.nl
summary Single Page App form handler and input state saver
package dom
frontend true
backend false
requires @DOM
tags dom form input state spa
view

script

line 10
Handles input events for form elements, updating their attributes based on user interaction, and submits the form asynchronously using the specified method.
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
version 1.0
creator q-ai.nl
summary Client-side file upload image resizer
package dom
frontend true
backend false
tags dom image resize upload canvas
view

script

line 9
Resizes an image file to specified maximum dimensions while maintaining the aspect ratio, and returns the resized image as a data URL through a callback function.
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
/phlo/resources/DOM/link.phlo
version 1.0
creator q-ai.nl
summary Single Page App async link handler
package dom
frontend true
backend false
requires @DOM
tags dom link async navigation spa
view

script

line 10
This function handles click events on anchor tags, preventing default behavior under certain conditions and managing asynchronous navigation or hash changes in the URL.
on('click', 'a', (a, e) => {
	if (e.ctrlKey || e.shiftKey || e.metaKey || 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
version 1.0
creator q-ai.nl
summary Client-side markdown parser
package dom
frontend true
backend false
tags dom markdown parser frontend
view

script

line 9
Parses Markdown text into HTML, supporting various features like GitHub Flavored Markdown, block elements, inline elements, and reference links.
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

%presentation

/phlo/resources/DOM/presentation.phlo
version 1.0
creator q-ai.nl
summary Audio-clock 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 #pp-data + #pp-root when present.
package DOM
frontend true
backend false
tags presentation player timeline audio video subtitles transitions canvas render
view

script

line 9
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 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.t = 0
		this.clockStart = 0
		this.items = []
		this.videos = []
		this.audio = null
		this.durOverride = null
		this.subIndex = -1
		this.raf = null
		this.blobCache = {}
		this.updateGen = 0
		this.loaded = false
		this.buildShell()
		this.update(data.presentation)
	}

	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 : ''
	}

	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)
		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
		if (Number.isFinite(this.durOverride) && this.durOverride > 0) return this.durOverride
		if (p.audio && Number.isFinite(p.audio.duration) && p.audio.duration > 0) return p.audio.duration
		let end = 0
		for (const img of p.images || []) end = Math.max(end, (img.start || 0) + (img.duration || 0))
		for (const v of this.videos) if (v.duration) end = Math.max(end, v.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 = () => {
			const w = this.root.clientWidth
			this.root.style.fontSize = Math.max(9, w / 48) + 'px'
		}
		new ResizeObserver(fit).observe(this.root)
		fit()
	}

	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',
			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.bigPlay = document.createElement('button')
		this.bigPlay.className = 'pp-bigplay'
		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.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.min = 0
		this.seekEl.max = 1000
		this.seekEl.value = 0
		this.seekEl.addEventListener('input', () => this.seek(this.seekEl.value / 1000 * this.duration))
		this.ccBtn = document.createElement('button')
		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.style.display = 'none'
		this.langSel.addEventListener('change', () => this.setLang(this.langSel.value === 'original' ? null : this.langSel.value))
		this.fsBtn = document.createElement('button')
		this.fsBtn.innerHTML = this.icon('full')
		this.fsBtn.addEventListener('click', () => {
			if (document.fullscreenElement) document.exitFullscreen()
			else (this.root.closest('.pp-standalone') || this.root).requestFullscreen()
		})
		bar.append(this.playBtn, this.timeEl, this.seekEl, this.ccBtn, this.langSel, this.fsBtn)
		this.root.appendChild(bar)
		this.stage.addEventListener('click', e => {
			if (!e.target.closest('a')) this.toggle()
		})
	}

	update(pres){
		this.pres = pres
		const gen = ++this.updateGen
		const jobs = []
		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)) : ''
		if (this.audio && this.audioSrc !== wantAudio){
			this.audio.pause()
			this.audio = null
			this.durOverride = null
		}
		if (wantAudio && !this.audio){
			this.audioSrc = wantAudio
			this.audio = new Audio()
			this.audio.preload = 'auto'
			jobs.push(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.durOverride = d
						if (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.volume = Math.min(1, Math.max(0, (cfg.volume ?? 100) / 100))
			v.muted = !cfg.volume
			v.style.objectFit = cfg.fit || 'cover'
			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)
			}
			jobs.push(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.draggable = false
			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})
		}
		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.opts.onLoaded) this.opts.onLoaded()
		})
		this.applyTime(this.t, true)
		this.updateTime()
		this.refreshLangSel()
	}

	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.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', 'original' + (this.pres.lang ? ' (' + this.pres.lang + ')' : ''))
		for (const l of langs) opt(l, l)
		this.langSel.value = this.lang || 'original'
		this.langSel.style.display = ''
	}

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

	play(){
		if (!this.loaded) return
		if (this.t >= this.duration - 0.05) this.seek(0)
		this.playing = true
		this.root.classList.remove('pp-paused')
		if (this.bigPlay) this.bigPlay.style.display = 'none'
		if (this.playBtn) this.playBtn.innerHTML = this.icon('pause')
		if (this.audio){
			this.audio.currentTime = this.t
			this.audio.play()
		}
		else this.clockStart = performance.now() - this.t * 1000
		this.syncVideos(true)
		for (const item of this.items) item.el.style.animationPlayState = 'running'
		const step = () => {
			if (!this.playing) return
			this.t = this.audio ? this.audio.currentTime : (performance.now() - this.clockStart) / 1000
			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
		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 (ended && this.opts.onEnded) this.opts.onEnded()
	}

	seek(t){
		this.t = Math.min(Math.max(0, t), this.duration)
		if (this.audio) this.audio.currentTime = this.t
		if (!this.audio) 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)
		}
		this.syncVideos(force)
		this.applySubs(t)
	}

	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){
			if (v.readyState < 1) continue
			const drift = Math.abs(v.currentTime - this.t)
			if (force || drift > 0.35) v.currentTime = Math.min(this.t, v.duration || this.t)
			if (this.playing && v.paused && this.t < (v.duration || Infinity)) v.play().catch(() => {})
			if (!this.playing && !v.paused) v.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(){
		if (this.timeEl) this.timeEl.textContent = this.fmt(this.t) + ' / ' + this.fmt(this.duration)
		if (this.seekEl && !this.seeking) this.seekEl.value = this.duration ? Math.round(this.t / this.duration * 1000) : 0
	}

	static boot(){
		const data = document.getElementById('pp-data')
		const root = document.getElementById('pp-root')
		if (data && root) window.pp = new PresentationPlayer(root, JSON.parse(data.textContent), {controls: true})
	}
}

if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => PresentationPlayer.boot())
else PresentationPlayer.boot()
view

style

line 701
.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
}
.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-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: 900
}
.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: 950
	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: 940
	opacity: 0
	transition: opacity .25s ease
	font-size: .9em
}
.pp-root:hover .pp-controls, .pp-root.pp-paused .pp-controls: opacity: 1
.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 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-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(100vh * var(--pp-ar, 1.7778)))
			height: min(100vh, 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
version 1.0
creator q-ai.nl
summary 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`).
package DOM
frontend true
backend false
requires DOM/ffmpeg?
tags video recorder mediarecorder screen capture getdisplaymedia webcam canvas
view

script

line 10
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
version 1.0
creator q-ai.nl
summary onChange, onClick and onInput event shorthands
package dom
frontend true
backend false
requires @DOM
tags dom events shorthand frontend
view

script

line 10
Defines shorthand functions for common event listeners: onChange, onClick, and onInput, which simplify the process of attaching event handlers to elements.
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
version 1.0
creator q-ai.nl
summary Stateful binding engine
package dom
frontend true
backend false
requires @DOM
tags dom store binding state signals calc reactive
view

script

line 10
Manages a reactive state store in Phlo, allowing for the storage, retrieval, and notification of changes to state values. It supports computed values, dependency tracking, and DOM binding for dynamic updates.
phlo.store = {
	signals: {},
	listeners: {},
	calcs: {},
	calcDeps: {},
	calcVals: {},
	calcTick: false,
	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()
	},
	on(path, cb){ (phlo.store.listeners[path] ??= new Set).add(cb) },
	off(path, cb){ phlo.store.listeners[path] && phlo.store.listeners[path].delete(cb) },
	reset(){
		phlo.store.signals = {}
		phlo.store.listeners = {}
		phlo.store.calcs = {}
		phlo.store.calcDeps = {}
		phlo.store.calcVals = {}
		phlo.store.calcTick = false
	},
	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){
		const set = phlo.store.listeners[path]
		if (set) set.forEach(cb => cb(val))
	},
	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()
		})
	},
	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) => {
	const cur = phlo.store.get(key)
	if (JSON.stringify(cur) === JSON.stringify(value)) return
	const walk = (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)
}

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 } }
})

onExist('[data-bind]', el => {
	const key = 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 S = v => v == null ? '' : (typeof v === 'object' ? '' : String(v))
	const apply = v => {
		const s = S(v)
		if (isInput) el.value = s
		else el.textContent = s
	}
	phlo.store.on(key, apply)
	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 = [])
	spec.split(/\s*,\s*/).filter(Boolean).forEach(pair => {
		const m = pair.match(/^\s*([^:]+)\s*:\s*(.+)\s*$/)
		if (!m) return
		const name = m[1]
		const path = 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 = v => {
			if (name === 'text') el.textContent = S(v)
			else if (name === 'html') app.mod.inner(el, S(v))
			else if (name === 'value') app.mod.value(el, S(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 = meta.attr.cls
				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)
		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)
	})
})
object

%template

/phlo/resources/DOM/template.phlo
version 1.0
creator q-ai.nl
summary Single Page App client-side templating
advice Add cb's to the templates object and output via apply(template: [$name => $rows, $name2 => $rows2, etc])
package dom
frontend true
backend false
tags dom template spa frontend render
view

script

line 10
Defines a function that applies a specified template to each row of data, invoking the template with the values from the row.
app.mod.template = (template, rows) => rows.forEach(row => templates[template](...Object.values(row)))
const templates = {}
object

%timestamps

/phlo/resources/DOM/timestamps.phlo
version 1.0
creator q-ai.nl
summary DOM live timestamps
advice Create an app.tsLabels array to overwrite the tsBase labels in any language
package dom
frontend true
backend false
tags dom timestamps time live frontend
view

script

line 10
Updates the text content of elements with a 'data-ts' attribute to display the time elapsed since a timestamp in a human-readable format, refreshing every second.
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
version 1.0
creator q-ai.nl
summary Simple toast resource
package dom
frontend true
backend false
requires @DOM
tags dom toast notification frontend
view

script

line 10
Creates a toast notification that displays a message for a short duration and can be dismissed by clicking on it.
app.mod.toast = msg => {
	obj('#toasts') || app.mod.append('body', '<div id="toasts"></div>')
	const toast = document.createElement('div')
	toast.innerHTML = msg
	toast.onclick = () => toast.remove()
	obj('#toasts').insertAdjacentElement('beforeend', toast)
	setTimeout(() => toast.remove(), 4000)
}
view

style

line 21
Defines the CSS styles for toast notifications, positioning them fixed in the top right corner with specific background color, border radius, and text styling.
#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
version 1.0
creator q-ai.nl
summary onVisible and onVisibleIn helpers for DOM visibility
package dom
frontend true
backend false
requires @DOM
tags dom visible intersection observer frontend
view

script

line 10
Sets up an IntersectionObserver to execute callback functions when specified elements become visible or hidden in the 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
version 1.0
creator q-ai.nl
summary Client-side WebSocket handler
package realtime
frontend true
backend false
requires @DOM
tags websocket realtime frontend dom
view

script

line 10
Establishes a WebSocket connection to the specified path and manages its lifecycle, including handling messages, errors, and reconnections.
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 => [(cb = app.websocket.connect) && cb(e), phlo.log('πŸ–§ Websocket connected', e), app.websocket.retry = 333]
			phlo.wss.onerror = e => [(cb = app.websocket.error) && cb(e), phlo.log('πŸ–§ Websocket error', e)]
			phlo.wss.onclose = e => [(cb = app.websocket.close) && cb(e), phlo.log('πŸ–§ Websocket close', e), app.websocket.retry && [app.websocket.open, app.websocket.retry *= 3]]
		})
	},
	path: 'websocket',
	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

We use essential cookies to make this site work. With your permission we also use analytics to improve the site.