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

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

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

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

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

%cookiewall

/phlo/resources/DOM/cookiewall.phlo
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
Deze methode beheert de cookie wall-functionaliteit en regelt de gebruikersconsent voor cookies.
null
prop

%cookiewall -> choice

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

%cookiewall -> hasChosen

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

%cookiewall -> canTrack

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

%cookiewall -> canAnalytics

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

%cookiewall -> translate

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

%cookiewall -> labels

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

%cookiewall -> label ($key)

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

route async POST cookiewall accept all

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

route async POST cookiewall accept essential

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

%cookiewall -> banner

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

style

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

%CSS_fixes

/phlo/resources/DOM/CSS.fixes.phlo
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
Past globale CSS-stijlen toe om consistente box-sizing, touch-acties, focusomtrekken en uiterlijk voor verschillende HTML-elementen te waarborgen.
*, ::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
Definieert een proxy voor het openen en wijzigen van CSS-aangepaste eigenschappen (variabelen) op het root-element van het document, waardoor dynamische updates en het ophalen van hun waarden mogelijk zijn.
Object.defineProperty(app, 'var', {get(){return new Proxy({}, {get(_, key){return getComputedStyle(document.documentElement).getPropertyValue(`--${key}`).trim()}, set(_, key, value){ return document.documentElement.style.setProperty(`--${key}`, value)}})}, configurable: true})
app.mod.setvar = (key, value) => app.var[key] = value
object

%datatags

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

%dialog

/phlo/resources/DOM/dialog.phlo
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
Maakt en beheert dialoogvensters voor waarschuwingen, bevestigingen en prompts in een Phlo-applicatie, waardoor gebruikersinteractie via modale dialoogvensters mogelijk is.
window.alert = app.mod.alert = msg => phlo.dialog('alert', msg)
window.confirm = msg => phlo.dialog('confirm', msg)
window.prompt = (msg, defaultValue) => phlo.dialog('prompt', msg, defaultValue)

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

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

%exists

/phlo/resources/DOM/exists.phlo
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
Deze functie houdt elementen en hun bijbehorende callbacks bij en voert de callbacks uit voor elementen die bestaan maar nog niet eerder zijn geregistreerd.
phlo.exist = []
phlo.existing = new WeakMap

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

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

%ffmpeg

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

%image_resizer

