DOM
object
%charts
/phlo/resources/DOM/charts.phlo
static
charts :: spark ($values, $color = '#888', $w = 240, $h = 48, $label = null)
line 9
根据提供的值生成SVG火花线图,计算渲染所需的点和区域。
$values = array_values(array_map('floatval', (array)$values))
if (!$values) return void
$max = max($values) ?: 1
$min = min($values)
$range = max(0.0001, $max - $min)
$count = count($values)
$step = $count > 1 ? $w / ($count - 1) : 0
$points = []
foreach ($values AS $i => $v){
$x = round($i * $step, 1)
$y = round($h - (($v - $min) / $range) * ($h - 6) - 3, 1)
$points[] = "$x,$y"
}
$area = array_merge($points, [round($w, 1).comma.$h, '0,'.$h])
$id = 'g'.substr(md5(implode(comma, $points)), 0, 6)
$aria = $label !== null ? ' role="img" aria-label="'.esc($label).'"' : void
return '<svg viewBox="0 0 '.$w.' '.$h.'"'.$aria.' xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="none" style="display:block;width:100%;height:'.$h.'px"><defs><linearGradient id="'.$id.'" x1="0" x2="0" y1="0" y2="1"><stop offset="0%" stop-color="'.$color.'" stop-opacity=".4"/><stop offset="100%" stop-color="'.$color.'" stop-opacity="0"/></linearGradient></defs><polygon points="'.implode(space, $area).'" fill="url(#'.$id.')"/><polyline points="'.implode(space, $points).'" fill="none" stroke="'.$color.'" stroke-width="1.6" stroke-linejoin="round" stroke-linecap="round"/></svg>'static
charts :: bars ($values, $color = '#888', $w = 240, $h = 48, $label = null)
line 29
根据提供的值生成SVG条形图,计算每个条形相对于最大值的高度和位置。
$values = array_values(array_map('floatval', (array)$values))
if (!$values) return void
$max = max($values) ?: 1
$count = count($values)
$gap = 2
$bw = max(1, ($w - ($count - 1) * $gap) / $count)
$out = void
foreach ($values AS $i => $v){
$bh = round(($v / $max) * ($h - 4), 1)
$x = round($i * ($bw + $gap), 1)
$y = round($h - $bh, 1)
$out .= '<rect x="'.$x.'" y="'.$y.'" width="'.round($bw, 1).'" height="'.max(0.5, $bh).'" fill="'.$color.'" rx="1.5"/>'
}
$aria = $label !== null ? ' role="img" aria-label="'.esc($label).'"' : void
return '<svg viewBox="0 0 '.$w.' '.$h.'"'.$aria.' xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="none" style="display:block;width:100%;height:'.$h.'px">'.$out.'</svg>'static
charts :: donut ($parts, $colors = null, $size = 120)
line 47
根据提供的部分生成SVG甜甜圈图,计算每个部分的比例和颜色。
$parts = array_filter(array_map('floatval', (array)$parts))
$total = array_sum($parts)
if (!$total) return void
$colors ??= ['#FFC76B', '#6CC0FF', '#9D7BFF', '#6BE39E', '#FF8A5C', '#FF6B6B']
$cx = $size / 2
$cy = $size / 2
$r = $size * 0.4
$circumference = 2 * M_PI * $r
$out = void
$rotate = -90
$i = 0
foreach ($parts AS $value){
$len = ($value / $total) * $circumference
$color = $colors[$i % count($colors)]
$out .= '<circle cx="'.$cx.'" cy="'.$cy.'" r="'.$r.'" fill="none" stroke="'.$color.'" stroke-width="'.($size * 0.18).'" stroke-dasharray="'.$len.space.$circumference.'" transform="rotate('.$rotate.space.$cx.space.$cy.')"/>'
$rotate += ($value / $total) * 360
$i++
}
return '<svg viewBox="0 0 '.$size.' '.$size.'" xmlns="http://www.w3.org/2000/svg" style="display:block;width:'.$size.'px;height:'.$size.'px">'.$out.'<circle cx="'.$cx.'" cy="'.$cy.'" r="'.($r - $size * 0.09).'" fill="rgba(255,255,255,.04)"/></svg>'object
%cookiewall
/phlo/resources/DOM/cookiewall.phlo
static
cookiewall :: __handle
line 10
此方法处理 cookie wall 功能,管理用户对 cookies 的同意。
nullprop
%cookiewall -> choice
line 12
从 %cookies 对象中获取 'cookieChoice' 的值,如果未设置则返回 null。
%cookies->cookieChoice ?? nullmethod
%cookiewall -> hasChosen
line 13
通过验证选择不为 null 来检查是否已做出选择。
$this->choice !== nullmethod
%cookiewall -> canTrack
line 14
根据用户的选择确定是否允许跟踪,特别是如果设置为'all'。
$this->choice === 'all'method
%cookiewall -> canAnalytics
line 15
检查当前选择是否设置为'all'以确定是否可以启用分析。
$this->choice === 'all'prop
%cookiewall -> translate
line 17
检查当前作用域中是否存在函数'en'。
function_exists('en')prop
%cookiewall -> labels
line 18
定义了一组用于cookie同意墙的标签,包括区域标题、主体文本和按钮标签。
arr(
region: 'Cookie choice',
body: 'We use essential cookies to make this site work. With your permission we also use analytics to improve the site.',
essential: 'Essential only',
accept: 'Accept',
)method
%cookiewall -> label ($key)
line 24
检索与指定键关联的标签,如果启用了翻译,则进行翻译。
$this->translate ? en($this->labels[$key]) : $this->labels[$key]route
route async POST cookiewall accept all
line 26
定义一个路由,设置一个cookie,表示用户已接受所有cookie,并异步移除cookiewall视图。
%cookies->objSet('cookieChoice', 'all', ['expires' => time() + 60 * 60 * 24 * 365, 'httponly' => false])
apply(remove: '#cookiewall')route
route async POST cookiewall accept essential
line 31
该路由设置一个名为'cookieChoice'的cookie,其值为'essential',有效期为一年,然后应用移除ID为'cookiewall'的元素。
%cookies->objSet('cookieChoice', 'essential', ['expires' => time() + 60 * 60 * 24 * 365, 'httponly' => false])
apply(remove: '#cookiewall')view
%cookiewall -> banner
line 36
如果用户尚未做出选择,则显示一个提示用户接受基本或所有cookie的cookie同意墙。
<if !$this->hasChosen()>
<div#cookiewall role=region aria-label="{{ $this->label('region') }}">
<p>{{ $this->label('body') }}</p>
<div.actions>
<form.async method=post action=/cookiewall/accept/essential>
<button.ghost type=submit>{{ $this->label('essential') }}</button>
</form>
<form.async method=post action=/cookiewall/accept/all>
<button.primary type=submit>{{ $this->label('accept') }}</button>
</form>
</div>
</div>
</if>view
style
line 51
定义cookiewall组件的CSS样式,包括布局、颜色和响应式设计。
#cookiewall {
background: #1a1a1a
border-radius: 8px
bottom: 16px
box-shadow: 0 8px 32px #0004
color: #fff
font-size: 13px
left: 16px
line-height: 1.5
max-width: 360px
padding: 14px 16px
position: fixed
right: 16px
z-index: 9999
@media(min-width: 600px): right: auto
p: margin: 0 0 10px
.actions {
display: flex
gap: 8px
justify-content: flex-end
}
button {
border-radius: 4px
border: 0
cursor: pointer
font-size: 12px
padding: 6px 12px
}
button.ghost {
background: #444
color: #fff
}
button.primary {
background: #fff
color: #1a1a1a
font-weight: 600
}
}object
%CSS_fixes
/phlo/resources/DOM/CSS.fixes.phlo
view
style
line 9
应用全局CSS样式,以确保各种HTML元素的一致盒模型、触摸操作、焦点轮廓和外观。
*, ::before, ::after: box-sizing: border-box
a, area, button, input, label, select, summary, textarea, [tabindex]: touch-action: manipulation
button:focus, input:focus, select:focus, textarea:focus, [contenteditable]:focus: outline: 0
input::-webkit-outer-spin-button, input::-webkit-inner-spin-button: -webkit-appearance: none
input[type="number"]: -moz-appearance: textfield
table: border-collapse: collapse
::-ms-expand: display: noneobject
%CSS_var
/phlo/resources/DOM/CSS.var.phlo
view
script
line 9
定义一个代理,用于访问和修改文档根元素上的 CSS 自定义属性(变量),允许动态更新和检索它们的值。
Object.defineProperty(app, 'var', {get(){return new Proxy({}, {get(_, key){return getComputedStyle(document.documentElement).getPropertyValue(`--${key}`).trim()}, set(_, key, value){ return document.documentElement.style.setProperty(`--${key}`, value)}})}, configurable: true})
app.mod.setvar = (key, value) => app.var[key] = valueobject
%datatags
/phlo/resources/DOM/datatags.phlo
view
script
line 10
处理带有数据属性的元素的点击事件,防止默认操作,并使用指定的路径和数据执行相应的方法。
on('click', '[data-get], [data-post], [data-put], [data-patch], [data-delete]', (el, e) => {
if (el.dataset.confirm) return
e.preventDefault()
let method, path, data = null
if ((path = el.dataset.get) !== undefined) method = 'get'
else if ((path = el.dataset.delete) !== undefined) method = 'delete'
else [data = {}, Object.keys(el.dataset).forEach(key => key === 'post' || key === 'put' || key === 'patch' ? [method = key, path = el.dataset[key]] : data[key] = el.dataset[key])]
app[method](path, data)
})object
%dialog
/phlo/resources/DOM/dialog.phlo
view
script
line 10
在Phlo应用程序中创建和管理警报、确认和提示对话框,通过模态对话框允许用户交互。
window.alert = app.mod.alert = msg => phlo.dialog('alert', msg)
window.confirm = msg => phlo.dialog('confirm', msg)
window.prompt = (msg, defaultValue) => phlo.dialog('prompt', msg, defaultValue)
phlo.dialog = async (type, message, defaultValue = '') => new Promise(resolve => {
app.mod.append('body', '<dialog id="phloDialog" class="phlo-dialog" role="dialog" aria-modal="true">\n<form method="dialog">\n<p class="phlo-dialog__message"></p>\n' + (type === 'prompt' ? '<input class="phlo-dialog__input" name="value">' : '') + '\n<menu class="phlo-dialog__actions">\n<button value="1" autofocus>OK</button>\n' + (type !== 'alert' ? '<button value="0">Cancel</button>' : '') + '\n</menu>\n</form>\n</dialog>')
const dialog = obj('#phloDialog')
const messageEl = dialog.querySelector('.phlo-dialog__message')
messageEl && (messageEl.textContent = String(message ?? ''))
if (type === 'prompt') dialog.querySelector('input').value = String(defaultValue ?? '')
dialog.showModal()
dialog.addEventListener('close', () => {
const value = dialog.returnValue
const input = dialog.querySelector('input')
dialog.remove()
if (type === 'alert') return resolve()
if (type === 'confirm') return resolve(value === '1')
if (type === 'prompt') return resolve(value === '1' ? input.value : null)
})
})
on('click', '[data-confirm]', async (el, e) => {
e.preventDefault()
if (!await window.confirm(el.dataset.confirm)) return
delete el.dataset.confirm
app.update()
el.click()
})object
%exists
/phlo/resources/DOM/exists.phlo
view
script
line 10
此函数跟踪元素及其关联的回调,对于存在但尚未注册的元素执行回调。
phlo.exist = []
phlo.existing = new WeakMap
const onExist = (els, cb) => phlo.exist.push({els, cb})
app.updates.push(() => {
const existing = []
phlo.exist.forEach(item => objects(item.els).forEach(el => phlo.existing.has(el) || existing.push({el, cb: item.cb})))
existing.forEach(item => [phlo.existing.has(item.el) || phlo.existing.set(item.el, 'exist'), item.cb(item.el)])
})object
%ffmpeg
/phlo/resources/DOM/ffmpeg.phlo
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
处理表单元素的输入事件,根据用户交互更新其属性,并使用指定的方法异步提交表单。
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
将图像文件调整为指定的最大尺寸,同时保持纵横比,并通过回调函数返回调整后的图像作为数据URL。
const imageResizer = (file, maxWidth, maxHeight, cb, quality = .8) => {
const img = new Image
img.onload = () => {
let width = img.width, height = img.height
const aspectRatio = width / height
if (width > maxWidth || height > maxHeight){
if (width > height){
width = maxWidth
height = Math.round(maxWidth / aspectRatio)
}
else {
height = maxHeight
width = Math.round(maxHeight * aspectRatio)
}
}
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height
canvas.getContext('2d').drawImage(img, 0, 0, width, height)
cb(canvas.toDataURL(file.type, quality))
}
img.src = URL.createObjectURL(file)
}object
%link
/phlo/resources/DOM/link.phlo
view
script
line 10
此函数处理锚标签的点击事件,在某些条件下防止默认行为,并管理URL中的异步导航或哈希更改。
on('click', 'a', (a, e) => {
if (e.ctrlKey || e.shiftKey || e.metaKey || a.dataset.confirm) return false
const isAsync = a.classList.contains('async')
const [uri, hash] = a.getAttribute('href').split('#')
if (isAsync || hash) e.preventDefault()
phlo.anchor = hash ? `#${hash}` : ''
if (hash && (!uri || uri === location.pathname + location.search)) location.hash = phlo.anchor
else if (isAsync) app.get(uri.substr(1))
})object
%markdown
/phlo/resources/DOM/markdown.phlo
view
script
line 9
将Markdown文本解析为HTML,支持多种功能,如GitHub风格的Markdown、块元素、内联元素和引用链接。
function parse_markdown(md, opts = {}){
const o = {
gfm: opts.gfm !== false,
breaks: !!opts.breaks,
headerIds: opts.headerIds !== false,
headerPrefix: opts.headerPrefix || '',
smartypants: !!opts.smartypants
}
const unnull = x => (x == null ? '' : String(x))
let src = unnull(md).replace(/\r\n?/g, "\n")
const escHtml = s => s.replace(/[&<>"]/g, c => ({'&':'&','<':'<','>':'>','"':'"'}[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
定义了常见事件监听器的简写函数:onChange、onClick 和 onInput,简化了将事件处理程序附加到元素的过程。
function onChange(els, cb){ on('change', els, cb) }
function onClick(els, cb){ on('click', els, cb) }
function onInput(els, cb){ on('input', els, cb) }object
%store
/phlo/resources/DOM/store.phlo
view
script
line 10
在Phlo中管理反应式状态存储,允许存储、检索和通知状态值的变化。它支持计算值、依赖跟踪和DOM绑定以实现动态更新。
phlo.store = {
signals: {},
listeners: {},
calcs: {},
calcDeps: {},
calcVals: {},
calcTick: false,
split: path => path.replace(/\]/g, '').split(/\.|\[/),
get(path){
if (!path) return undefined
let ctx = phlo.store.signals
const keys = phlo.store.split(path)
for (let i = 0; i < keys.length; i++){
if (ctx == null) return undefined
ctx = ctx[keys[i]]
}
return ctx
},
setPath(path, value){
let keys = phlo.store.split(path)
let ctx = phlo.store.signals
while (keys.length > 1){
const k = keys.shift()
ctx[k] ??= isNaN(keys[0]) ? {} : []
ctx = ctx[k]
}
const k = keys[0]
const old = ctx[k]
if (old === value) return false
ctx[k] = value
return true
},
set(path, value){
if (!phlo.store.setPath(path, value)) return
phlo.store.notify(path, phlo.store.get(path))
phlo.store.recalc(path)
phlo.store.schedule()
},
on(path, cb){ (phlo.store.listeners[path] ??= new Set).add(cb) },
off(path, cb){ phlo.store.listeners[path] && phlo.store.listeners[path].delete(cb) },
reset(){
phlo.store.signals = {}
phlo.store.listeners = {}
phlo.store.calcs = {}
phlo.store.calcDeps = {}
phlo.store.calcVals = {}
phlo.store.calcTick = false
},
signal(path, initial){
if (phlo.store.get(path) === undefined) phlo.store.set(path, initial)
return { subscribe: cb => phlo.store.on(path, cb), unsubscribe: cb => phlo.store.off(path, cb) }
},
notify(path, val){
const set = phlo.store.listeners[path]
if (set) set.forEach(cb => cb(val))
},
match(dep, changed){
if (!dep) return false
if (dep === changed) return true
return changed.startsWith(dep + '.') || changed.startsWith(dep + '[') || dep.startsWith(changed + '.') || dep.startsWith(changed + '[')
},
depsReady(list){
const arr = Array.isArray(list) ? list : (list ? [list] : [])
return arr.every(d => phlo.store.get(d) !== undefined)
},
evalCalc(name){
const fn = phlo.store.calcs[name]
if (!fn) return
let deps = []
let val
try {
const out = fn()
if (Array.isArray(out) && out.length === 2) deps = out[0], val = out[1]
else val = out
}
catch(e){
deps = []
val = undefined
}
const list = Array.isArray(deps) ? deps : (deps ? [deps] : [])
phlo.store.calcDeps[name] = list
if (!phlo.store.depsReady(list)) return
const old = phlo.store.calcVals[name]
if (old !== val){
phlo.store.calcVals[name] = val
const p = `calc.${name}`
phlo.store.setPath(p, val)
phlo.store.notify(p, val)
}
},
recalc(changed){
const names = Object.keys(phlo.store.calcs)
for (let i = 0; i < names.length; i++){
const name = names[i]
const deps = phlo.store.calcDeps[name] || []
for (let j = 0; j < deps.length; j++){
if (phlo.store.match(deps[j], changed)){
phlo.store.evalCalc(name)
break
}
}
}
},
recalcAll(){
const names = Object.keys(phlo.store.calcs)
for (let i = 0; i < names.length; i++) phlo.store.evalCalc(names[i])
},
schedule(){
if (phlo.store.calcTick) return
phlo.store.calcTick = true
setTimeout(() => {
phlo.store.calcTick = false
phlo.store.recalcAll()
})
},
proxy(base){
return new Proxy({}, {
get(t, k){
if (typeof k === 'symbol') return undefined
const seg = /^\d+$/.test(k) ? `[${k}]` : (base ? `.${k}` : String(k))
const path = base + seg
const v = phlo.store.get(path)
if (v !== undefined && (typeof v !== 'object' || v === null)) return v
return phlo.store.proxy(path)
},
set(t, k, v){
const seg = /^\d+$/.test(k) ? `[${k}]` : (base ? `.${k}` : String(k))
phlo.store.set(base + seg, v)
return true
},
has(t, k){ return phlo.store.get(base + (base ? '.' : '') + String(k)) !== undefined },
ownKeys(){ return Object.keys(phlo.store.get(base) || {}) },
getOwnPropertyDescriptor(){ return { enumerable: true, configurable: true } }
})
}
}
app.store = phlo.store.proxy('')
app.mod.store = (key, value) => {
const cur = phlo.store.get(key)
if (JSON.stringify(cur) === JSON.stringify(value)) return
const walk = (base, obj) => {
if (typeof obj !== 'object' || obj === null) return phlo.store.set(base, obj)
Object.entries(obj).forEach(([k, v]) => walk(isNaN(k) ? `${base}.${k}` : `${base}[${k}]`, v))
}
walk(key, value)
}
phlo.calc = new Proxy({}, {
set(t, k, fn){
if (typeof fn !== 'function') return false
phlo.store.calcs[k] = fn
phlo.store.evalCalc(k)
setTimeout(() => phlo.store.evalCalc(k))
return true
},
get(t, k){ return phlo.store.calcs[k] },
has(t, k){ return k in phlo.store.calcs },
deleteProperty(t, k){
delete phlo.store.calcs[k]
delete phlo.store.calcDeps[k]
delete phlo.store.calcVals[k]
return true
}
})
app.calc = new Proxy({}, {
get(t, k){ return phlo.store.calcVals[k] },
has(t, k){ return k in phlo.store.calcVals },
ownKeys(){ return Object.keys(phlo.store.calcVals) },
getOwnPropertyDescriptor(){ return { enumerable: true, configurable: true } }
})
onExist('[data-bind]', el => {
const key = el.dataset.bind
const isCalc = key.startsWith('calc.')
const isInput = el.tagName === 'INPUT' || el.tagName === 'SELECT' || el.tagName === 'TEXTAREA'
const fromDom = isInput ? el.value : el.textContent
const fromStore = phlo.store.get(key)
const domNonEmpty = (fromDom ?? '').trim() !== ''
const storeEmpty = fromStore === undefined || (typeof fromStore === 'string' && fromStore.trim() === '')
const domLeads = !isCalc && domNonEmpty && storeEmpty
const S = v => v == null ? '' : (typeof v === 'object' ? '' : String(v))
const apply = v => {
const s = S(v)
if (isInput) el.value = s
else el.textContent = s
}
phlo.store.on(key, apply)
if (domLeads) phlo.store.set(key, fromDom)
const initial = domLeads ? fromDom : fromStore
apply(initial)
if (!isCalc && isInput) el.oninput = e => phlo.store.set(key, e.target.value)
})
onExist('[data-bind-attr]', el => {
const spec = el.getAttribute('data-bind-attr')
if (!spec) return
const BOOL = new Set(['disabled','checked','hidden','required','readonly','selected','autofocus','multiple'])
let meta = phlo.existing.get(el)
if (!meta || typeof meta !== 'object'){
meta = { exist: true }
phlo.existing.set(el, meta)
}
meta.attr || (meta.attr = {})
meta.attr.cls || (meta.attr.cls = [])
spec.split(/\s*,\s*/).filter(Boolean).forEach(pair => {
const m = pair.match(/^\s*([^:]+)\s*:\s*(.+)\s*$/)
if (!m) return
const name = m[1]
const path = m[2]
const isCalc = path.startsWith('calc.')
const isInputVal = name === 'value' && (el.tagName === 'INPUT' || el.tagName === 'SELECT' || el.tagName === 'TEXTAREA')
const domVal =
name === 'text' ? el.textContent :
name === 'html' ? el.innerHTML :
name === 'value' ? el.value :
(name === 'class' ? null : el.getAttribute(name))
const fromStore = phlo.store.get(path)
const domNonEmpty = (domVal ?? '').trim() !== ''
const storeEmpty = fromStore === undefined || (typeof fromStore === 'string' && fromStore.trim() === '')
const domLeads = !isCalc && name !== 'class' && domNonEmpty && storeEmpty
const S = v => v == null ? '' : (typeof v === 'object' ? '' : String(v))
const apply = v => {
if (name === 'text') el.textContent = S(v)
else if (name === 'html') app.mod.inner(el, S(v))
else if (name === 'value') app.mod.value(el, S(v))
else if (name === 'class'){
const next = Array.isArray(v) ? v : (v && typeof v === 'object') ? Object.keys(v).filter(k => v[k]) : String(v ?? '').split(/\s+/)
const uniq = [...new Set(next.filter(Boolean))]
const prev = meta.attr.cls
for (let i = 0; i < prev.length; i++) el.classList.remove(prev[i])
for (let i = 0; i < uniq.length; i++) el.classList.add(uniq[i])
meta.attr.cls = uniq
}
else if (BOOL.has(name)){
const on = !!v
app.mod.attr(el, { [name]: on ? '' : null })
if (name in el) el[name] = on
}
else app.mod.attr(el, { [name]: S(v) })
}
phlo.store.on(path, apply)
if (domLeads) phlo.store.set(path, domVal)
const initial = domLeads ? domVal : fromStore
apply(initial)
if (!isCalc && isInputVal) el.oninput = e => phlo.store.set(path, e.target.value)
})
})object
%template
/phlo/resources/DOM/template.phlo
view
script
line 10
定义一个函数,将指定的模板应用于每一行数据,使用行中的值调用模板。
app.mod.template = (template, rows) => rows.forEach(row => templates[template](...Object.values(row)))
const templates = {}object
%timestamps
/phlo/resources/DOM/timestamps.phlo
view
script
line 10
更新具有 'data-ts' 属性的元素的文本内容,以人类可读的格式显示自时间戳以来经过的时间,每秒刷新一次。
app.tsBase = {seconds: 60, minutes: 60, hours: 24, days: 7, weeks: 4, months: 13, years: 1}
const tsUpdate = () => (ranges = app.tsLabels && (tsValues = Object.values(app.tsBase)) ? Object.fromEntries(app.tsLabels.map((k, i) => [k, tsValues[i]])) : app.tsBase) && objects('[data-ts]').forEach(el => {
let age = Math.round(Date.now() / 1000) - Number(el.dataset.ts), text = ''
const future = age < 0
if (future) age = -age
for (const [range, multiplier] of Object.entries(ranges)){
if (text) continue
if (age / multiplier < 1.6583) text = `${Math.round(age)} ${range}`
age /= multiplier
}
text ||= `${Math.round(age)} ${Object.keys(ranges).at(-1)}`
text = `${future ? '-' : ''}${text}`
el.innerText === text || (el.innerText = text)
})
setInterval(() => document.hidden || tsUpdate(), 1000)
setTimeout(tsUpdate, 1)object
%toasts
/phlo/resources/DOM/toasts.phlo
view
script
line 10
创建一个吐司通知,显示一条消息,持续短时间,并可以通过点击来关闭。
app.mod.toast = msg => {
obj('#toasts') || app.mod.append('body', '<div id="toasts"></div>')
const toast = document.createElement('div')
toast.innerHTML = msg
toast.onclick = () => toast.remove()
obj('#toasts').insertAdjacentElement('beforeend', toast)
setTimeout(() => toast.remove(), 4000)
}view
style
line 21
定义了吐司通知的CSS样式,将其固定在右上角,并具有特定的背景颜色、边框半径和文本样式。
#toasts {
position: fixed
right: 10px
top: 5px
z-index: 1001
> * {
background-color: #000A
border-radius: 10px
clear: both
color: white
cursor: zoom-out
float: right
margin-top: 5px
padding: 3px 6px
}
}object
%visible
/phlo/resources/DOM/visible.phlo
view
script
line 10
设置一个IntersectionObserver,当指定元素在视口中变得可见或隐藏时执行回调函数。
phlo.observe = []
phlo.observing = new WeakMap
const onVisible = (els, cbIn, cbOut) => onVisibleIn(els, null, cbIn, cbOut)
const onVisibleIn = (els, root, cbIn, cbOut) => phlo.observe.push({els, root, cbIn, cbOut})
app.updates.push(() => {
const observers = []
phlo.observe.forEach(item => objects(item.els).forEach(el => phlo.observing.has(el) || observers.push({el, root: item.root, cbIn: item.cbIn, cbOut: item.cbOut})))
observers.forEach(item => [phlo.observing.has(item.el) || phlo.observing.set(item.el, 'observe'), (observer = new IntersectionObserver(entries => entries.forEach(entry => entry.isIntersecting ? !item.cbIn && item.cbOut ? [observer.unobserve(entry.target), item.cbOut(entry.target)] : item.cbIn(entry.target) : item.cbIn && item.cbOut && item.cbOut(entry.target)), {root: obj(item.root), threshold: .1})).observe(item.el)])
})object
%websocket
/phlo/resources/DOM/websocket.phlo
view
script
line 10
建立到指定路径的WebSocket连接,并管理其生命周期,包括处理消息、错误和重新连接。
app.websocket = {
get open(){
app.options.contains('wss') && delay('websocket', app.websocket.retry, () => {
phlo.wss?.close()
phlo.wss = new WebSocket(`wss://${location.host}/${app.websocket.path}`)
phlo.wss.onmessage = e => [{trans, state, ...cmds} = JSON.parse(e.data), apply(cmds, trans, state)]
phlo.wss.onopen = e => [(cb = app.websocket.connect) && cb(e), phlo.log('🖧 Websocket connected', e), app.websocket.retry = 333]
phlo.wss.onerror = e => [(cb = app.websocket.error) && cb(e), phlo.log('🖧 Websocket error', e)]
phlo.wss.onclose = e => [(cb = app.websocket.close) && cb(e), phlo.log('🖧 Websocket close', e), app.websocket.retry && [app.websocket.open, app.websocket.retry *= 3]]
})
},
path: 'websocket',
send: data => phlo.wss?.readyState === 1 ? [phlo.log('🖧 app.websocket.send', '\n', data), phlo.wss.send(JSON.stringify(data))] : phlo.error('🖧 Could not send websocket data over closed socket'),
retry: 333,
}
app.websocket.open