DOM
object
%charts
/phlo/resources/DOM/charts.phlo
static
charts :: spark ($values, $color = '#888', $w = 240, $h = 48, $label = null)
line 9
Generates an SVG sparkline chart based on the provided values, calculating the necessary points and area for rendering.
$values = array_values(array_map('floatval', (array)$values))
if (!$values) return void
$max = max($values) ?: 1
$min = min($values)
$range = max(0.0001, $max - $min)
$count = count($values)
$step = $count > 1 ? $w / ($count - 1) : 0
$points = []
foreach ($values AS $i => $v){
$x = round($i * $step, 1)
$y = round($h - (($v - $min) / $range) * ($h - 6) - 3, 1)
$points[] = "$x,$y"
}
$area = array_merge($points, [round($w, 1).comma.$h, '0,'.$h])
$id = 'g'.substr(md5(implode(comma, $points)), 0, 6)
$aria = $label !== null ? ' role="img" aria-label="'.esc($label).'"' : void
return '<svg viewBox="0 0 '.$w.' '.$h.'"'.$aria.' xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="none" style="display:block;width:100%;height:'.$h.'px"><defs><linearGradient id="'.$id.'" x1="0" x2="0" y1="0" y2="1"><stop offset="0%" stop-color="'.$color.'" stop-opacity=".4"/><stop offset="100%" stop-color="'.$color.'" stop-opacity="0"/></linearGradient></defs><polygon points="'.implode(space, $area).'" fill="url(#'.$id.')"/><polyline points="'.implode(space, $points).'" fill="none" stroke="'.$color.'" stroke-width="1.6" stroke-linejoin="round" stroke-linecap="round"/></svg>'static
charts :: bars ($values, $color = '#888', $w = 240, $h = 48, $label = null)
line 29
Generates an SVG bar chart based on the provided values, calculating the height and position of each bar relative to the maximum value.
$values = array_values(array_map('floatval', (array)$values))
if (!$values) return void
$max = max($values) ?: 1
$count = count($values)
$gap = 2
$bw = max(1, ($w - ($count - 1) * $gap) / $count)
$out = void
foreach ($values AS $i => $v){
$bh = round(($v / $max) * ($h - 4), 1)
$x = round($i * ($bw + $gap), 1)
$y = round($h - $bh, 1)
$out .= '<rect x="'.$x.'" y="'.$y.'" width="'.round($bw, 1).'" height="'.max(0.5, $bh).'" fill="'.$color.'" rx="1.5"/>'
}
$aria = $label !== null ? ' role="img" aria-label="'.esc($label).'"' : void
return '<svg viewBox="0 0 '.$w.' '.$h.'"'.$aria.' xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="none" style="display:block;width:100%;height:'.$h.'px">'.$out.'</svg>'static
charts :: donut ($parts, $colors = null, $size = 120)
line 47
Generates an SVG donut chart based on the provided parts, calculating the proportions and colors for each segment.
$parts = array_filter(array_map('floatval', (array)$parts))
$total = array_sum($parts)
if (!$total) return void
$colors ??= ['#FFC76B', '#6CC0FF', '#9D7BFF', '#6BE39E', '#FF8A5C', '#FF6B6B']
$cx = $size / 2
$cy = $size / 2
$r = $size * 0.4
$circumference = 2 * M_PI * $r
$out = void
$rotate = -90
$i = 0
foreach ($parts AS $value){
$len = ($value / $total) * $circumference
$color = $colors[$i % count($colors)]
$out .= '<circle cx="'.$cx.'" cy="'.$cy.'" r="'.$r.'" fill="none" stroke="'.$color.'" stroke-width="'.($size * 0.18).'" stroke-dasharray="'.$len.space.$circumference.'" transform="rotate('.$rotate.space.$cx.space.$cy.')"/>'
$rotate += ($value / $total) * 360
$i++
}
return '<svg viewBox="0 0 '.$size.' '.$size.'" xmlns="http://www.w3.org/2000/svg" style="display:block;width:'.$size.'px;height:'.$size.'px">'.$out.'<circle cx="'.$cx.'" cy="'.$cy.'" r="'.($r - $size * 0.09).'" fill="rgba(255,255,255,.04)"/></svg>'object
%cookiewall
/phlo/resources/DOM/cookiewall.phlo
static
cookiewall :: __handle
line 10
This method handles the cookie wall functionality, managing user consent for cookies.
nullprop
%cookiewall -> choice
line 12
Retrieves the value of 'cookieChoice' from the %cookies object, returning null if it is not set.
%cookies->cookieChoice ?? nullmethod
%cookiewall -> hasChosen
line 13
Checks if a choice has been made by verifying that the choice is not null.
$this->choice !== nullmethod
%cookiewall -> canTrack
line 14
Determines if tracking is allowed based on the user's choice, specifically if it is set to 'all'.
$this->choice === 'all'method
%cookiewall -> canAnalytics
line 15
Checks if the current choice is set to 'all' to determine if analytics can be enabled.
$this->choice === 'all'prop
%cookiewall -> translate
line 17
Checks if the function 'en' exists in the current scope.
function_exists('en')prop
%cookiewall -> labels
line 18
Defines a set of labels for a cookie consent wall, including the region title, body text, and button labels.
arr(
region: 'Cookie choice',
body: 'We use essential cookies to make this site work. With your permission we also use analytics to improve the site.',
essential: 'Essential only',
accept: 'Accept',
)method
%cookiewall -> label ($key)
line 24
Retrieves the label associated with the specified key, translating it if translation is enabled.
$this->translate ? en($this->labels[$key]) : $this->labels[$key]route
route async POST cookiewall accept all
line 26
Defines a route that sets a cookie indicating the user has accepted all cookies and removes the cookiewall view asynchronously.
%cookies->objSet('cookieChoice', 'all', ['expires' => time() + 60 * 60 * 24 * 365, 'httponly' => false])
apply(remove: '#cookiewall')route
route async POST cookiewall accept essential
line 31
This route sets a cookie named 'cookieChoice' with the value 'essential' that expires in one year and then applies a removal of the element with the ID 'cookiewall'.
%cookies->objSet('cookieChoice', 'essential', ['expires' => time() + 60 * 60 * 24 * 365, 'httponly' => false])
apply(remove: '#cookiewall')view
%cookiewall -> banner
line 36
Displays a cookie consent wall prompting users to accept essential or all cookies if they have not already made a choice.
<if !$this->hasChosen()>
<div#cookiewall role=region aria-label="{{ $this->label('region') }}">
<p>{{ $this->label('body') }}</p>
<div.actions>
<form.async method=post action=/cookiewall/accept/essential>
<button.ghost type=submit>{{ $this->label('essential') }}</button>
</form>
<form.async method=post action=/cookiewall/accept/all>
<button.primary type=submit>{{ $this->label('accept') }}</button>
</form>
</div>
</div>
</if>view
style
line 51
Defines the CSS styles for the cookiewall component, including layout, colors, and responsive design.
#cookiewall {
background: #1a1a1a
border-radius: 8px
bottom: 16px
box-shadow: 0 8px 32px #0004
color: #fff
font-size: 13px
left: 16px
line-height: 1.5
max-width: 360px
padding: 14px 16px
position: fixed
right: 16px
z-index: 9999
@media(min-width: 600px): right: auto
p: margin: 0 0 10px
.actions {
display: flex
gap: 8px
justify-content: flex-end
}
button {
border-radius: 4px
border: 0
cursor: pointer
font-size: 12px
padding: 6px 12px
}
button.ghost {
background: #444
color: #fff
}
button.primary {
background: #fff
color: #1a1a1a
font-weight: 600
}
}object
%CSS_fixes
/phlo/resources/DOM/CSS.fixes.phlo
view
style
line 9
Applies global CSS styles to ensure consistent box-sizing, touch actions, focus outlines, and appearance for various HTML elements.
*, ::before, ::after: box-sizing: border-box
a, area, button, input, label, select, summary, textarea, [tabindex]: touch-action: manipulation
button:focus, input:focus, select:focus, textarea:focus, [contenteditable]:focus: outline: 0
input::-webkit-outer-spin-button, input::-webkit-inner-spin-button: -webkit-appearance: none
input[type="number"]: -moz-appearance: textfield
table: border-collapse: collapse
::-ms-expand: display: noneobject
%CSS_var
/phlo/resources/DOM/CSS.var.phlo
view
script
line 9
Defines a proxy for accessing and modifying CSS custom properties (variables) on the document's root element, allowing for dynamic updates and retrieval of their values.
Object.defineProperty(app, 'var', {get(){return new Proxy({}, {get(_, key){return getComputedStyle(document.documentElement).getPropertyValue(`--${key}`).trim()}, set(_, key, value){ return document.documentElement.style.setProperty(`--${key}`, value)}})}, configurable: true})
app.mod.setvar = (key, value) => app.var[key] = valueobject
%datatags
/phlo/resources/DOM/datatags.phlo
view
script
line 10
Handles click events on elements with data attributes for HTTP methods, preventing default actions and executing the corresponding method with the specified path and data.
on('click', '[data-get], [data-post], [data-put], [data-patch], [data-delete]', (el, e) => {
if (el.dataset.confirm) return
e.preventDefault()
let method, path, data = null
if ((path = el.dataset.get) !== undefined) method = 'get'
else if ((path = el.dataset.delete) !== undefined) method = 'delete'
else [data = {}, Object.keys(el.dataset).forEach(key => key === 'post' || key === 'put' || key === 'patch' ? [method = key, path = el.dataset[key]] : data[key] = el.dataset[key])]
app[method](path, data)
})object
%dialog
/phlo/resources/DOM/dialog.phlo
view
script
line 10
Creates and manages dialog boxes for alerts, confirmations, and prompts in a Phlo application, allowing user interaction through modal dialogs.
window.alert = app.mod.alert = msg => phlo.dialog('alert', msg)
window.confirm = msg => phlo.dialog('confirm', msg)
window.prompt = (msg, defaultValue) => phlo.dialog('prompt', msg, defaultValue)
phlo.dialog = async (type, message, defaultValue = '') => new Promise(resolve => {
app.mod.append('body', '<dialog id="phloDialog" class="phlo-dialog" role="dialog" aria-modal="true">\n<form method="dialog">\n<p class="phlo-dialog__message"></p>\n' + (type === 'prompt' ? '<input class="phlo-dialog__input" name="value">' : '') + '\n<menu class="phlo-dialog__actions">\n<button value="1" autofocus>OK</button>\n' + (type !== 'alert' ? '<button value="0">Cancel</button>' : '') + '\n</menu>\n</form>\n</dialog>')
const dialog = obj('#phloDialog')
const messageEl = dialog.querySelector('.phlo-dialog__message')
messageEl && (messageEl.textContent = String(message ?? ''))
if (type === 'prompt') dialog.querySelector('input').value = String(defaultValue ?? '')
dialog.showModal()
dialog.addEventListener('close', () => {
const value = dialog.returnValue
const input = dialog.querySelector('input')
dialog.remove()
if (type === 'alert') return resolve()
if (type === 'confirm') return resolve(value === '1')
if (type === 'prompt') return resolve(value === '1' ? input.value : null)
})
})
on('click', '[data-confirm]', async (el, e) => {
e.preventDefault()
if (!await window.confirm(el.dataset.confirm)) return
delete el.dataset.confirm
app.update()
el.click()
})object
%exists
/phlo/resources/DOM/exists.phlo
view
script
line 10
This function tracks elements and their associated callbacks, executing the callbacks for elements that exist but have not been previously registered.
phlo.exist = []
phlo.existing = new WeakMap
const onExist = (els, cb) => phlo.exist.push({els, cb})
app.updates.push(() => {
const existing = []
phlo.exist.forEach(item => objects(item.els).forEach(el => phlo.existing.has(el) || existing.push({el, cb: item.cb})))
existing.forEach(item => [phlo.existing.has(item.el) || phlo.existing.set(item.el, 'exist'), item.cb(item.el)])
})object
%ffmpeg
/phlo/resources/DOM/ffmpeg.phlo
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
view
script
line 10
Handles input events for form elements, updating their attributes based on user interaction, and submits the form asynchronously using the specified method.
on('input change', 'input, select, textarea', input => {
if (input.tagName === 'SELECT') input.querySelectorAll('option').forEach((option, index) => option.selected ? option.setAttribute('selected', '') : option.removeAttribute('selected'))
if (input.type === 'checkbox') input.checked ? input.setAttribute('checked', '') : input.removeAttribute('checked')
if (input.type === 'text' && input.value !== input.getAttribute('value')) input.setAttribute('value', input.value)
if (input.type === 'textarea' && input.value !== input.innerHTML) input.innerHTML = input.value
phlo.state.replace()
return false
})
on('submit', 'form.async', (form, e) => [e.preventDefault(), app[(form.attributes.method?.value ?? 'GET').toLowerCase()](new URL(form.action).pathname.substr(1), new FormData(form))])object
%image_resizer
/phlo/resources/DOM/image.resizer.phlo
view
script
line 9
Resizes an image file to specified maximum dimensions while maintaining the aspect ratio, and returns the resized image as a data URL through a callback function.
const imageResizer = (file, maxWidth, maxHeight, cb, quality = .8) => {
const img = new Image
img.onload = () => {
let width = img.width, height = img.height
const aspectRatio = width / height
if (width > maxWidth || height > maxHeight){
if (width > height){
width = maxWidth
height = Math.round(maxWidth / aspectRatio)
}
else {
height = maxHeight
width = Math.round(maxHeight * aspectRatio)
}
}
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height
canvas.getContext('2d').drawImage(img, 0, 0, width, height)
cb(canvas.toDataURL(file.type, quality))
}
img.src = URL.createObjectURL(file)
}object
%link
/phlo/resources/DOM/link.phlo
view
script
line 10
This function handles click events on anchor tags, preventing default behavior under certain conditions and managing asynchronous navigation or hash changes in the URL.
on('click', 'a', (a, e) => {
if (e.ctrlKey || e.shiftKey || e.metaKey || a.dataset.confirm) return false
const isAsync = a.classList.contains('async')
const [uri, hash] = a.getAttribute('href').split('#')
if (isAsync || hash) e.preventDefault()
phlo.anchor = hash ? `#${hash}` : ''
if (hash && (!uri || uri === location.pathname + location.search)) location.hash = phlo.anchor
else if (isAsync) app.get(uri.substr(1))
})object
%markdown
/phlo/resources/DOM/markdown.phlo
view
script
line 9
Parses Markdown text into HTML, supporting various features like GitHub Flavored Markdown, block elements, inline elements, and reference links.
function parse_markdown(md, opts = {}){
const o = {
gfm: opts.gfm !== false,
breaks: !!opts.breaks,
headerIds: opts.headerIds !== false,
headerPrefix: opts.headerPrefix || '',
smartypants: !!opts.smartypants
}
const unnull = x => (x == null ? '' : String(x))
let src = unnull(md).replace(/\r\n?/g, "\n")
const escHtml = s => s.replace(/[&<>"]/g, c => ({'&':'&','<':'<','>':'>','"':'"'}[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, "&").replace(/<(?!\/?[A-Za-z][^>]*>)/g, "<")
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
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
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 Recorderobject
%shorthands
/phlo/resources/DOM/shorthands.phlo
view
script
line 10
Defines shorthand functions for common event listeners: onChange, onClick, and onInput, which simplify the process of attaching event handlers to elements.
function onChange(els, cb){ on('change', els, cb) }
function onClick(els, cb){ on('click', els, cb) }
function onInput(els, cb){ on('input', els, cb) }object
%store
/phlo/resources/DOM/store.phlo
view
script
line 10
Manages a reactive state store in Phlo, allowing for the storage, retrieval, and notification of changes to state values. It supports computed values, dependency tracking, and DOM binding for dynamic updates.
phlo.store = {
signals: {},
listeners: {},
calcs: {},
calcDeps: {},
calcVals: {},
calcTick: false,
split: path => path.replace(/\]/g, '').split(/\.|\[/),
get(path){
if (!path) return undefined
let ctx = phlo.store.signals
const keys = phlo.store.split(path)
for (let i = 0; i < keys.length; i++){
if (ctx == null) return undefined
ctx = ctx[keys[i]]
}
return ctx
},
setPath(path, value){
let keys = phlo.store.split(path)
let ctx = phlo.store.signals
while (keys.length > 1){
const k = keys.shift()
ctx[k] ??= isNaN(keys[0]) ? {} : []
ctx = ctx[k]
}
const k = keys[0]
const old = ctx[k]
if (old === value) return false
ctx[k] = value
return true
},
set(path, value){
if (!phlo.store.setPath(path, value)) return
phlo.store.notify(path, phlo.store.get(path))
phlo.store.recalc(path)
phlo.store.schedule()
},
on(path, cb){ (phlo.store.listeners[path] ??= new Set).add(cb) },
off(path, cb){ phlo.store.listeners[path] && phlo.store.listeners[path].delete(cb) },
reset(){
phlo.store.signals = {}
phlo.store.listeners = {}
phlo.store.calcs = {}
phlo.store.calcDeps = {}
phlo.store.calcVals = {}
phlo.store.calcTick = false
},
signal(path, initial){
if (phlo.store.get(path) === undefined) phlo.store.set(path, initial)
return { subscribe: cb => phlo.store.on(path, cb), unsubscribe: cb => phlo.store.off(path, cb) }
},
notify(path, val){
const set = phlo.store.listeners[path]
if (set) set.forEach(cb => cb(val))
},
match(dep, changed){
if (!dep) return false
if (dep === changed) return true
return changed.startsWith(dep + '.') || changed.startsWith(dep + '[') || dep.startsWith(changed + '.') || dep.startsWith(changed + '[')
},
depsReady(list){
const arr = Array.isArray(list) ? list : (list ? [list] : [])
return arr.every(d => phlo.store.get(d) !== undefined)
},
evalCalc(name){
const fn = phlo.store.calcs[name]
if (!fn) return
let deps = []
let val
try {
const out = fn()
if (Array.isArray(out) && out.length === 2) deps = out[0], val = out[1]
else val = out
}
catch(e){
deps = []
val = undefined
}
const list = Array.isArray(deps) ? deps : (deps ? [deps] : [])
phlo.store.calcDeps[name] = list
if (!phlo.store.depsReady(list)) return
const old = phlo.store.calcVals[name]
if (old !== val){
phlo.store.calcVals[name] = val
const p = `calc.${name}`
phlo.store.setPath(p, val)
phlo.store.notify(p, val)
}
},
recalc(changed){
const names = Object.keys(phlo.store.calcs)
for (let i = 0; i < names.length; i++){
const name = names[i]
const deps = phlo.store.calcDeps[name] || []
for (let j = 0; j < deps.length; j++){
if (phlo.store.match(deps[j], changed)){
phlo.store.evalCalc(name)
break
}
}
}
},
recalcAll(){
const names = Object.keys(phlo.store.calcs)
for (let i = 0; i < names.length; i++) phlo.store.evalCalc(names[i])
},
schedule(){
if (phlo.store.calcTick) return
phlo.store.calcTick = true
setTimeout(() => {
phlo.store.calcTick = false
phlo.store.recalcAll()
})
},
proxy(base){
return new Proxy({}, {
get(t, k){
if (typeof k === 'symbol') return undefined
const seg = /^\d+$/.test(k) ? `[${k}]` : (base ? `.${k}` : String(k))
const path = base + seg
const v = phlo.store.get(path)
if (v !== undefined && (typeof v !== 'object' || v === null)) return v
return phlo.store.proxy(path)
},
set(t, k, v){
const seg = /^\d+$/.test(k) ? `[${k}]` : (base ? `.${k}` : String(k))
phlo.store.set(base + seg, v)
return true
},
has(t, k){ return phlo.store.get(base + (base ? '.' : '') + String(k)) !== undefined },
ownKeys(){ return Object.keys(phlo.store.get(base) || {}) },
getOwnPropertyDescriptor(){ return { enumerable: true, configurable: true } }
})
}
}
app.store = phlo.store.proxy('')
app.mod.store = (key, value) => {
const cur = phlo.store.get(key)
if (JSON.stringify(cur) === JSON.stringify(value)) return
const walk = (base, obj) => {
if (typeof obj !== 'object' || obj === null) return phlo.store.set(base, obj)
Object.entries(obj).forEach(([k, v]) => walk(isNaN(k) ? `${base}.${k}` : `${base}[${k}]`, v))
}
walk(key, value)
}
phlo.calc = new Proxy({}, {
set(t, k, fn){
if (typeof fn !== 'function') return false
phlo.store.calcs[k] = fn
phlo.store.evalCalc(k)
setTimeout(() => phlo.store.evalCalc(k))
return true
},
get(t, k){ return phlo.store.calcs[k] },
has(t, k){ return k in phlo.store.calcs },
deleteProperty(t, k){
delete phlo.store.calcs[k]
delete phlo.store.calcDeps[k]
delete phlo.store.calcVals[k]
return true
}
})
app.calc = new Proxy({}, {
get(t, k){ return phlo.store.calcVals[k] },
has(t, k){ return k in phlo.store.calcVals },
ownKeys(){ return Object.keys(phlo.store.calcVals) },
getOwnPropertyDescriptor(){ return { enumerable: true, configurable: true } }
})
onExist('[data-bind]', el => {
const key = el.dataset.bind
const isCalc = key.startsWith('calc.')
const isInput = el.tagName === 'INPUT' || el.tagName === 'SELECT' || el.tagName === 'TEXTAREA'
const fromDom = isInput ? el.value : el.textContent
const fromStore = phlo.store.get(key)
const domNonEmpty = (fromDom ?? '').trim() !== ''
const storeEmpty = fromStore === undefined || (typeof fromStore === 'string' && fromStore.trim() === '')
const domLeads = !isCalc && domNonEmpty && storeEmpty
const S = v => v == null ? '' : (typeof v === 'object' ? '' : String(v))
const apply = v => {
const s = S(v)
if (isInput) el.value = s
else el.textContent = s
}
phlo.store.on(key, apply)
if (domLeads) phlo.store.set(key, fromDom)
const initial = domLeads ? fromDom : fromStore
apply(initial)
if (!isCalc && isInput) el.oninput = e => phlo.store.set(key, e.target.value)
})
onExist('[data-bind-attr]', el => {
const spec = el.getAttribute('data-bind-attr')
if (!spec) return
const BOOL = new Set(['disabled','checked','hidden','required','readonly','selected','autofocus','multiple'])
let meta = phlo.existing.get(el)
if (!meta || typeof meta !== 'object'){
meta = { exist: true }
phlo.existing.set(el, meta)
}
meta.attr || (meta.attr = {})
meta.attr.cls || (meta.attr.cls = [])
spec.split(/\s*,\s*/).filter(Boolean).forEach(pair => {
const m = pair.match(/^\s*([^:]+)\s*:\s*(.+)\s*$/)
if (!m) return
const name = m[1]
const path = m[2]
const isCalc = path.startsWith('calc.')
const isInputVal = name === 'value' && (el.tagName === 'INPUT' || el.tagName === 'SELECT' || el.tagName === 'TEXTAREA')
const domVal =
name === 'text' ? el.textContent :
name === 'html' ? el.innerHTML :
name === 'value' ? el.value :
(name === 'class' ? null : el.getAttribute(name))
const fromStore = phlo.store.get(path)
const domNonEmpty = (domVal ?? '').trim() !== ''
const storeEmpty = fromStore === undefined || (typeof fromStore === 'string' && fromStore.trim() === '')
const domLeads = !isCalc && name !== 'class' && domNonEmpty && storeEmpty
const S = v => v == null ? '' : (typeof v === 'object' ? '' : String(v))
const apply = v => {
if (name === 'text') el.textContent = S(v)
else if (name === 'html') app.mod.inner(el, S(v))
else if (name === 'value') app.mod.value(el, S(v))
else if (name === 'class'){
const next = Array.isArray(v) ? v : (v && typeof v === 'object') ? Object.keys(v).filter(k => v[k]) : String(v ?? '').split(/\s+/)
const uniq = [...new Set(next.filter(Boolean))]
const prev = meta.attr.cls
for (let i = 0; i < prev.length; i++) el.classList.remove(prev[i])
for (let i = 0; i < uniq.length; i++) el.classList.add(uniq[i])
meta.attr.cls = uniq
}
else if (BOOL.has(name)){
const on = !!v
app.mod.attr(el, { [name]: on ? '' : null })
if (name in el) el[name] = on
}
else app.mod.attr(el, { [name]: S(v) })
}
phlo.store.on(path, apply)
if (domLeads) phlo.store.set(path, domVal)
const initial = domLeads ? domVal : fromStore
apply(initial)
if (!isCalc && isInputVal) el.oninput = e => phlo.store.set(path, e.target.value)
})
})object
%template
/phlo/resources/DOM/template.phlo
view
script
line 10
Defines a function that applies a specified template to each row of data, invoking the template with the values from the row.
app.mod.template = (template, rows) => rows.forEach(row => templates[template](...Object.values(row)))
const templates = {}object
%timestamps
/phlo/resources/DOM/timestamps.phlo
view
script
line 10
Updates the text content of elements with a 'data-ts' attribute to display the time elapsed since a timestamp in a human-readable format, refreshing every second.
app.tsBase = {seconds: 60, minutes: 60, hours: 24, days: 7, weeks: 4, months: 13, years: 1}
const tsUpdate = () => (ranges = app.tsLabels && (tsValues = Object.values(app.tsBase)) ? Object.fromEntries(app.tsLabels.map((k, i) => [k, tsValues[i]])) : app.tsBase) && objects('[data-ts]').forEach(el => {
let age = Math.round(Date.now() / 1000) - Number(el.dataset.ts), text = ''
const future = age < 0
if (future) age = -age
for (const [range, multiplier] of Object.entries(ranges)){
if (text) continue
if (age / multiplier < 1.6583) text = `${Math.round(age)} ${range}`
age /= multiplier
}
text ||= `${Math.round(age)} ${Object.keys(ranges).at(-1)}`
text = `${future ? '-' : ''}${text}`
el.innerText === text || (el.innerText = text)
})
setInterval(() => document.hidden || tsUpdate(), 1000)
setTimeout(tsUpdate, 1)object
%toasts
/phlo/resources/DOM/toasts.phlo
view
script
line 10
Creates a toast notification that displays a message for a short duration and can be dismissed by clicking on it.
app.mod.toast = msg => {
obj('#toasts') || app.mod.append('body', '<div id="toasts"></div>')
const toast = document.createElement('div')
toast.innerHTML = msg
toast.onclick = () => toast.remove()
obj('#toasts').insertAdjacentElement('beforeend', toast)
setTimeout(() => toast.remove(), 4000)
}view
style
line 21
Defines the CSS styles for toast notifications, positioning them fixed in the top right corner with specific background color, border radius, and text styling.
#toasts {
position: fixed
right: 10px
top: 5px
z-index: 1001
> * {
background-color: #000A
border-radius: 10px
clear: both
color: white
cursor: zoom-out
float: right
margin-top: 5px
padding: 3px 6px
}
}object
%visible
/phlo/resources/DOM/visible.phlo
view
script
line 10
Sets up an IntersectionObserver to execute callback functions when specified elements become visible or hidden in the viewport.
phlo.observe = []
phlo.observing = new WeakMap
const onVisible = (els, cbIn, cbOut) => onVisibleIn(els, null, cbIn, cbOut)
const onVisibleIn = (els, root, cbIn, cbOut) => phlo.observe.push({els, root, cbIn, cbOut})
app.updates.push(() => {
const observers = []
phlo.observe.forEach(item => objects(item.els).forEach(el => phlo.observing.has(el) || observers.push({el, root: item.root, cbIn: item.cbIn, cbOut: item.cbOut})))
observers.forEach(item => [phlo.observing.has(item.el) || phlo.observing.set(item.el, 'observe'), (observer = new IntersectionObserver(entries => entries.forEach(entry => entry.isIntersecting ? !item.cbIn && item.cbOut ? [observer.unobserve(entry.target), item.cbOut(entry.target)] : item.cbIn(entry.target) : item.cbIn && item.cbOut && item.cbOut(entry.target)), {root: obj(item.root), threshold: .1})).observe(item.el)])
})object
%websocket
/phlo/resources/DOM/websocket.phlo
view
script
line 10
Establishes a WebSocket connection to the specified path and manages its lifecycle, including handling messages, errors, and reconnections.
app.websocket = {
get open(){
app.options.contains('wss') && delay('websocket', app.websocket.retry, () => {
phlo.wss?.close()
phlo.wss = new WebSocket(`wss://${location.host}/${app.websocket.path}`)
phlo.wss.onmessage = e => [{trans, state, ...cmds} = JSON.parse(e.data), apply(cmds, trans, state)]
phlo.wss.onopen = e => [(cb = app.websocket.connect) && cb(e), phlo.log('π§ Websocket connected', e), app.websocket.retry = 333]
phlo.wss.onerror = e => [(cb = app.websocket.error) && cb(e), phlo.log('π§ Websocket error', e)]
phlo.wss.onclose = e => [(cb = app.websocket.close) && cb(e), phlo.log('π§ Websocket close', e), app.websocket.retry && [app.websocket.open, app.websocket.retry *= 3]]
})
},
path: 'websocket',
send: data => phlo.wss?.readyState === 1 ? [phlo.log('π§ app.websocket.send', '\n', data), phlo.wss.send(JSON.stringify(data))] : phlo.error('π§ Could not send websocket data over closed socket'),
retry: 333,
}
app.websocket.open