DOM
object
%charts
/phlo/resources/DOM/charts.phlo
static
charts :: spark ($values, $color = '#888', $w = 240, $h = 48, $label = null)
line 9
Genereert een SVG-sparkline-grafiek op basis van de opgegeven waarden, waarbij de benodigde punten en het gebied voor weergave worden berekend.
$values = array_values(array_map('floatval', (array)$values))
if (!$values) return void
$max = max($values) ?: 1
$min = min($values)
$range = max(0.0001, $max - $min)
$count = count($values)
$step = $count > 1 ? $w / ($count - 1) : 0
$points = []
foreach ($values AS $i => $v){
$x = round($i * $step, 1)
$y = round($h - (($v - $min) / $range) * ($h - 6) - 3, 1)
$points[] = "$x,$y"
}
$area = array_merge($points, [round($w, 1).comma.$h, '0,'.$h])
$id = 'g'.substr(md5(implode(comma, $points)), 0, 6)
$aria = $label !== null ? ' role="img" aria-label="'.esc($label).'"' : void
return '<svg viewBox="0 0 '.$w.' '.$h.'"'.$aria.' xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="none" style="display:block;width:100%;height:'.$h.'px"><defs><linearGradient id="'.$id.'" x1="0" x2="0" y1="0" y2="1"><stop offset="0%" stop-color="'.$color.'" stop-opacity=".4"/><stop offset="100%" stop-color="'.$color.'" stop-opacity="0"/></linearGradient></defs><polygon points="'.implode(space, $area).'" fill="url(#'.$id.')"/><polyline points="'.implode(space, $points).'" fill="none" stroke="'.$color.'" stroke-width="1.6" stroke-linejoin="round" stroke-linecap="round"/></svg>'static
charts :: bars ($values, $color = '#888', $w = 240, $h = 48, $label = null)
line 29
Genereert een SVG-balkdiagram op basis van de opgegeven waarden, waarbij de hoogte en positie van elke balk ten opzichte van de maximale waarde worden berekend.
$values = array_values(array_map('floatval', (array)$values))
if (!$values) return void
$max = max($values) ?: 1
$count = count($values)
$gap = 2
$bw = max(1, ($w - ($count - 1) * $gap) / $count)
$out = void
foreach ($values AS $i => $v){
$bh = round(($v / $max) * ($h - 4), 1)
$x = round($i * ($bw + $gap), 1)
$y = round($h - $bh, 1)
$out .= '<rect x="'.$x.'" y="'.$y.'" width="'.round($bw, 1).'" height="'.max(0.5, $bh).'" fill="'.$color.'" rx="1.5"/>'
}
$aria = $label !== null ? ' role="img" aria-label="'.esc($label).'"' : void
return '<svg viewBox="0 0 '.$w.' '.$h.'"'.$aria.' xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="none" style="display:block;width:100%;height:'.$h.'px">'.$out.'</svg>'static
charts :: donut ($parts, $colors = null, $size = 120)
line 47
Genereert een SVG donutgrafiek op basis van de opgegeven delen, waarbij de verhoudingen en kleuren voor elk segment worden berekend.
$parts = array_filter(array_map('floatval', (array)$parts))
$total = array_sum($parts)
if (!$total) return void
$colors ??= ['#FFC76B', '#6CC0FF', '#9D7BFF', '#6BE39E', '#FF8A5C', '#FF6B6B']
$cx = $size / 2
$cy = $size / 2
$r = $size * 0.4
$circumference = 2 * M_PI * $r
$out = void
$rotate = -90
$i = 0
foreach ($parts AS $value){
$len = ($value / $total) * $circumference
$color = $colors[$i % count($colors)]
$out .= '<circle cx="'.$cx.'" cy="'.$cy.'" r="'.$r.'" fill="none" stroke="'.$color.'" stroke-width="'.($size * 0.18).'" stroke-dasharray="'.$len.space.$circumference.'" transform="rotate('.$rotate.space.$cx.space.$cy.')"/>'
$rotate += ($value / $total) * 360
$i++
}
return '<svg viewBox="0 0 '.$size.' '.$size.'" xmlns="http://www.w3.org/2000/svg" style="display:block;width:'.$size.'px;height:'.$size.'px">'.$out.'<circle cx="'.$cx.'" cy="'.$cy.'" r="'.($r - $size * 0.09).'" fill="rgba(255,255,255,.04)"/></svg>'object
%cookiewall
/phlo/resources/DOM/cookiewall.phlo
static
cookiewall :: __handle
line 10
Deze methode beheert de cookie wall-functionaliteit en regelt de gebruikersconsent voor cookies.
nullprop
%cookiewall -> choice
line 12
Haal de waarde van 'cookieChoice' op uit het %cookies-object, en retourneer null als deze niet is ingesteld.
%cookies->cookieChoice ?? nullmethod
%cookiewall -> hasChosen
line 13
Controleert of er een keuze is gemaakt door te verifiëren dat de keuze niet null is.
$this->choice !== nullmethod
%cookiewall -> canTrack
line 14
Bepaalt of tracking is toegestaan op basis van de keuze van de gebruiker, specifiek als deze is ingesteld op 'all'.
$this->choice === 'all'method
%cookiewall -> canAnalytics
line 15
Controleert of de huidige keuze is ingesteld op 'all' om te bepalen of analytics kan worden ingeschakeld.
$this->choice === 'all'prop
%cookiewall -> translate
line 17
Controleert of de functie 'en' bestaat in de huidige scope.
function_exists('en')prop
%cookiewall -> labels
line 18
Definieert een set labels voor een cookie-toestemmingsmuur, inclusief de titel van de regio, de tekst van de body en de knoppenlabels.
arr(
region: 'Cookie choice',
body: 'We use essential cookies to make this site work. With your permission we also use analytics to improve the site.',
essential: 'Essential only',
accept: 'Accept',
)method
%cookiewall -> label ($key)
line 24
Haal het label op dat is gekoppeld aan de opgegeven sleutel, en vertaal het indien vertaling is ingeschakeld.
$this->translate ? en($this->labels[$key]) : $this->labels[$key]route
route async POST cookiewall accept all
line 26
Definieert een route die een cookie instelt waarin staat dat de gebruiker alle cookies heeft geaccepteerd en verwijdert de cookiewall view asynchroon.
%cookies->objSet('cookieChoice', 'all', ['expires' => time() + 60 * 60 * 24 * 365, 'httponly' => false])
apply(remove: '#cookiewall')route
route async POST cookiewall accept essential
line 31
Deze route stelt een cookie in met de naam 'cookieChoice' met de waarde 'essential' die over een jaar verloopt en past vervolgens een verwijdering toe van het element met de ID 'cookiewall'.
%cookies->objSet('cookieChoice', 'essential', ['expires' => time() + 60 * 60 * 24 * 365, 'httponly' => false])
apply(remove: '#cookiewall')view
%cookiewall -> banner
line 36
Toont een cookie-toestemmingsmuur die gebruikers vraagt om essentiële of alle cookies te accepteren als ze nog geen keuze hebben gemaakt.
<if !$this->hasChosen()>
<div#cookiewall role=region aria-label="{{ $this->label('region') }}">
<p>{{ $this->label('body') }}</p>
<div.actions>
<form.async method=post action=/cookiewall/accept/essential>
<button.ghost type=submit>{{ $this->label('essential') }}</button>
</form>
<form.async method=post action=/cookiewall/accept/all>
<button.primary type=submit>{{ $this->label('accept') }}</button>
</form>
</div>
</div>
</if>view
style
line 51
Definieert de CSS-stijlen voor de cookiewall-component, inclusief lay-out, kleuren en responsief ontwerp.
#cookiewall {
background: #1a1a1a
border-radius: 8px
bottom: 16px
box-shadow: 0 8px 32px #0004
color: #fff
font-size: 13px
left: 16px
line-height: 1.5
max-width: 360px
padding: 14px 16px
position: fixed
right: 16px
z-index: 9999
@media(min-width: 600px): right: auto
p: margin: 0 0 10px
.actions {
display: flex
gap: 8px
justify-content: flex-end
}
button {
border-radius: 4px
border: 0
cursor: pointer
font-size: 12px
padding: 6px 12px
}
button.ghost {
background: #444
color: #fff
}
button.primary {
background: #fff
color: #1a1a1a
font-weight: 600
}
}object
%CSS_fixes
/phlo/resources/DOM/CSS.fixes.phlo
view
style
line 9
Past globale CSS-stijlen toe om consistente box-sizing, touch-acties, focusomtrekken en uiterlijk voor verschillende HTML-elementen te waarborgen.
*, ::before, ::after: box-sizing: border-box
a, area, button, input, label, select, summary, textarea, [tabindex]: touch-action: manipulation
button:focus, input:focus, select:focus, textarea:focus, [contenteditable]:focus: outline: 0
input::-webkit-outer-spin-button, input::-webkit-inner-spin-button: -webkit-appearance: none
input[type="number"]: -moz-appearance: textfield
table: border-collapse: collapse
::-ms-expand: display: noneobject
%CSS_var
/phlo/resources/DOM/CSS.var.phlo
view
script
line 9
Definieert een proxy voor het openen en wijzigen van CSS-aangepaste eigenschappen (variabelen) op het root-element van het document, waardoor dynamische updates en het ophalen van hun waarden mogelijk zijn.
Object.defineProperty(app, 'var', {get(){return new Proxy({}, {get(_, key){return getComputedStyle(document.documentElement).getPropertyValue(`--${key}`).trim()}, set(_, key, value){ return document.documentElement.style.setProperty(`--${key}`, value)}})}, configurable: true})
app.mod.setvar = (key, value) => app.var[key] = valueobject
%datatags
/phlo/resources/DOM/datatags.phlo
view
script
line 10
Behandelt klikgebeurtenissen op elementen met data-attributen voor HTTP-methoden, voorkomt standaardacties en voert de bijbehorende methode uit met het opgegeven pad en gegevens.
on('click', '[data-get], [data-post], [data-put], [data-patch], [data-delete]', (el, e) => {
if (el.dataset.confirm) return
e.preventDefault()
let method, path, data = null
if ((path = el.dataset.get) !== undefined) method = 'get'
else if ((path = el.dataset.delete) !== undefined) method = 'delete'
else [data = {}, Object.keys(el.dataset).forEach(key => key === 'post' || key === 'put' || key === 'patch' ? [method = key, path = el.dataset[key]] : data[key] = el.dataset[key])]
app[method](path, data)
})object
%dialog
/phlo/resources/DOM/dialog.phlo
view
script
line 10
Maakt en beheert dialoogvensters voor waarschuwingen, bevestigingen en prompts in een Phlo-applicatie, waardoor gebruikersinteractie via modale dialoogvensters mogelijk is.
window.alert = app.mod.alert = msg => phlo.dialog('alert', msg)
window.confirm = msg => phlo.dialog('confirm', msg)
window.prompt = (msg, defaultValue) => phlo.dialog('prompt', msg, defaultValue)
phlo.dialog = async (type, message, defaultValue = '') => new Promise(resolve => {
app.mod.append('body', '<dialog id="phloDialog" class="phlo-dialog" role="dialog" aria-modal="true">\n<form method="dialog">\n<p class="phlo-dialog__message"></p>\n' + (type === 'prompt' ? '<input class="phlo-dialog__input" name="value">' : '') + '\n<menu class="phlo-dialog__actions">\n<button value="1" autofocus>OK</button>\n' + (type !== 'alert' ? '<button value="0">Cancel</button>' : '') + '\n</menu>\n</form>\n</dialog>')
const dialog = obj('#phloDialog')
const messageEl = dialog.querySelector('.phlo-dialog__message')
messageEl && (messageEl.textContent = String(message ?? ''))
if (type === 'prompt') dialog.querySelector('input').value = String(defaultValue ?? '')
dialog.showModal()
dialog.addEventListener('close', () => {
const value = dialog.returnValue
const input = dialog.querySelector('input')
dialog.remove()
if (type === 'alert') return resolve()
if (type === 'confirm') return resolve(value === '1')
if (type === 'prompt') return resolve(value === '1' ? input.value : null)
})
})
on('click', '[data-confirm]', async (el, e) => {
e.preventDefault()
if (!await window.confirm(el.dataset.confirm)) return
delete el.dataset.confirm
app.update()
el.click()
})object
%exists
/phlo/resources/DOM/exists.phlo
view
script
line 10
Deze functie houdt elementen en hun bijbehorende callbacks bij en voert de callbacks uit voor elementen die bestaan maar nog niet eerder zijn geregistreerd.
phlo.exist = []
phlo.existing = new WeakMap
const onExist = (els, cb) => phlo.exist.push({els, cb})
app.updates.push(() => {
const existing = []
phlo.exist.forEach(item => objects(item.els).forEach(el => phlo.existing.has(el) || existing.push({el, cb: item.cb})))
existing.forEach(item => [phlo.existing.has(item.el) || phlo.existing.set(item.el, 'exist'), item.cb(item.el)])
})object
%ffmpeg
/phlo/resources/DOM/ffmpeg.phlo
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
Behandelt invoergebeurtenissen voor formelementen, werkt hun attributen bij op basis van gebruikersinteractie en dient het formulier asynchroon in met de opgegeven methode.
on('input change', 'input, select, textarea', input => {
if (input.tagName === 'SELECT') input.querySelectorAll('option').forEach((option, index) => option.selected ? option.setAttribute('selected', '') : option.removeAttribute('selected'))
if (input.type === 'checkbox') input.checked ? input.setAttribute('checked', '') : input.removeAttribute('checked')
if (input.type === 'text' && input.value !== input.getAttribute('value')) input.setAttribute('value', input.value)
if (input.type === 'textarea' && input.value !== input.innerHTML) input.innerHTML = input.value
phlo.state.replace()
return false
})
on('submit', 'form.async', (form, e) => [e.preventDefault(), app[(form.attributes.method?.value ?? 'GET').toLowerCase()](new URL(form.action).pathname.substr(1), new FormData(form))])object
%image_resizer
/phlo/resources/DOM/image.resizer.phlo
view
script
line 9
Verkleint een afbeeldingsbestand tot opgegeven maximale afmetingen terwijl de beeldverhouding behouden blijft, en retourneert de verkleinde afbeelding als een data-URL via een callbackfunctie.
const imageResizer = (file, maxWidth, maxHeight, cb, quality = .8) => {
const img = new Image
img.onload = () => {
let width = img.width, height = img.height
const aspectRatio = width / height
if (width > maxWidth || height > maxHeight){
if (width > height){
width = maxWidth
height = Math.round(maxWidth / aspectRatio)
}
else {
height = maxHeight
width = Math.round(maxHeight * aspectRatio)
}
}
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height
canvas.getContext('2d').drawImage(img, 0, 0, width, height)
cb(canvas.toDataURL(file.type, quality))
}
img.src = URL.createObjectURL(file)
}object
%link
/phlo/resources/DOM/link.phlo
view
script
line 10
Deze functie behandelt klikgebeurtenissen op anker-tags, voorkomt standaardgedrag onder bepaalde voorwaarden en beheert asynchrone navigatie of hash-wijzigingen in de URL.
on('click', 'a', (a, e) => {
if (e.ctrlKey || e.shiftKey || e.metaKey || a.dataset.confirm) return false
const isAsync = a.classList.contains('async')
const [uri, hash] = a.getAttribute('href').split('#')
if (isAsync || hash) e.preventDefault()
phlo.anchor = hash ? `#${hash}` : ''
if (hash && (!uri || uri === location.pathname + location.search)) location.hash = phlo.anchor
else if (isAsync) app.get(uri.substr(1))
})object
%markdown
/phlo/resources/DOM/markdown.phlo
view
script
line 9
Parset Markdown-tekst naar HTML, met ondersteuning voor verschillende functies zoals GitHub Flavored Markdown, blokelementen, inline-elementen en referentielinks.
function parse_markdown(md, opts = {}){
const o = {
gfm: opts.gfm !== false,
breaks: !!opts.breaks,
headerIds: opts.headerIds !== false,
headerPrefix: opts.headerPrefix || '',
smartypants: !!opts.smartypants
}
const unnull = x => (x == null ? '' : String(x))
let src = unnull(md).replace(/\r\n?/g, "\n")
const escHtml = s => s.replace(/[&<>"]/g, c => ({'&':'&','<':'<','>':'>','"':'"'}[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
Definieert afkortingsfuncties voor veelvoorkomende gebeurtenisluisteraars: onChange, onClick en onInput, die het proces van het koppelen van gebeurtenisbehandelaars aan elementen vereenvoudigen.
function onChange(els, cb){ on('change', els, cb) }
function onClick(els, cb){ on('click', els, cb) }
function onInput(els, cb){ on('input', els, cb) }object
%store
/phlo/resources/DOM/store.phlo
view
script
line 10
Beheert een reactieve statusopslag in Phlo, waarmee de opslag, het ophalen en de notificatie van wijzigingen in statuswaarden mogelijk is. Het ondersteunt berekende waarden, afhankelijkheidstracering en DOM-binding voor dynamische updates.
phlo.store = {
signals: {},
listeners: {},
calcs: {},
calcDeps: {},
calcVals: {},
calcTick: false,
split: path => path.replace(/\]/g, '').split(/\.|\[/),
get(path){
if (!path) return undefined
let ctx = phlo.store.signals
const keys = phlo.store.split(path)
for (let i = 0; i < keys.length; i++){
if (ctx == null) return undefined
ctx = ctx[keys[i]]
}
return ctx
},
setPath(path, value){
let keys = phlo.store.split(path)
let ctx = phlo.store.signals
while (keys.length > 1){
const k = keys.shift()
ctx[k] ??= isNaN(keys[0]) ? {} : []
ctx = ctx[k]
}
const k = keys[0]
const old = ctx[k]
if (old === value) return false
ctx[k] = value
return true
},
set(path, value){
if (!phlo.store.setPath(path, value)) return
phlo.store.notify(path, phlo.store.get(path))
phlo.store.recalc(path)
phlo.store.schedule()
},
on(path, cb){ (phlo.store.listeners[path] ??= new Set).add(cb) },
off(path, cb){ phlo.store.listeners[path] && phlo.store.listeners[path].delete(cb) },
reset(){
phlo.store.signals = {}
phlo.store.listeners = {}
phlo.store.calcs = {}
phlo.store.calcDeps = {}
phlo.store.calcVals = {}
phlo.store.calcTick = false
},
signal(path, initial){
if (phlo.store.get(path) === undefined) phlo.store.set(path, initial)
return { subscribe: cb => phlo.store.on(path, cb), unsubscribe: cb => phlo.store.off(path, cb) }
},
notify(path, val){
const set = phlo.store.listeners[path]
if (set) set.forEach(cb => cb(val))
},
match(dep, changed){
if (!dep) return false
if (dep === changed) return true
return changed.startsWith(dep + '.') || changed.startsWith(dep + '[') || dep.startsWith(changed + '.') || dep.startsWith(changed + '[')
},
depsReady(list){
const arr = Array.isArray(list) ? list : (list ? [list] : [])
return arr.every(d => phlo.store.get(d) !== undefined)
},
evalCalc(name){
const fn = phlo.store.calcs[name]
if (!fn) return
let deps = []
let val
try {
const out = fn()
if (Array.isArray(out) && out.length === 2) deps = out[0], val = out[1]
else val = out
}
catch(e){
deps = []
val = undefined
}
const list = Array.isArray(deps) ? deps : (deps ? [deps] : [])
phlo.store.calcDeps[name] = list
if (!phlo.store.depsReady(list)) return
const old = phlo.store.calcVals[name]
if (old !== val){
phlo.store.calcVals[name] = val
const p = `calc.${name}`
phlo.store.setPath(p, val)
phlo.store.notify(p, val)
}
},
recalc(changed){
const names = Object.keys(phlo.store.calcs)
for (let i = 0; i < names.length; i++){
const name = names[i]
const deps = phlo.store.calcDeps[name] || []
for (let j = 0; j < deps.length; j++){
if (phlo.store.match(deps[j], changed)){
phlo.store.evalCalc(name)
break
}
}
}
},
recalcAll(){
const names = Object.keys(phlo.store.calcs)
for (let i = 0; i < names.length; i++) phlo.store.evalCalc(names[i])
},
schedule(){
if (phlo.store.calcTick) return
phlo.store.calcTick = true
setTimeout(() => {
phlo.store.calcTick = false
phlo.store.recalcAll()
})
},
proxy(base){
return new Proxy({}, {
get(t, k){
if (typeof k === 'symbol') return undefined
const seg = /^\d+$/.test(k) ? `[${k}]` : (base ? `.${k}` : String(k))
const path = base + seg
const v = phlo.store.get(path)
if (v !== undefined && (typeof v !== 'object' || v === null)) return v
return phlo.store.proxy(path)
},
set(t, k, v){
const seg = /^\d+$/.test(k) ? `[${k}]` : (base ? `.${k}` : String(k))
phlo.store.set(base + seg, v)
return true
},
has(t, k){ return phlo.store.get(base + (base ? '.' : '') + String(k)) !== undefined },
ownKeys(){ return Object.keys(phlo.store.get(base) || {}) },
getOwnPropertyDescriptor(){ return { enumerable: true, configurable: true } }
})
}
}
app.store = phlo.store.proxy('')
app.mod.store = (key, value) => {
const cur = phlo.store.get(key)
if (JSON.stringify(cur) === JSON.stringify(value)) return
const walk = (base, obj) => {
if (typeof obj !== 'object' || obj === null) return phlo.store.set(base, obj)
Object.entries(obj).forEach(([k, v]) => walk(isNaN(k) ? `${base}.${k}` : `${base}[${k}]`, v))
}
walk(key, value)
}
phlo.calc = new Proxy({}, {
set(t, k, fn){
if (typeof fn !== 'function') return false
phlo.store.calcs[k] = fn
phlo.store.evalCalc(k)
setTimeout(() => phlo.store.evalCalc(k))
return true
},
get(t, k){ return phlo.store.calcs[k] },
has(t, k){ return k in phlo.store.calcs },
deleteProperty(t, k){
delete phlo.store.calcs[k]
delete phlo.store.calcDeps[k]
delete phlo.store.calcVals[k]
return true
}
})
app.calc = new Proxy({}, {
get(t, k){ return phlo.store.calcVals[k] },
has(t, k){ return k in phlo.store.calcVals },
ownKeys(){ return Object.keys(phlo.store.calcVals) },
getOwnPropertyDescriptor(){ return { enumerable: true, configurable: true } }
})
onExist('[data-bind]', el => {
const key = el.dataset.bind
const isCalc = key.startsWith('calc.')
const isInput = el.tagName === 'INPUT' || el.tagName === 'SELECT' || el.tagName === 'TEXTAREA'
const fromDom = isInput ? el.value : el.textContent
const fromStore = phlo.store.get(key)
const domNonEmpty = (fromDom ?? '').trim() !== ''
const storeEmpty = fromStore === undefined || (typeof fromStore === 'string' && fromStore.trim() === '')
const domLeads = !isCalc && domNonEmpty && storeEmpty
const S = v => v == null ? '' : (typeof v === 'object' ? '' : String(v))
const apply = v => {
const s = S(v)
if (isInput) el.value = s
else el.textContent = s
}
phlo.store.on(key, apply)
if (domLeads) phlo.store.set(key, fromDom)
const initial = domLeads ? fromDom : fromStore
apply(initial)
if (!isCalc && isInput) el.oninput = e => phlo.store.set(key, e.target.value)
})
onExist('[data-bind-attr]', el => {
const spec = el.getAttribute('data-bind-attr')
if (!spec) return
const BOOL = new Set(['disabled','checked','hidden','required','readonly','selected','autofocus','multiple'])
let meta = phlo.existing.get(el)
if (!meta || typeof meta !== 'object'){
meta = { exist: true }
phlo.existing.set(el, meta)
}
meta.attr || (meta.attr = {})
meta.attr.cls || (meta.attr.cls = [])
spec.split(/\s*,\s*/).filter(Boolean).forEach(pair => {
const m = pair.match(/^\s*([^:]+)\s*:\s*(.+)\s*$/)
if (!m) return
const name = m[1]
const path = m[2]
const isCalc = path.startsWith('calc.')
const isInputVal = name === 'value' && (el.tagName === 'INPUT' || el.tagName === 'SELECT' || el.tagName === 'TEXTAREA')
const domVal =
name === 'text' ? el.textContent :
name === 'html' ? el.innerHTML :
name === 'value' ? el.value :
(name === 'class' ? null : el.getAttribute(name))
const fromStore = phlo.store.get(path)
const domNonEmpty = (domVal ?? '').trim() !== ''
const storeEmpty = fromStore === undefined || (typeof fromStore === 'string' && fromStore.trim() === '')
const domLeads = !isCalc && name !== 'class' && domNonEmpty && storeEmpty
const S = v => v == null ? '' : (typeof v === 'object' ? '' : String(v))
const apply = v => {
if (name === 'text') el.textContent = S(v)
else if (name === 'html') app.mod.inner(el, S(v))
else if (name === 'value') app.mod.value(el, S(v))
else if (name === 'class'){
const next = Array.isArray(v) ? v : (v && typeof v === 'object') ? Object.keys(v).filter(k => v[k]) : String(v ?? '').split(/\s+/)
const uniq = [...new Set(next.filter(Boolean))]
const prev = meta.attr.cls
for (let i = 0; i < prev.length; i++) el.classList.remove(prev[i])
for (let i = 0; i < uniq.length; i++) el.classList.add(uniq[i])
meta.attr.cls = uniq
}
else if (BOOL.has(name)){
const on = !!v
app.mod.attr(el, { [name]: on ? '' : null })
if (name in el) el[name] = on
}
else app.mod.attr(el, { [name]: S(v) })
}
phlo.store.on(path, apply)
if (domLeads) phlo.store.set(path, domVal)
const initial = domLeads ? domVal : fromStore
apply(initial)
if (!isCalc && isInputVal) el.oninput = e => phlo.store.set(path, e.target.value)
})
})object
%template
/phlo/resources/DOM/template.phlo
view
script
line 10
Definieert een functie die een opgegeven sjabloon toepast op elke rij gegevens, waarbij het sjabloon wordt aangeroepen met de waarden uit de rij.
app.mod.template = (template, rows) => rows.forEach(row => templates[template](...Object.values(row)))
const templates = {}object
%timestamps
/phlo/resources/DOM/timestamps.phlo
view
script
line 10
Werk de tekstinhoud van elementen met een 'data-ts'-attribuut bij om de verstreken tijd sinds een tijdstempel in een leesbaar formaat weer te geven, elke seconde vernieuwend.
app.tsBase = {seconds: 60, minutes: 60, hours: 24, days: 7, weeks: 4, months: 13, years: 1}
const tsUpdate = () => (ranges = app.tsLabels && (tsValues = Object.values(app.tsBase)) ? Object.fromEntries(app.tsLabels.map((k, i) => [k, tsValues[i]])) : app.tsBase) && objects('[data-ts]').forEach(el => {
let age = Math.round(Date.now() / 1000) - Number(el.dataset.ts), text = ''
const future = age < 0
if (future) age = -age
for (const [range, multiplier] of Object.entries(ranges)){
if (text) continue
if (age / multiplier < 1.6583) text = `${Math.round(age)} ${range}`
age /= multiplier
}
text ||= `${Math.round(age)} ${Object.keys(ranges).at(-1)}`
text = `${future ? '-' : ''}${text}`
el.innerText === text || (el.innerText = text)
})
setInterval(() => document.hidden || tsUpdate(), 1000)
setTimeout(tsUpdate, 1)object
%toasts
/phlo/resources/DOM/toasts.phlo
view
script
line 10
Maakt een toastmelding die een bericht gedurende korte tijd weergeeft en kan worden gesloten door erop te klikken.
app.mod.toast = msg => {
obj('#toasts') || app.mod.append('body', '<div id="toasts"></div>')
const toast = document.createElement('div')
toast.innerHTML = msg
toast.onclick = () => toast.remove()
obj('#toasts').insertAdjacentElement('beforeend', toast)
setTimeout(() => toast.remove(), 4000)
}view
style
line 21
Definieert de CSS-stijlen voor toastmeldingen, die vast in de rechterbovenhoek worden gepositioneerd met specifieke achtergrondkleur, randradius en tekststijl.
#toasts {
position: fixed
right: 10px
top: 5px
z-index: 1001
> * {
background-color: #000A
border-radius: 10px
clear: both
color: white
cursor: zoom-out
float: right
margin-top: 5px
padding: 3px 6px
}
}object
%visible
/phlo/resources/DOM/visible.phlo
view
script
line 10
Stelt een IntersectionObserver in om callback-functies uit te voeren wanneer opgegeven elementen zichtbaar of verborgen worden in het viewport.
phlo.observe = []
phlo.observing = new WeakMap
const onVisible = (els, cbIn, cbOut) => onVisibleIn(els, null, cbIn, cbOut)
const onVisibleIn = (els, root, cbIn, cbOut) => phlo.observe.push({els, root, cbIn, cbOut})
app.updates.push(() => {
const observers = []
phlo.observe.forEach(item => objects(item.els).forEach(el => phlo.observing.has(el) || observers.push({el, root: item.root, cbIn: item.cbIn, cbOut: item.cbOut})))
observers.forEach(item => [phlo.observing.has(item.el) || phlo.observing.set(item.el, 'observe'), (observer = new IntersectionObserver(entries => entries.forEach(entry => entry.isIntersecting ? !item.cbIn && item.cbOut ? [observer.unobserve(entry.target), item.cbOut(entry.target)] : item.cbIn(entry.target) : item.cbIn && item.cbOut && item.cbOut(entry.target)), {root: obj(item.root), threshold: .1})).observe(item.el)])
})object
%websocket
/phlo/resources/DOM/websocket.phlo
view
script
line 10
Stelt een WebSocket-verbinding in met het opgegeven pad en beheert de levenscyclus, inclusief het afhandelen van berichten, fouten en herverbindingen.
app.websocket = {
get open(){
app.options.contains('wss') && delay('websocket', app.websocket.retry, () => {
phlo.wss?.close()
phlo.wss = new WebSocket(`wss://${location.host}/${app.websocket.path}`)
phlo.wss.onmessage = e => [{trans, state, ...cmds} = JSON.parse(e.data), apply(cmds, trans, state)]
phlo.wss.onopen = e => [(cb = app.websocket.connect) && cb(e), phlo.log('🖧 Websocket connected', e), app.websocket.retry = 333]
phlo.wss.onerror = e => [(cb = app.websocket.error) && cb(e), phlo.log('🖧 Websocket error', e)]
phlo.wss.onclose = e => [(cb = app.websocket.close) && cb(e), phlo.log('🖧 Websocket close', e), app.websocket.retry && [app.websocket.open, app.websocket.retry *= 3]]
})
},
path: 'websocket',
send: data => phlo.wss?.readyState === 1 ? [phlo.log('🖧 app.websocket.send', '\n', data), phlo.wss.send(JSON.stringify(data))] : phlo.error('🖧 Could not send websocket data over closed socket'),
retry: 333,
}
app.websocket.open