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
根据提供的值生成SVG火花线图,计算渲染所需的点和区域。
$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
根据提供的值生成SVG条形图,计算每个条形相对于最大值的高度和位置。
$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
根据提供的部分生成SVG甜甜圈图,计算每个部分的比例和颜色。
$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
此方法处理 cookie wall 功能,管理用户对 cookies 的同意。
null
prop

%cookiewall -> choice

line 12
从 %cookies 对象中获取 'cookieChoice' 的值,如果未设置则返回 null。
%cookies->cookieChoice ?? null
method

%cookiewall -> hasChosen

line 13
通过验证选择不为 null 来检查是否已做出选择。
$this->choice !== null
method

%cookiewall -> canTrack

line 14
根据用户的选择确定是否允许跟踪,特别是如果设置为'all'。
$this->choice === 'all'
method

%cookiewall -> canAnalytics

line 15
检查当前选择是否设置为'all'以确定是否可以启用分析。
$this->choice === 'all'
prop

%cookiewall -> translate

line 17
检查当前作用域中是否存在函数'en'。
function_exists('en')
prop

%cookiewall -> labels

line 18
定义了一组用于cookie同意墙的标签,包括区域标题、主体文本和按钮标签。
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
检索与指定键关联的标签,如果启用了翻译,则进行翻译。
$this->translate ? en($this->labels[$key]) : $this->labels[$key]
route

route async POST cookiewall accept all

line 26
定义一个路由,设置一个cookie,表示用户已接受所有cookie,并异步移除cookiewall视图。
%cookies->objSet('cookieChoice', 'all', ['expires' => time() + 60 * 60 * 24 * 365, 'httponly' => false])
apply(remove: '#cookiewall')
route

route async POST cookiewall accept essential

line 31
该路由设置一个名为'cookieChoice'的cookie,其值为'essential',有效期为一年,然后应用移除ID为'cookiewall'的元素。
%cookies->objSet('cookieChoice', 'essential', ['expires' => time() + 60 * 60 * 24 * 365, 'httponly' => false])
apply(remove: '#cookiewall')
view

%cookiewall -> banner

line 36
如果用户尚未做出选择,则显示一个提示用户接受基本或所有cookie的cookie同意墙。
<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
定义cookiewall组件的CSS样式,包括布局、颜色和响应式设计。
#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
应用全局CSS样式,以确保各种HTML元素的一致盒模型、触摸操作、焦点轮廓和外观。
*, ::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
定义一个代理,用于访问和修改文档根元素上的 CSS 自定义属性(变量),允许动态更新和检索它们的值。
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
处理带有数据属性的元素的点击事件,防止默认操作,并使用指定的路径和数据执行相应的方法。
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
在Phlo应用程序中创建和管理警报、确认和提示对话框,通过模态对话框允许用户交互。
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
此函数跟踪元素及其关联的回调,对于存在但尚未注册的元素执行回调。
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
处理表单元素的输入事件,根据用户交互更新其属性,并使用指定的方法异步提交表单。
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
将图像文件调整为指定的最大尺寸,同时保持纵横比,并通过回调函数返回调整后的图像作为数据URL。
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
此函数处理锚标签的点击事件,在某些条件下防止默认行为,并管理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
将Markdown文本解析为HTML,支持多种功能,如GitHub风格的Markdown、块元素、内联元素和引用链接。
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
定义了常见事件监听器的简写函数:onChange、onClick 和 onInput,简化了将事件处理程序附加到元素的过程。
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
在Phlo中管理反应式状态存储,允许存储、检索和通知状态值的变化。它支持计算值、依赖跟踪和DOM绑定以实现动态更新。
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
定义一个函数,将指定的模板应用于每一行数据,使用行中的值调用模板。
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
更新具有 'data-ts' 属性的元素的文本内容,以人类可读的格式显示自时间戳以来经过的时间,每秒刷新一次。
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
创建一个吐司通知,显示一条消息,持续短时间,并可以通过点击来关闭。
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
定义了吐司通知的CSS样式,将其固定在右上角,并具有特定的背景颜色、边框半径和文本样式。
#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
设置一个IntersectionObserver,当指定元素在视口中变得可见或隐藏时执行回调函数。
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
建立到指定路径的WebSocket连接,并管理其生命周期,包括处理消息、错误和重新连接。
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

我们使用必要的cookie来使该网站正常工作。在您的许可下,我们还使用分析工具来改善网站。