/phlo/resources/DOM/image.resizer.phlo
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
Verkleint een afbeeldingsbestand tot opgegeven maximale afmetingen terwijl de beeldverhouding behouden blijft, en retourneert de verkleinde afbeelding als een data-URL via een callbackfunctie.
const imageResizer = (file, maxWidth, maxHeight, cb, quality = .8) => {
	const img = new Image
	img.onload = () => {
		let width = img.width, height = img.height
		const aspectRatio = width / height
		if (width > maxWidth || height > maxHeight){
			if (width > height){
				width = maxWidth
				height = Math.round(maxWidth / aspectRatio)
			}
			else {
				height = maxHeight
				width = Math.round(maxHeight * aspectRatio)
			}
		}
		const canvas = document.createElement('canvas')
		canvas.width = width
		canvas.height = height
		canvas.getContext('2d').drawImage(img, 0, 0, width, height)
		cb(canvas.toDataURL(file.type, quality))
	}
	img.src = URL.createObjectURL(file)
}
object
/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
Deze functie behandelt klikgebeurtenissen op anker-tags, voorkomt standaardgedrag onder bepaalde voorwaarden en beheert asynchrone navigatie of hash-wijzigingen in de 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
Parset Markdown-tekst naar HTML, met ondersteuning voor verschillende functies zoals GitHub Flavored Markdown, blokelementen, inline-elementen en referentielinks.
function parse_markdown(md, opts = {}){
  const o = {
    gfm: opts.gfm !== false,
    breaks: !!opts.breaks,
    headerIds: opts.headerIds !== false,
    headerPrefix: opts.headerPrefix || '',
    smartypants: !!opts.smartypants
  }
  const unnull = x => (x == null ? '' : String(x))
  let src = unnull(md).replace(/\r\n?/g, "\n")
  const escHtml = s => s.replace(/[&<>"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]))
  const trimEndNL = s => s.replace(/\s+$/,'')
  const isBlank = s => /^\s*$/.test(s)
  const slugmap = new Map()
  const slug = t => {
    let s = t.toLowerCase().replace(/<\/?[^>]+>/g, '').replace(/[^\p{L}\p{N}\- _]+/gu, '').trim().replace(/[\s_]+/g, '-')
    const base = o.headerPrefix + s
    let k = base, i = 1
    while (slugmap.has(k)) k = `${base}-${++i}`
    slugmap.set(k, true)
    return k
  }
  const smart = s => {
    if (!o.smartypants) return s
    return s.replace(/---/g, "-").replace(/--/g, "–").replace(/(^|[\s"(\[])(?=')/g, "$1‘").replace(/'/g, "’").replace(/(^|[\s(\[])(?=")/g, "$1“").replace(/"/g, "”").replace(/\.{3}/g, "…")
  }
  const refs = Object.create(null)
  src = src.replace(
    /^ {0,3}\[([^\]]+)\]:\s*<?([^\s>]+)>?(?:\s+(?:"([^"]*)"|'([^']*)'|\(([^)]+)\)))?\s*$/gm,
    (_, label, url, t1, t2, t3) => {
      const key = label.trim().replace(/\s+/g, ' ').toLowerCase()
      if (!refs[key]) refs[key] = { href: url, title: t1 || t2 || t3 || '' }
      return ''
    }
  )
  const tokens = []
  const lines = src.split("\n")
  function takeWhile(start, pred){
    let end = start
    while (end < lines.length && pred(lines[end], end)) end++
    return { start, end }
  }
  function pushParagraph(buf){
    const text = buf.join("\n").trimEnd()
    if (text) tokens.push({ type: "paragraph", text })
    buf.length = 0
  }
  function parseBlock(start = 0, end = lines.length){
    const para = []
    let l = start
    while (l < end){
      const line = lines[l]
      if (isBlank(line)){
        pushParagraph(para)
        l++
        continue
      }
      let m = line.match(/^ {0,3}(`{3,}|~{3,})([^\n]*)$/)
      if (m){
        pushParagraph(para)
        const fenceLen = m[1].length
        const info = (m[2] || '').trim()
        let body = []
        l++
        while (l < end){
          const s = lines[l]
          const close = s.match(new RegExp(`^ {0,3}${m[1][0]}{${fenceLen},}\\s*$`))
          if (close){
            l++
            break
          }
          body.push(s)
          l++
        }
        tokens.push({ type: "code", lang: info.split(/\s+/)[0] || '', text: trimEndNL(body.join("\n")) })
        continue
      }
      if (/^(?: {4}|\t)/.test(line)){
        pushParagraph(para)
        const { end: j } = takeWhile(l, s => /^(?: {4}|\t)/.test(s) || isBlank(s))
        const block = lines.slice(l, j).map(s => s.replace(/^(?: {4}|\t)/, '')).join("\n")
        tokens.push({ type: "code", lang: '', text: trimEndNL(block) })
        l = j
        continue
      }
      if (/^ {0,3}<(?:!--|\/?(?:html|head|body|pre|script|style|table|thead|tbody|tfoot|tr|td|th|div|p|h[1-6]|blockquote|ul|ol|li|section|article|aside|details|summary|figure|figcaption)\b)/i.test(line)){
        pushParagraph(para)
        const { end: j } = takeWhile(l, (s, idx) => !(idx > l && isBlank(lines[idx-1]) && isBlank(s)))
        const html = lines.slice(l, j).join("\n")
        tokens.push({ type: "html", text: html })
        l = j
        continue
      }
      if (/^ {0,3}(?:-+\s*|-{3,}|_{3,}|\*{3,})\s*$/.test(line)){
        pushParagraph(para)
        tokens.push({ type: "hr" })
        l++
        continue
      }
      m = line.match(/^ {0,3}(#{1,6})[ \t]*([^#\n]*?)[ \t#]*$/)
      if (m){
        pushParagraph(para)
        tokens.push({ type: "heading", depth: m[1].length, text: m[2].trim() })
        l++
        continue
      }
      if (l + 1 < end && /^[^\s].*$/.test(line) && /^ {0,3}(=+|-+)\s*$/.test(lines[l + 1])){
        pushParagraph(para)
        const depth = lines[l + 1].trim().startsWith("=") ? 1 : 2
        tokens.push({ type: "heading", depth, text: line.trim() })
        l += 2
        continue
      }
      if (/^ {0,3}>\s?/.test(line)){
        pushParagraph(para)
        const { end: j } = takeWhile(l, s => /^ {0,3}>\s?/.test(s) || isBlank(s))
        const inner = lines.slice(l, j).map(s => s.replace(/^ {0,3}>\s?/, '')).join("\n")
        const sub = parse_markdown(inner, { ...o })
        tokens.push({ type: "blockquote", html: sub })
        l = j
        continue
      }
      m = line.match(/^ {0,3}((?:[*+-])|\d{1,9}[.)])\s+/)
      if (m){
        pushParagraph(para)
        const bulletRe = /^ {0,3}((?:[*+-])|\d{1,9}[.)])\s+/
        const { end: j } = takeWhile(l, (s, idx) =>
          bulletRe.test(s) ||
          (/^(?: {4}|\t)/.test(s)) ||
          (!isBlank(s) && idx > l && !/^(?: {0,3}(?:[*+-]|\d{1,9}[.)])\s+)/.test(s))
        )
        const block = lines.slice(l, j)
        const ordered = /^\d/.test(m[1])
        const items = []
        let cur = []
        for (let k = 0; k < block.length; k++){
          const ln = block[k]
          const head = ln.match(bulletRe)
          if (head){
            if (cur.length) items.push(cur), cur = []
            cur.push(ln.replace(bulletRe, ''))
          } else {
            cur.push(ln.replace(/^(?: {4}|\t)/, ''))
          }
        }
        if (cur.length) items.push(cur)
        const parsedItems = items.map(linesArr => {
          let raw = linesArr.join("\n").replace(/\n\s+$/,'')
          let checked = null
          if (o.gfm){
            const t = raw.match(/^\[([ xX])\][ \t]+/)
            if (t){
              checked = t[1].toLowerCase() === 'x'
              raw = raw.replace(/^\[[ xX]\][ \t]+/, '')
            }
          }
          const html = parse_markdown(raw, o)
          return { html, checked }
        })
        tokens.push({ type: "list", ordered, items: parsedItems })
        l = j
        continue
      }
      if (o.gfm){
        const hdr = line
        const alignLn = lines[l + 1] || ''
        if (/\|/.test(hdr) && /^ {0,3}\|? *:?-+:? *(?:\| *:?-+:? *)*\|? *$/.test(alignLn)){
          pushParagraph(para)
          const aligns = alignLn
            .trim().replace(/^(\|)|(\|)$/g,'')
            .split("|").map(s => s.trim()).map(s => s.startsWith(":-") && s.endsWith("-:") ? "center" : s.endsWith("-:") ? "right" : s.startsWith(":-") ? "left" : null)
          const headerCells = hdr.trim().replace(/^(\|)|(\|)$/g,'').split("|").map(s => s.trim())
          l += 2
          const rows = []
          while (l < end && /\|/.test(lines[l]) && !isBlank(lines[l])){
            rows.push(lines[l].trim().replace(/^(\|)|(\|)$/g,'').split("|").map(s => s.trim()))
            l++
          }
          tokens.push({ type: "table", header: headerCells, aligns, rows })
          continue
        }
      }
      para.push(line)
      const next = lines[l + 1] || ''
      const endPara =
        isBlank(next) ||
        /^ {0,3}(?:`{3,}|~{3,})/.test(next) ||
        /^(?: {4}|\t)/.test(next) ||
        /^ {0,3}((?:[*+-])|\d{1,9}[.)])\s+/.test(next) ||
        /^ {0,3}(#{1,6})/.test(next) ||
        /^ {0,3}>\s?/.test(next) ||
        /^ {0,3}(?:-+\s*|-{3,}|_{3,}|\*{3,})\s*$/.test(next) ||
        (o.gfm && /\|/.test(next) && /^ {0,3}\|? *:?-+:? *(?:\| *:?-+:? *)*\|? *$/.test(lines[l + 2] || ''))
      if (endPara) pushParagraph(para)
      l++
    }
    pushParagraph(para)
  }
  parseBlock(0, lines.length)
  function renderInline(s){
    if (!s) return ''
    s = s.replace(/(`+)([^`]|[^`][\s\S]*?[^`])\1/g, (_, ticks, code) => `<code>${escHtml(code)}</code>`)
    s = s.replace(/!\[([^\]]*)\]\(\s*<?([^\s)<>]+)>?\s*(?:(?:"([^"]*)"|'([^']*)'|\(([^)]+)\)))?\s*\)/g,
      (_, alt, url, t1, t2, t3) => `<img src="${escHtml(url)}" alt="${escHtml(alt)}"${t1||t2||t3?` title="${escHtml(t1||t2||t3)}"`:''}>`)
    s = s.replace(/!\[([^\]]*)\]\[([^\]]*)\]/g, (_, alt, id) => {
      const ref = refs[(id || alt).trim().replace(/\s+/g,' ').toLowerCase()]
      return ref ? `<img src="${escHtml(ref.href)}" alt="${escHtml(alt)}"${ref.title?` title="${escHtml(ref.title)}"`:''}>` : _
    })
    s = s.replace(/\[([^\]]+)\]\(\s*<?([^\s)<>]+)>?\s*(?:(?:"([^"]*)"|'([^']*)'|\(([^)]+)\)))?\s*\)/g,
      (_, text, url, t1, t2, t3) => `<a href="${escHtml(url)}"${t1||t2||t3?` title="${escHtml(t1||t2||t3)}"`:''}>${text}</a>`)
    s = s.replace(/\[([^\]]+)\]\s*\[([^\]]*)\]/g, (_, text, id) => {
      const key = (id || text).trim().replace(/\s+/g,' ').toLowerCase()
      const ref = refs[key]
      return ref ? `<a href="${escHtml(ref.href)}"${ref.title?` title="${escHtml(ref.title)}"`:''}>${text}</a>` : _
    })
    s = s.replace(/<([a-zA-Z][a-zA-Z0-9+.-]{1,31}:[^ <>"']+)>/g, (_, url) => `<a href="${escHtml(url)}">${escHtml(url)}</a>`)
    s = s.replace(/<([A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,})>/g, (_, mail) => `<a href="mailto:${escHtml(mail)}">${escHtml(mail)}</a>`)
    if (o.gfm){
      s = s.replace(/(?:(?<=\s)|^)(https?:\/\/[^\s<]+)(?=\s|$)/g, '<a href="$1">$1</a>')
      s = s.replace(/(?:(?<=\s)|^)(www\.[^\s<]+)(?=\s|$)/g, '<a href="http://$1">$1</a>')
    }
    s = s.replace(/\*\*([\s\S]+?)\*\*/g, '<strong>$1</strong>').replace(/__([\s\S]+?)__/g, '<strong>$1</strong>')
    s = s.replace(/\*([^*\n]+?)\*/g, '<em>$1</em>').replace(/_([^_\n]+?)_/g, '<em>$1</em>')
    if (o.gfm) s = s.replace(/~~([\s\S]+?)~~/g, '<del>$1</del>')
    s = s.replace(/ {2,}\n/g, "<br>\n")
    if (o.breaks) s = s.replace(/\n/g, "<br>\n")
    s = s.replace(/&(?!#?\w+;)/g, "&amp;").replace(/<(?!\/?[A-Za-z][^>]*>)/g, "&lt;")
    return smart(s)
  }
  let out = ''
  for (const t of tokens){
    switch (t.type){
      case "paragraph":
        out += `<p>${renderInline(t.text)}</p>\n`
        break
      case "heading": {
        const text = renderInline(t.text)
        const id = o.headerIds ? slug(text.replace(/<[^>]+>/g, '')) : null
        out += id ? `<h${t.depth} id="${id}">${text}</h${t.depth}>\n` : `<h${t.depth}>${text}</h${t.depth}>\n`
        break
      }
      case "code": {
        const cls = t.lang ? ` class="language-${escHtml(t.lang)}"` : ''
        out += `<pre><code${cls}>${escHtml(t.text)}</code></pre>\n`
        break
      }
      case "blockquote":
        out += `<blockquote>\n${t.html.trim()}\n</blockquote>\n`
        break
      case "list": {
        const tag = t.ordered ? "ol" : "ul"
        out += `<${tag}>\n`
        for (const it of t.items){
          const task = it.checked === null ? '' : `<input ${it.checked ? 'checked="" ' : ''}disabled="" type="checkbox"> `
          const body = it.html.trim().replace(/^<p>/, task + "<p>")
          out += `<li>${body}</li>\n`
        }
        out += `</${tag}>\n`
        break
      }
      case "table": {
        const ths = t.header.map((h, i) => {
          const a = t.aligns[i]
          return a ? `<th align="${a}">${renderInline(h)}</th>` : `<th>${renderInline(h)}</th>`
        }).join("\n")
        let body = ''
        for (const row of t.rows){
          const tds = row.map((cell, i) => {
            const a = t.aligns[i]
            return a ? `<td align="${a}">${renderInline(cell)}</td>` : `<td>${renderInline(cell)}</td>`
          }).join("\n")
          body += `<tr>\n${tds}\n</tr>\n`
        }
        out += `<table>\n<thead>\n<tr>\n${ths}\n</tr>\n</thead>\n` + (body ? `<tbody>\n${body}</tbody>\n` : '') + `</table>\n`
        break
      }
      case "hr":
        out += "<hr>\n"
        break
      case "html":
        out += t.text + "\n"
        break
    }
  }
  return out.trim()
}
object

%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
Definieert afkortingsfuncties voor veelvoorkomende gebeurtenisluisteraars: onChange, onClick en onInput, die het proces van het koppelen van gebeurtenisbehandelaars aan elementen vereenvoudigen.
function onChange(els, cb){ on('change', els, cb) }
function onClick(els, cb){ on('click', els, cb) }
function onInput(els, cb){ on('input', els, cb) }
object

%store

/phlo/resources/DOM/store.phlo
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
Beheert een reactieve statusopslag in Phlo, waarmee de opslag, het ophalen en de notificatie van wijzigingen in statuswaarden mogelijk is. Het ondersteunt berekende waarden, afhankelijkheidstracering en DOM-binding voor dynamische 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
Definieert een functie die een opgegeven sjabloon toepast op elke rij gegevens, waarbij het sjabloon wordt aangeroepen met de waarden uit de rij.
app.mod.template = (template, rows) => rows.forEach(row => templates[template](...Object.values(row)))
const templates = {}
object

%timestamps

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

%toasts

/phlo/resources/DOM/toasts.phlo
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
Maakt een toastmelding die een bericht gedurende korte tijd weergeeft en kan worden gesloten door erop te klikken.
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
Definieert de CSS-stijlen voor toastmeldingen, die vast in de rechterbovenhoek worden gepositioneerd met specifieke achtergrondkleur, randradius en tekststijl.
#toasts {
	position: fixed
	right: 10px
	top: 5px
	z-index: 1001
	> * {
		background-color: #000A
		border-radius: 10px
		clear: both
		color: white
		cursor: zoom-out
		float: right
		margin-top: 5px
		padding: 3px 6px
	}
}
object

%visible

/phlo/resources/DOM/visible.phlo
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
Stelt een IntersectionObserver in om callback-functies uit te voeren wanneer opgegeven elementen zichtbaar of verborgen worden in het viewport.
phlo.observe = []
phlo.observing = new WeakMap

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

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

%websocket

/phlo/resources/DOM/websocket.phlo
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
Stelt een WebSocket-verbinding in met het opgegeven pad en beheert de levenscyclus, inclusief het afhandelen van berichten, fouten en herverbindingen.
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 gebruiken essentiële cookies om deze site te laten werken. Met uw toestemming gebruiken we ook analytics om de site te verbeteren.