Loading files
<!doctype html>
<html lang="en">
<head><meta charset="utf-8">
<script src="/pf-console.js"></script>
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Patternflow - Patterns</title>
<style>
/* Dark instrument tokens — same names every console page uses (--cream is
"the page background" by history), night values. The marketing site stays
paper; the device is the thing glowing in a dark room. */
:root{--cream:#0C0B09;--ink:#EDE7DB;--muted:#8A8272;--faint:#5A5546;
--rule:#242118;--rule-soft:#1B1914;--led:#FF5C2E;--ok:#57B87F;--warn:#D9A03F;
--panel:#131110;
--sans:'Inter',ui-sans-serif,system-ui,sans-serif;
--mono:'JetBrains Mono',ui-monospace,SFMono-Regular,Menlo,monospace}
*{box-sizing:border-box}
body{margin:0;background:var(--cream);color:var(--ink);font-family:var(--sans);
line-height:1.5;-webkit-font-smoothing:antialiased}
.wrap{max-width:680px;margin:0 auto;padding:32px 20px 64px}
header{display:flex;align-items:center;gap:8px;padding-bottom:12px;
border-bottom:1px solid var(--rule)}
.dot{width:7px;height:7px;background:var(--led);border-radius:1px}
h1{font-size:15px;font-weight:600;letter-spacing:.01em;margin:0;flex:1}
.sub{font-family:var(--mono);font-size:11px;color:var(--faint)}
section{margin-top:28px}
h2{font-size:11px;font-weight:600;letter-spacing:.09em;text-transform:uppercase;
color:var(--muted);margin:0 0 10px}
.drop{display:block;border:1px dashed var(--rule);border-radius:3px;padding:26px 18px;text-align:center;
background:rgba(255,255,255,.02);transition:border-color .15s,background .15s;cursor:pointer}
.drop.over{border-color:var(--led);background:rgba(255,92,46,.06)}
.drop p{margin:0;font-size:13px;color:var(--muted)}
.drop b{color:var(--ink);font-weight:600}
.hint{font-family:var(--mono);font-size:11px;color:var(--faint);margin-top:6px}
input[type=file]{display:none}
ul{list-style:none;margin:0;padding:0;border-top:1px solid var(--rule-soft)}
li{display:flex;align-items:center;gap:10px;padding:9px 2px;
border-bottom:1px solid var(--rule-soft);font-size:13px}
li .n{font-family:var(--mono);font-size:11px;color:var(--faint);min-width:24px}
li .nm{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.tag{font-family:var(--mono);font-size:10px;letter-spacing:.06em;text-transform:uppercase;
padding:1px 6px;border:1px solid var(--rule);border-radius:2px;color:var(--muted)}
.tag.mod{border-color:var(--led);color:var(--led)}
li.on{background:rgba(255,92,46,.09)}
/* The name is the play control now; make that discoverable. */
li .nm{cursor:pointer}
li .nm:hover{color:var(--led)}
li.drag{opacity:.35}
li.over-row{border-top:2px solid var(--led)}
button.del{font:inherit;font-size:11px;color:var(--muted);background:none;
border:1px solid var(--rule);border-radius:2px;padding:2px 8px;cursor:pointer}
button.del:hover{border-color:var(--led);color:var(--led)}
button.del:disabled{opacity:.4;cursor:default;border-color:var(--rule);color:var(--muted)}
/* Install queue: one row per file, states colour-coded. The bar under an
uploading row is its own progress, not a global one. */
.queue{border-top:none;margin-top:12px;display:none}
.queue li{display:block;padding:6px 2px;border-bottom:1px dashed var(--rule-soft);
font-family:var(--mono);font-size:11.5px}
.qrow{display:flex;align-items:center;gap:10px}
.qn{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--muted)}
.qs{white-space:nowrap}
.qs.get,.qs.wait{color:var(--faint)}
.qs.up{color:var(--ink)}
.qs.retry{color:var(--warn)}
.qs.ok{color:var(--ok)}
.qs.fail{color:var(--led)}
.qbar{height:2px;background:var(--rule-soft);margin-top:5px}
.qbar i{display:block;height:100%;width:0;background:var(--led)}
.qerr{margin-top:3px;color:var(--led);font-size:10.5px;white-space:normal}
#msg{margin-top:12px;font-family:var(--mono);font-size:11px;min-height:16px}
#msg.err{color:var(--led)}
#msg.good{color:var(--ok)}
/* Clearing a big library is ONE request the device answers only once it has
finished, so there is no progress to report from inside it — a percentage
here would be invented. This is deliberately indeterminate: it says "still
working", which is the only true thing available, and it moves, which is
what stops several motionless seconds from reading as a hang. */
.sweep{height:2px;background:var(--rule-soft);margin-top:8px;overflow:hidden;display:none}
.sweep.on{display:block}
.sweep i{display:block;height:100%;width:34%;background:var(--led);
animation:sweep 1.1s ease-in-out infinite}
@keyframes sweep{from{transform:translateX(-110%)}to{transform:translateX(400%)}}
/* Rows on their way out, dimmed the instant you confirm rather than when the
reply lands — the click needs an answer now, not in five seconds. */
li.going{opacity:.3}
li.going button.del{visibility:hidden}
.actions{display:flex;gap:8px;margin-top:8px;align-items:center}
input.sel{width:14px;height:14px;accent-color:var(--led);margin:0}
#bulk{display:none}
.bulkTop{margin:0 0 10px}
.bulkNote{font-family:var(--mono);font-size:11px;color:var(--faint)}
/* First run. A partition that has never been formatted cannot hold anything,
and the only cure is a deliberate, destructive button — so that button used
to sit at the foot of the upload column, in the same grey as "Retry failed",
on the one visit where it is the only control that matters. On that state it
comes to the top and says what it is; the drop zone goes cold behind it,
because a drop there can only produce a queue of certain failures. */
.firstrun{display:none;margin-top:20px;padding:14px 16px;
border:1px solid var(--warn);border-radius:3px;background:rgba(217,160,63,.06)}
.firstrun.on{display:block}
.firstrun h2{color:var(--warn);margin:0 0 6px}
.firstrun p{margin:0;font-size:13px;color:var(--muted)}
.firstrun .actions{margin-top:11px}
button.del.prim{border-color:var(--warn);color:var(--warn)}
button.del.prim:hover{background:rgba(217,160,63,.12);
border-color:var(--warn);color:var(--warn)}
.drop.off{opacity:.4;cursor:not-allowed;border-style:solid}
footer{margin-top:36px;padding-top:12px;border-top:1px solid var(--rule);
font-family:var(--mono);font-size:11px;color:var(--faint)}
a{color:var(--muted)}
/* Desktop: upload pinned left, the library right — the whole page without
scrolling, instead of a phone column with acres beside it. */
@media(min-width:960px){
.wrap{max-width:1140px}
.cols{display:grid;grid-template-columns:430px minmax(0,1fr);gap:48px;align-items:start}
.colL{position:sticky;top:18px}
.colR section{margin-top:16px}}
</style></head><body><div class="wrap">
<header><span class="dot"></span><h1>Patterns</h1><span class="sub" id="fs">-</span></header>
<div class="firstrun" id="firstRun">
<h2>Storage needs formatting</h2>
<p>This board's pattern partition isn't readable — either it has never been
formatted, or its filesystem is damaged. Format it once, and patterns install
normally from then on.</p>
<div class="actions">
<button class="del prim" id="fmt">Format storage</button>
<span class="bulkNote" id="fmtNote"></span>
</div>
</div>
<div class="cols">
<div class="colL">
<section>
<h2>Upload</h2>
<label class="drop" id="drop">
<input type="file" id="file" accept=".pfm,.json,.pfs,.zip,application/zip" multiple>
<p>Drop <b>.pfm</b> files or a <b>.zip</b> pack here, or click to choose</p>
<p class="hint">modules + their .json sidecars, all at once · zip unpacks in the browser</p>
</label>
<ul class="queue" id="q"></ul>
<div id="msg"></div>
<div class="sweep" id="sweep"><i></i></div>
<div class="actions">
<button class="del" id="retry" style="display:none">Retry failed</button>
</div>
</section>
</div>
<div class="colR">
<section>
<h2>Installed</h2>
<div class="actions bulkTop" id="bulk">
<button class="del" id="selAll">Select all</button>
<button class="del" id="bulkZip">Download ZIP</button>
<button class="del" id="bulkDel">Delete selected</button>
<button class="del" id="saveOrd" style="display:none">Save order</button>
<span class="bulkNote" id="bulkN"></span>
</div>
<ul id="list"></ul>
</section>
</div>
</div>
<footer>Presets are built into the firmware and cannot be removed here.
<a href="/">Home</a></footer>
</div>
<script>
function $(i){return document.getElementById(i)}
var msg=$('msg'),listEl=$('list'),drop=$('drop'),file=$('file'),fs=$('fs'),
qEl=$('q'),retryBtn=$('retry'),bulk=$('bulk'),bulkDel=$('bulkDel'),bulkN=$('bulkN'),
selAll=$('selAll'),bulkZip=$('bulkZip'),firstRun=$('firstRun'),fmtNote=$('fmtNote');
// null until the first /api/patterns reply lands. Nothing may decide whether
// an install can proceed before then — least of all the community deep-link
// below, which fires on page load and is exactly what a first-install visitor
// arrives on.
var mounted=null,pendingSrc=null;
// Only modules get a checkbox — presets live in firmware.bin and are not
// deletable — so these are also the count of what bulk actions can touch.
function selBoxes(){return listEl.querySelectorAll('input.sel')}
function selectedSlugs(){
var out=[];
listEl.querySelectorAll('input.sel:checked').forEach(function(c){out.push(c.dataset.slug)});
return out;
}
// The bar is visible whenever there is anything selectable, not just once
// something is selected: "Select all" has to be reachable from zero, which
// is exactly the state you are in when a long list needs clearing out.
function updateBulk(){
var total=selBoxes().length;
var n=selectedSlugs().length;
bulk.style.display=total?'flex':'none';
bulkDel.textContent='Delete selected ('+n+')';
bulkDel.disabled=n===0;
bulkZip.textContent='Download ZIP ('+n+')';
bulkZip.disabled=n===0;
selAll.textContent=(n===total&&total>0)?'Clear selection':'Select all';
bulkN.textContent=total?(n?'presets cannot be deleted':total+' module'+(total===1?'':'s')):'';
}
selAll.onclick=function(){
var boxes=selBoxes();
var want=selectedSlugs().length!==boxes.length;
boxes.forEach(function(c){c.checked=want});
updateBulk();
};
// Director MQTT select marks modules here. Rev changes when a new list arrives.
var pendingRev=-1;
function applyPendingSlugs(slugs,rev,announce,force){
if(!force && rev===pendingRev)return;
var revChanged=rev!==pendingRev;
pendingRev=rev;
var set={};
(slugs||[]).forEach(function(s){set[String(s).toLowerCase()]=1});
var n=0;
selBoxes().forEach(function(c){
var on=!!set[String(c.dataset.slug||'').toLowerCase()];
c.checked=on;
if(on)n++;
});
updateBulk();
if(announce!==false && revChanged && (slugs||[]).length){
var missed=(slugs||[]).length-n;
var msg='The link marked '+n+' module'+(n===1?'':'s')+' for download';
if(missed>0)msg+=', '+missed+' not on this list';
say(msg,n?'good':'err');
}
}
function pollPending(){
fetch('/api/patterns/pending',{cache:'no-store'}).then(function(r){return r.json()}).then(function(d){
applyPendingSlugs(d.slugs||[], d.rev||0, true);
}).catch(function(){});
}
setInterval(pollPending,2500);
function say(t,cls){msg.textContent=t;msg.className=cls||''}
// A wait with a moving bar and a seconds count. Used for the requests the
// device answers only when it is done — the page cannot show real progress
// for those, but it can show that time is being counted rather than lost.
// The count starts at 2s so a fast reply never flashes a number.
var sweepEl=$('sweep'),busyTimer=null;
function busy(text){
var t0=Date.now();
sweepEl.classList.add('on');
var tick=function(){
var s=Math.round((Date.now()-t0)/1000);
say(s>=2?text+' — '+s+'s':text+'…');
};
tick();
if(busyTimer)clearInterval(busyTimer);
busyTimer=setInterval(tick,500);
}
function busyDone(){
if(busyTimer){clearInterval(busyTimer);busyTimer=null}
sweepEl.classList.remove('on');
}
// One place decides what an unusable volume looks like: the banner at the top,
// a dead drop zone, and a community link held rather than spent on uploads
// that cannot land. Formatting stays deliberate and destructive — the firmware
// never does it behind your back (pattern_registry.h says why) — so this only
// ever prompts.
function setMounted(m){
mounted=!!m;
firstRun.className=mounted?'firstrun':'firstrun on';
drop.className=mounted?'drop':'drop off';
file.disabled=!mounted;
fmtNote.textContent=(!mounted&&pendingSrc)?
'a pack is waiting — it installs as soon as this is done':'';
if(mounted&&pendingSrc){var s=pendingSrc;pendingSrc=null;runSrc(s)}
}
// A drop or a link while the volume is unusable would enqueue a batch that
// fails one file at a time and reads as "uploading is broken". Refuse it once,
// up front, and point at the one control that fixes it.
function storageReady(){
if(mounted)return true;
if(mounted===null){say('still checking the device — try that again in a moment','err');return false}
say('format storage first — the button is at the top of this page','err');
firstRun.scrollIntoView({block:'nearest'});
return false;
}
function load(){
return fetch('/api/patterns').then(function(r){return r.json()}).then(function(d){
fs.textContent=d.mounted?(Math.round(d.free/1024)+' KB free'):'storage not mounted';
setMounted(d.mounted);
listEl.innerHTML='';
d.patterns.slice().reverse().forEach(function(p){
var li=document.createElement('li');
if(p.index===d.active)li.className='on';
var n=document.createElement('span');n.className='n';n.textContent=p.index+1;
var nm=document.createElement('span');nm.className='nm';nm.textContent=p.name;
nm.title='play';
nm.onclick=function(){play(p.index,p.name)};
var tg=document.createElement('span');
tg.className='tag'+(p.module?' mod':'');
tg.textContent=p.module?'module':'preset';
if(p.module){
var c=document.createElement('input');c.type='checkbox';c.className='sel';
c.dataset.slug=p.module;c.onchange=updateBulk;
li.appendChild(c);
li.draggable=true;
li.dataset.slug=p.module;
wireDrag(li);
}else{
var sp=document.createElement('span');sp.style.width='14px';li.appendChild(sp);
}
li.appendChild(n);li.appendChild(nm);li.appendChild(tg);
if(p.module){
var b=document.createElement('button');b.className='del';b.textContent='delete';
b.onclick=function(){del(p.module,p.name)};
li.appendChild(b);
}
listEl.appendChild(li);
});
applyPendingSlugs(d.pending||[], d.pendingRev||0, false, true);
updateBulk();
}).catch(function(){say('cannot reach device','err')});
}
// ── Play (click a name) ──────────────────────────────────────────
// Same endpoint the community's one-click install uses. The device queues
// the switch and the loop performs it, so this returns before the module
// has even been read — the highlight moves optimistically and load()
// verifies shortly after.
function play(index,name){
say('switching to '+name+'…');
fetch('/api/patterns/select?index='+index,{cache:'no-store'})
.then(function(r){return r.json()})
.then(function(d){
if(!d.ok){say(d.error||'switch failed','err');return}
say('now playing: '+d.name,'good');
listEl.querySelectorAll('li').forEach(function(el){el.classList.remove('on')});
var rows=listEl.querySelectorAll('li .n');
rows.forEach(function(el){
if(parseInt(el.textContent,10)===index+1)el.parentElement.classList.add('on');
});
}).catch(function(){say('no reply from device','err')});
}
// ── Arrange (drag rows, then Save order) ─────────────────────────
// Dragging is DISPLAY order; the device's order is the reverse (the list
// shows the last pattern first, because "one back-turn from Origin" is the
// closest slot to hand). saveOrder() un-reverses before writing, so
// catalog.txt reads top of the file = pattern 2, exactly what
// pattern_registry.h expects. The file travels through the ordinary upload
// path with X-PF-Name: catalog.txt — no dedicated endpoint.
var dragging=null,orderDirty=false;
function wireDrag(li){
li.addEventListener('dragstart',function(){dragging=li;li.classList.add('drag')});
li.addEventListener('dragend',function(){
li.classList.remove('drag');
listEl.querySelectorAll('li').forEach(function(el){el.classList.remove('over-row')});
});
li.addEventListener('dragover',function(ev){
if(!dragging||dragging===li)return;
ev.preventDefault();
li.classList.add('over-row');
});
li.addEventListener('dragleave',function(){li.classList.remove('over-row')});
li.addEventListener('drop',function(ev){
ev.preventDefault();
li.classList.remove('over-row');
if(!dragging||dragging===li)return;
listEl.insertBefore(dragging,li);
orderDirty=true;
$('saveOrd').style.display='';
say('order changed — not saved yet','good');
});
}
$('saveOrd').onclick=function(){
var slugs=[];
listEl.querySelectorAll('li[data-slug]').forEach(function(el){slugs.push(el.dataset.slug)});
slugs.reverse(); // display order → device order (see note above)
var text='# Patternflow running order — first line is pattern 2\n'+slugs.join('\n')+'\n';
say('saving order…');
var xhr=new XMLHttpRequest();
xhr.open('PUT','/api/patterns');
xhr.setRequestHeader('X-PF-Name','catalog.txt');
xhr.setRequestHeader('X-PF-Last','1');
xhr.onload=function(){
var d=null;try{d=JSON.parse(xhr.responseText)}catch(e){}
if(d&&d.ok){orderDirty=false;$('saveOrd').style.display='none';
say('order saved','good');setTimeout(load,900)}
else say((d&&d.error)||'save failed','err');
};
xhr.onerror=function(){say('no reply from device','err')};
xhr.send(new Blob([text]));
};
// ── Bulk delete ──────────────────────────────────────────────────
// ONE request for the whole selection. This used to walk the per-slug
// DELETE with a breather between each, and every one of those calls made
// the device rescan FATFS and reload the running module — so clearing a
// library of fifty was a minute of watching the list redraw. The device
// now takes the list, removes the files in one pass, and rescans once.
//
// Sending "*" instead of the list is the whole library: the device clears
// whatever .pfm files are actually on disk, which also catches modules the
// loader rejected at boot and that therefore never appear in this list.
function deleteSlugs(slugs, label){
// The rows go dim on the click, not on the reply. Removing fifty modules
// takes the device a few seconds and it cannot say how far along it is, so
// the page answers the part it does know immediately: these are the ones
// going away.
var all=slugs.length===1&&slugs[0]==='*';
listEl.querySelectorAll('input.sel').forEach(function(c){
if(all||slugs.indexOf(c.dataset.slug)>=0){
var row=c.closest('li');
if(row)row.classList.add('going');
}
});
busy('deleting ' + label);
bulkDel.disabled = true;
fetch('/api/patterns/delete',{method:'POST',body:slugs.join('\n')})
.then(function(r){return r.json()})
.then(function(d){
busyDone();
bulkDel.disabled=false;
if(!d.ok){
// It stayed: undim, or the list looks emptier than the device is.
listEl.querySelectorAll('li.going').forEach(function(r){r.classList.remove('going')});
say(d.error||'delete failed','err');return;
}
var msg=d.removed+' module'+(d.removed===1?'':'s')+' removed';
if(d.missing)msg+=', '+d.missing+' not found';
say(msg,d.missing?'err':'good');
// The rescan is deferred on the device (see requestReload), so the
// list is only right a moment after the reply.
setTimeout(load,900);
})
.catch(function(){
busyDone();
listEl.querySelectorAll('li.going').forEach(function(r){r.classList.remove('going')});
bulkDel.disabled=false;say('no reply from device','err');
});
}
bulkDel.onclick=function(){
var slugs=selectedSlugs();
if(!slugs.length)return;
var total=selBoxes().length;
// Clearing everything is the common case behind a big selection, and the
// device can do it without being told fifty names.
if(slugs.length===total&&total>2){
if(!confirm('Delete all '+total+' modules? Origin stays.'))return;
deleteSlugs(['*'],'every module');
return;
}
if(!confirm('Delete '+slugs.length+' module'+(slugs.length===1?'':'s')+
' -- '+slugs.join(', ')+'?'))return;
deleteSlugs(slugs,slugs.length+' module'+(slugs.length===1?'':'s'));
};
function fetchPatternFile(slug,ext){
return fetch('/api/patterns/file?slug='+encodeURIComponent(slug)+'&ext='+ext,{cache:'no-store'})
.then(function(r){
if(r.status===404)return null;
if(!r.ok)throw new Error('could not read '+slug+'.'+ext);
return r.arrayBuffer().then(function(b){return new Uint8Array(b)});
});
}
// Sequential on purpose: this HTTP server is one-connection. Parallel
// fetches pile up the same way a live preview once locked the panel.
bulkZip.onclick=function(){
var slugs=selectedSlugs();
if(!slugs.length)return;
busy('packing zip');
bulkZip.disabled=true;
loadFflate().then(function(){
var files={};
var chain=Promise.resolve();
slugs.forEach(function(slug,i){
chain=chain.then(function(){
say('packing zip — '+(i+1)+'/'+slugs.length);
return fetchPatternFile(slug,'pfm').then(function(pfm){
if(!pfm)throw new Error(slug+'.pfm not on device');
files[slug+'.pfm']=pfm;
return fetchPatternFile(slug,'json');
}).then(function(js){
if(js)files[slug+'.json']=js;
});
});
});
return chain.then(function(){
if(!window.fflate||typeof fflate.zipSync!=='function')
throw new Error('zip library missing zipSync');
var zipped=fflate.zipSync(files,{level:6});
var blob=new Blob([zipped],{type:'application/zip'});
var a=document.createElement('a');
a.href=URL.createObjectURL(blob);
a.download='patternflow-pack.zip';
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(function(){URL.revokeObjectURL(a.href)},2000);
busyDone();
bulkZip.disabled=false;
updateBulk();
say('downloaded '+slugs.length+' module'+(slugs.length===1?'':'s'),'good');
});
}).catch(function(e){
busyDone();
bulkZip.disabled=false;
updateBulk();
say((e&&e.message)||'zip failed','err');
});
};
function del(slug,name){
if(!confirm('Delete "'+name+'"?'))return;
fetch('/api/patterns?slug='+encodeURIComponent(slug),{method:'DELETE'})
.then(function(r){return r.json()}).then(function(d){
say(d.ok?('deleted '+slug):(d.error||'delete failed'),d.ok?'':'err');
// The device rebuilds its list off the HTTP path ~150ms later.
setTimeout(load,700);
}).catch(function(){say('delete failed','err')});
}
// ── Install queue ────────────────────────────────────────────────
// items: {f:File|null, name, st:'get'|'wait'|'up'|'retry'|'ok'|'fail', pct, err}
var items=[];
// Whether this firmware has the Sequences store, so a .pfs in a pack can be
// refused with a reason instead of a lie. Without the show feature there is
// no /api/shows: the PUT gets the 404 page, JSON.parse fails, and the queue
// reports "no reply from device" after two pointless retries. Assume yes
// until told otherwise, so a probe that never lands changes nothing.
var hasShows=true;
fetch('/api/status',{cache:'no-store'}).then(function(r){return r.json()})
.then(function(d){hasShows=(d.caps||[]).indexOf('shows')>=0}).catch(function(){});
// Upload states only — deleting is one request now and reports as a single
// line, so it no longer borrows this queue.
var STATE_LABEL={get:'fetching…',wait:'waiting',up:'uploading',
ver:'verifying…',retry:'retrying…',ok:'✓ done',fail:'✗ failed'};
function renderQ(){
qEl.style.display=items.length?'block':'none';
qEl.innerHTML='';
items.forEach(function(it){
var li=document.createElement('li');
var row=document.createElement('div');row.className='qrow';
var nm=document.createElement('span');nm.className='qn';nm.textContent=it.name;
var st=document.createElement('span');
st.className='qs '+(it.st==='ver'?'up':it.st);
st.textContent=it.st==='up'?('uploading '+(it.pct||0)+'%'):STATE_LABEL[it.st];
row.appendChild(nm);row.appendChild(st);li.appendChild(row);
if(it.st==='up'){
var b=document.createElement('div');b.className='qbar';
var f=document.createElement('i');f.style.width=(it.pct||0)+'%';
b.appendChild(f);li.appendChild(b);
}
if(it.st==='fail'&&it.err){
var e=document.createElement('div');e.className='qerr';e.textContent=it.err;
li.appendChild(e);
}
qEl.appendChild(li);
});
}
function finishBatch(){
var ok=0,fail=0;
items.forEach(function(it){if(it.st==='ok')ok++;else if(it.st==='fail')fail++});
if(fail===0){say(ok+' file'+(ok===1?'':'s')+' installed','good')}
else{say(ok+' installed, '+fail+' failed','err')}
retryBtn.style.display=items.some(function(it){return it.st==='fail'&&it.f})?'':'none';
setTimeout(load,700);
}
// Sequential with a breather between files — this WebServer flakes under
// rapid back-to-back multipart POSTs (~1 in 12 dies even when healthy).
// Failures don't stop the batch: the row goes red with the reason and the
// rest carry on. A dead reply (device usually stored the file anyway) gets
// two retries; a real JSON rejection never retries.
function runQ(i){
while(i<items.length&&items[i].st!=='wait'&&items[i].st!=='retry')i++;
if(i>=items.length){finishBatch();return}
var it=items[i];
var isLast=true;
for(var j=i+1;j<items.length;j++)
if((items[j].st==='wait'||items[j].st==='retry')&&!/\.pfs$/i.test(items[j].name))isLast=false;
it.st='up';it.pct=0;renderQ();
var xhr=new XMLHttpRequest();
// Raw PUT, not multipart: the device WebServer's multipart parser is its
// flakiest path (multi-chunk bodies died with no reply often enough to make
// installs feel broken). A raw body sidesteps boundary parsing entirely.
// Filename and the batch flag travel as headers; last=1 triggers the
// device's single end-of-batch rescan.
//
// A pack may carry .pfs show tables beside its modules — those go to the
// Sequences store instead, and the rescan flag only counts the items bound
// for /api/patterns (a .pfs must not swallow the batch's one rescan).
var isPfs=/\.pfs$/i.test(it.name);
if(isPfs&&!hasShows){
it.st='fail';it.pct=0;
it.err='this firmware cannot play .pfs files — an edition on the shelf can';
renderQ();setTimeout(function(){runQ(i+1)},350);return;
}
xhr.open('PUT',isPfs?'/api/shows':'/api/patterns');
xhr.setRequestHeader('X-PF-Name',it.name);
if(!isPfs)xhr.setRequestHeader('X-PF-Last',isLast?'1':'0');
// Transfer shows 0-90%. "100% handed to the network" is not "installed" —
// a bar parked on 100 while the device was still writing (or failing) read
// as success. The last 10% is the device's own confirmation.
xhr.upload.onprogress=function(e){
if(!e.lengthComputable)return;
it.pct=Math.round(e.loaded/e.total*90);
if(e.loaded===e.total)it.st='ver';
renderQ();
};
var next=function(){setTimeout(function(){runQ(i+1)},350)};
var deadReply=function(){
it.tries=(it.tries||0)+1;
it.pct=0;
if(it.tries<=2){it.st='retry';renderQ();
setTimeout(function(){runQ(i)},700);return}
it.st='fail';it.err='no reply from device';renderQ();next();
};
xhr.onload=function(){
var d=null;try{d=JSON.parse(xhr.responseText)}catch(e){}
if(d&&d.ok){it.st='ok';it.pct=100;renderQ();next()}
else if(d&&d.error){it.st='fail';it.err=d.error;renderQ();next()}
else deadReply();
};
xhr.onerror=deadReply;
xhr.send(it.f);
}
// ── Pattern packs (.zip) ─────────────────────────────────────────
// A pack is an ordinary zip of .pfm + .json — what the community Deck
// export hands you. The device never sees the archive: it is expanded
// here and the members join the same queue a plain multi-select uses,
// so the upload path below stays untouched. fflate comes from the
// device (not a CDN) so this works on a LAN with no internet, and it is
// only fetched when a zip actually shows up.
var ZIP_MAX_BYTES=8*1024*1024;
function loadFflate(){
if(window.fflate)return Promise.resolve();
return new Promise(function(res,rej){
var s=document.createElement('script');
s.src='/patterns/fflate.js';
s.onload=function(){res()};
s.onerror=function(){rej(new Error('could not load the unzip library'))};
document.head.appendChild(s);
});
}
// Promise for a flat File[]: zip members expanded, loose files passed
// through. Folders collapse to basenames and dotfiles go away, which is
// what drops the __MACOSX/._* junk a Mac-made zip carries.
//
// Split on BOTH separators. The zip spec says entry names use "/", but
// .NET's CreateFromDirectory — what PowerShell's Compress-Archive is built
// on — writes "\" on Windows, and a pack made that way arrives with its
// whole path stuck to the filename: the junk filter stops matching, the
// duplicate check stops matching, and the queue shows "a\b\wave.pfm".
function expandFiles(fileList){
var list=Array.prototype.slice.call(fileList||[]);
// catalog.txt rides along: it is the pack's running order (deck order).
// .pfs is a Director show table — installed to /show alongside the pack.
// performance.json is that show's editable source (Director / site), not
// a module sidecar: the device has no use for it, so it stays behind.
var keep=function(n){
if(/^performance\.json$/i.test(n)||/\.perf\.json$/i.test(n))return false;
return /\.(pfm|json|pfs)$/i.test(n)||/^catalog\.txt$/i.test(n)
};
if(!list.some(function(f){return /\.zip$/i.test(f.name)}))
return Promise.resolve(list.filter(function(f){return keep(f.name)}));
say('unpacking zip…');
return loadFflate().then(function(){
var out=[],seen={},total=0;
var push=function(f){if(!seen[f.name]){seen[f.name]=1;out.push(f)}};
var chain=Promise.resolve();
list.forEach(function(f){
if(!/\.zip$/i.test(f.name)){if(keep(f.name))push(f);return}
chain=chain.then(function(){return f.arrayBuffer()}).then(function(buf){
var unzipped=fflate.unzipSync(new Uint8Array(buf));
Object.keys(unzipped).forEach(function(name){
var base=name.split(/[\\/]/).pop();
if(!base||base.charAt(0)==='.'||!keep(base))return;
total+=unzipped[name].length;
if(total>ZIP_MAX_BYTES)
throw new Error('zip holds more than '+(ZIP_MAX_BYTES>>20)+' MB of patterns');
push(new File([unzipped[name]],base));
});
});
});
return chain.then(function(){return out});
});
}
// Every entry point to the queue goes through here so a zip is unpacked
// exactly once, wherever it came from.
function pickFiles(fileList){
if(!storageReady())return;
expandFiles(fileList).then(function(files){
if(!files.length){say('nothing to install — no .pfm, .json or .pfs in that drop','err');return}
startBatch(files);
}).catch(function(e){say((e&&e.message)||'could not read that zip','err')});
}
function startBatch(fileList){
items=[];
for(var i=0;i<fileList.length;i++)
items.push({f:fileList[i],name:fileList[i].name,st:'wait',pct:0,tries:0});
retryBtn.style.display='none';
say('');renderQ();runQ(0);
}
// This button only ever appears on an unusable volume, so the warning is
// written for that: the device cannot list what is on there to tell you what
// you are about to lose, and on a fresh board the honest answer is "nothing".
$('fmt').onclick=function(){
if(!confirm('Format pattern storage?\n\nThis writes a fresh filesystem to the '
+'pattern partition. Anything still on it — including modules the device '
+'cannot currently read — is destroyed. On a new board there is nothing '
+'there to lose.'))return;
// Same shape as the bulk delete: one call, no progress from inside it, and
// long enough that a still page looks broken.
busy('formatting');
fetch('/api/patterns/format',{method:'POST'})
.then(function(r){return r.json()}).then(function(d){
busyDone();
say(d.ok?'storage ready - upload patterns now':(d.error||'format failed'),
d.ok?'good':'err');
setTimeout(load,900);
}).catch(function(){busyDone();say('format failed','err')});
};
retryBtn.onclick=function(){
var redo=[];
items.forEach(function(it){if(it.st==='fail'&&it.f)redo.push(it.f)});
if(redo.length)startBatch(redo);
};
file.onchange=function(){if(file.files.length)pickFiles(file.files)};
// The file input is disabled while unmounted, so clicking the label opens
// nothing — which on its own is indistinguishable from a broken page. Say why.
drop.addEventListener('click',function(){if(!mounted)storageReady()});
['dragenter','dragover'].forEach(function(e){
drop.addEventListener(e,function(ev){ev.preventDefault();
if(mounted)drop.classList.add('over')})});
['dragleave','drop'].forEach(function(e){
drop.addEventListener(e,function(ev){ev.preventDefault();drop.classList.remove('over')})});
drop.addEventListener('drop',function(ev){
if(ev.dataTransfer.files.length)pickFiles(ev.dataTransfer.files)});
// One-click install from the community site: /patterns?src=<modules-url>.
// This page runs in the visitor's browser, which can reach both the (https)
// community and this (http) device — so IT does the ferrying: fetch the file
// list, then each file, then the same upload queue the drop zone uses. The
// device never talks to the internet and the visitor downloads nothing.
function installFromUrl(src){
if(!storageReady())return;
say('fetching module list from the community…');
var sep=src.indexOf('?')>=0?'&':'?';
fetch(src+sep+'list=1').then(function(r){
if(!r.ok)throw 0;return r.json();
}).then(function(d){
var names=(d.files||[]).filter(function(n){return /\.(pfm|json)$/.test(n)});
if(!names.length)throw 0;
items=names.map(function(n){return {f:null,name:n,st:'get',pct:0,tries:0}});
retryBtn.style.display='none';
say('');renderQ();
var chain=Promise.resolve();
items.forEach(function(it){
chain=chain.then(function(){
return fetch(src+sep+'file='+encodeURIComponent(it.name))
.then(function(r){if(!r.ok)throw 0;return r.blob()})
.then(function(b){it.f=new File([b],it.name);it.st='wait';renderQ()})
.catch(function(){it.st='fail';it.err='could not fetch from community';renderQ()});
});
});
return chain.then(function(){runQ(0)});
}).catch(function(){
say('could not fetch modules from the link — is the build still available?','err');
});
}
// One-click install of a PACK: /patterns?src=<url of a .zip>.
//
// The same ferrying as above, one file instead of a listing — a deck's
// download address and the site's own pattern packs are both plain zips, so
// sharing a set becomes a link rather than "download this, then drag it
// here". The archive goes through pickFiles, so it is unpacked by exactly
// the code a dropped zip uses; nothing here knows what a .pfm is.
//
// A deck nobody has downloaded yet has to be compiled first, and that route
// says so with 202 rather than blocking. Polling is capped: if a build is
// wedged, saying so beats a spinner that never resolves.
function installZipFromUrl(src,tries){
tries=tries||0;
say(tries?'building the pack…':'fetching the pack…');
fetch(src,{cache:'no-store'}).then(function(r){
if(r.status===202){
if(tries>=40)throw new Error('the pack is still building — try again in a minute');
return new Promise(function(res){setTimeout(res,2000)})
.then(function(){return installZipFromUrl(src,tries+1)});
}
if(!r.ok)throw new Error('could not fetch that pack ('+r.status+')');
return r.blob().then(function(b){
say('');
pickFiles([new File([b],'pack.zip')]);
});
}).catch(function(e){
say((e&&e.message)||'could not fetch that pack','err');
});
}
function runSrc(src){
// Match on the path only: a listing URL carrying "?…=x.zip" must not be
// mistaken for a pack. Both shapes count — a file named "basics.zip" and
// a deck's route, which ends in "/zip" with no extension at all.
var srcPath=src.split('?')[0].split('#')[0];
if(/[./]zip$/i.test(srcPath))installZipFromUrl(src);
else installFromUrl(src);
}
var srcParam=new URLSearchParams(location.search).get('src');
if(srcParam&&/^https?:\/\//.test(srcParam)){
// Strip the query so a reload doesn't reinstall.
history.replaceState(null,'',location.pathname);
// Held, not run: setMounted() spends it once the first reply says the volume
// can take files — or right after the format finishes, if it cannot yet.
pendingSrc=srcParam;
}
load();
</script></body></html>e5b871ed5This file is pinned to the selected release. Download the original to open it in your design tool.