pm-mobile: per-lane gain + Save & Library (create/save-as/reorder tracks)

- Per-lane gain: a volume slider (-18..+6 dB) in the lane dialog → m.gainDb,
  encoded in the lane token (e.g. kick:4@-6) and saved with the track.
- Save & Library sheet (💾): writes to the SAME store/format as the editor
  (metronome.setlists), so tracks made on the phone show up in the editor too.
    * Save current track: name + target set list (or "+ New set list"),
      "Save as new track" (always) and "Update <name>" for your own tracks —
      Update confirms the overwrite (the iterate-and-resave path is one tap, but
      a named confirm prevents accidentally clobbering the original when you
      meant to save a new track).
    * Manage library: reorder (up/down), rename and delete your set lists and
      their tracks; built-ins stay read-only (Save-as copies edits out of them).
- Built-in/transient set lists can't be overwritten — saving promotes the live
  working copy into one of your own set lists.

Engine untouched; conformance suite unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Me Here 2026-06-07 10:35:57 -05:00
parent 8f5635af52
commit 582118abf9

View file

@ -145,18 +145,18 @@
/* ---- bottom sheet (lane editor) ---- */
#scrim{ position:fixed; inset:0; background:rgba(0,0,0,.55); opacity:0; pointer-events:none; transition:opacity .2s; z-index:40; }
#scrim.open{ opacity:1; pointer-events:auto; }
#laneSheet, #trackSheet{ position:fixed; left:0; right:0; bottom:0; z-index:50; max-height:88vh; overflow-y:auto;
#laneSheet, #trackSheet, #saveSheet{ position:fixed; left:0; right:0; bottom:0; z-index:50; max-height:88vh; overflow-y:auto;
background:var(--panel-bg); border-top:1px solid var(--panel-bd); border-radius:18px 18px 0 0; transform:translateY(110%);
transition:transform .26s cubic-bezier(.2,.8,.2,1);
padding:12px max(16px,env(safe-area-inset-right)) max(18px,env(safe-area-inset-bottom)) max(16px,env(safe-area-inset-left)); }
#laneSheet.open, #trackSheet.open{ transform:none; }
#laneSheet .grab, #trackSheet .grab{ width:42px; height:5px; border-radius:3px; background:var(--panel-bd); margin:0 auto 12px; }
#laneSheet h2, #trackSheet h2{ margin:0 0 10px; font-size:16px; }
#laneSheet label, #trackSheet label{ display:block; font-size:12px; color:var(--muted); margin:10px 0 5px; }
#laneSheet select, #trackSheet select,
#laneSheet input[type=text], #trackSheet input[type=text], #trackSheet input[type=number]{
#laneSheet.open, #trackSheet.open, #saveSheet.open{ transform:none; }
#laneSheet .grab, #trackSheet .grab, #saveSheet .grab{ width:42px; height:5px; border-radius:3px; background:var(--panel-bd); margin:0 auto 12px; }
#laneSheet h2, #trackSheet h2, #saveSheet h2{ margin:0 0 10px; font-size:16px; }
#laneSheet label, #trackSheet label, #saveSheet label{ display:block; font-size:12px; color:var(--muted); margin:10px 0 5px; }
#laneSheet select, #trackSheet select, #saveSheet select,
#laneSheet input[type=text], #trackSheet input[type=text], #trackSheet input[type=number], #saveSheet input[type=text]{
width:100%; background:var(--field-bg); color:var(--txt); border:1px solid var(--field-bd); border-radius:10px; padding:11px; font-size:15px; }
#laneSheet .lrow, #trackSheet .lrow{ display:flex; gap:12px; margin-top:10px; flex-wrap:wrap; }
#laneSheet .lrow, #trackSheet .lrow, #saveSheet .lrow{ display:flex; gap:12px; margin-top:10px; flex-wrap:wrap; align-items:center; }
#laneSheet .half, #trackSheet .half{ display:block; flex:1 1 120px; margin:0; }
#laneSheet .chk, #trackSheet .chk{ display:flex; align-items:center; gap:8px; font-size:14px; color:var(--txt); margin-top:16px; }
#laneSheet .chk input, #trackSheet .chk input{ width:20px; height:20px; accent-color:var(--cyan); flex:0 0 auto; }
@ -164,6 +164,15 @@
.lfoot{ display:flex; justify-content:space-between; align-items:center; margin-top:18px; }
.lbtn{ cursor:pointer; color:var(--txt); background:linear-gradient(180deg,var(--btn1),var(--btn2)); border:1px solid var(--btn-bd); border-radius:10px; padding:10px 16px; font-size:14px; }
.lbtn.danger{ background:transparent; color:#ff7a7a; border-color:#ff7a7a; }
.seclbl{ font-size:11px; letter-spacing:.1em; text-transform:uppercase; color:var(--muted); margin:4px 0 8px; }
.savemsg{ font-size:12px; color:#5fd08a; align-self:center; }
.liblbl{ font-size:12px; color:var(--muted); margin:14px 0 4px; }
.libhint{ font-size:12px; color:var(--muted); padding:6px 2px; line-height:1.4; }
.librow{ display:flex; align-items:center; gap:4px; padding:4px 0; border-bottom:1px solid var(--panel-bd); }
.librow.active .libname{ color:var(--cyan); font-weight:600; }
.libname{ flex:1 1 auto; min-width:0; text-align:left; background:transparent; border:none; color:var(--txt); font-size:14px; padding:7px 2px; cursor:pointer; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.ibtn{ flex:0 0 auto; width:32px; height:32px; border-radius:7px; background:var(--chip-bg); border:1px solid var(--chip-bd); color:var(--txt); font-size:13px; cursor:pointer; }
.ibtn:disabled{ opacity:.3; }
/* ---- help tour (coachmarks) ---- */
#tour{ position:fixed; inset:0; z-index:200; display:none; }
@ -186,6 +195,7 @@
</div>
<div class="trow">
<div class="vol">🔈<input id="vol" type="range" min="0" max="100" value="85" />🔊</div>
<div class="icon" id="saveBtn" title="Save &amp; library" aria-label="Save and library">💾</div>
<div class="icon" id="helpBtn" title="Help" aria-label="Help">?</div>
<div class="icon" id="themeBtn" title="Theme" aria-label="Theme"></div>
<div class="icon" id="fsBtn" title="Full screen" aria-label="Full screen"></div>
@ -239,6 +249,8 @@
<label class="chk"><input type="checkbox" id="lsPoly" /> Polymeter</label>
<label class="chk"><input type="checkbox" id="lsMute" /> Mute lane</label>
</div>
<label for="lsGain">Lane volume <span id="lsGainVal" style="color:var(--txt)">0 dB</span></label>
<input id="lsGain" type="range" min="-18" max="6" step="1" style="width:100%;accent-color:var(--cyan)" />
<div class="lfoot"><button id="lsDel" class="lbtn danger">Delete lane</button><button id="lsDone" class="lbtn">Done</button></div>
</div>
@ -271,6 +283,26 @@
<div class="lfoot"><span></span><button id="tsDone" class="lbtn">Done</button></div>
</div>
<!-- save & library sheet -->
<div id="saveSheet">
<div class="grab"></div>
<h2>Save &amp; library</h2>
<div class="seclbl">Save current track</div>
<label for="saveName">Track name</label>
<input id="saveName" type="text" autocomplete="off" />
<label for="saveTo">Save to set list</label>
<select id="saveTo"></select>
<input id="saveNewName" type="text" autocomplete="off" placeholder="New set list name" style="display:none;margin-top:8px" />
<div class="lrow">
<button id="saveUpd" class="lbtn">Update</button>
<button id="saveNew" class="lbtn">Save as new track</button>
<span id="saveMsg" class="savemsg"></span>
</div>
<div class="seclbl" style="margin-top:20px">Manage library</div>
<div id="libBody"></div>
<div class="lfoot"><span></span><button id="saveDone" class="lbtn">Done</button></div>
</div>
<!-- guided help tour -->
<div id="tour">
<div id="tourHole"></div>
@ -448,17 +480,20 @@ function addLane(){ meters.push(buildMeters([{groupsStr:"4",stepsPerBeat:1,sound
/* lane settings sheet */
(function(){ const sel=$("lsSound"); VOICES.forEach(([k,lab])=>{ const o=document.createElement("option"); o.value=k; o.textContent=lab; sel.appendChild(o); }); })();
function gainLabel(db){ return (db>0?"+":"")+db+" dB"; }
function openLaneSheet(i){ editLaneIdx=i; const m=meters[i]; if(!m) return;
$("lsSound").value=m.sound; $("lsGroup").value=m.groupsStr; $("lsSub").value=String(m.stepsPerBeat); $("lsSwing").checked=!!m.swing; $("lsPoly").checked=!!m.poly; $("lsMute").checked=!m.enabled;
$("scrim").classList.add("open"); $("laneSheet").classList.add("open"); }
function closeSheets(){ $("scrim").classList.remove("open"); $("laneSheet").classList.remove("open"); $("trackSheet").classList.remove("open"); }
$("lsGain").value=m.gainDb||0; $("lsGainVal").textContent=gainLabel(m.gainDb||0);
$("trackSheet").classList.remove("open"); $("saveSheet").classList.remove("open"); $("scrim").classList.add("open"); $("laneSheet").classList.add("open"); }
function closeSheets(){ $("scrim").classList.remove("open"); $("laneSheet").classList.remove("open"); $("trackSheet").classList.remove("open"); $("saveSheet").classList.remove("open"); }
const closeLaneSheet=closeSheets;
function applyLane(){ const m=meters[editLaneIdx]; if(!m) return;
let grp=($("lsGroup").value||"").trim()||"4";
rebuildLane(editLaneIdx,{groupsStr:grp,stepsPerBeat:Math.max(1,Math.min(4,parseInt($("lsSub").value,10)||1)),swing:$("lsSwing").checked,
poly:$("lsPoly").checked,enabled:!$("lsMute").checked,sound:$("lsSound").value,gainDb:m.gainDb||0,beatsOn:m.beatsOn.slice(),orns:(m.orns||[]).slice()});
poly:$("lsPoly").checked,enabled:!$("lsMute").checked,sound:$("lsSound").value,gainDb:parseInt($("lsGain").value,10)||0,beatsOn:m.beatsOn.slice(),orns:(m.orns||[]).slice()});
laneSig=null; renderAll(); saveState(); }
["lsSound","lsSub","lsSwing","lsPoly","lsMute"].forEach(id=>$(id).addEventListener("change",applyLane));
$("lsGain").addEventListener("input",()=>{ const m=meters[editLaneIdx]; if(!m) return; m.gainDb=parseInt($("lsGain").value,10)||0; $("lsGainVal").textContent=gainLabel(m.gainDb); saveState(); }); // live, no rebuild
$("lsGroup").addEventListener("change",applyLane);
$("lsDone").onclick=closeLaneSheet;
$("lsDel").onclick=()=>{ if(meters.length<=1){ closeLaneSheet(); return; } meters.splice(editLaneIdx,1); laneSig=null; renderAll(); saveState(); closeLaneSheet(); };
@ -473,7 +508,7 @@ function openTrackSheet(){
$("tsGap").checked=trainer.on; $("tsGapPlay").value=trainer.playBars||2; $("tsGapMute").value=trainer.muteBars||2;
$("tsRampRow").classList.toggle("off",!ramp.on); $("tsGapRow").classList.toggle("off",!trainer.on);
$("tsStr").value=currentPatch(); $("tsMsg").textContent="";
$("laneSheet").classList.remove("open"); $("scrim").classList.add("open"); $("trackSheet").classList.add("open");
$("laneSheet").classList.remove("open"); $("saveSheet").classList.remove("open"); $("scrim").classList.add("open"); $("trackSheet").classList.add("open");
}
function applyTrackSettings(){
segBars=Math.max(0,parseInt($("tsBars").value,10)||0);
@ -493,6 +528,82 @@ $("tsApply").onclick=()=>{ const txt=($("tsStr").value||"").trim().replace(/^[#?
$("tsDone").onclick=closeSheets;
$("trackBtn").onclick=openTrackSheet;
/* ---- save & library: user set lists/tracks (same store + format as the editor) ---- */
function userSetlists(){ return lsGet(LS_SETLISTS,[]); }
function saveUserSetlists(a){ lsSet(LS_SETLISTS,a); savedLists=a; }
function curSetupObj(){ return { bpm:state.bpm, lanes:snapshotLanes(), trainer:{...trainer}, ramp:{...ramp}, countMs:0, bars:segBars, rep:curRep, end:curEnd }; }
function flashSave(msg){ $("saveMsg").textContent=msg; setTimeout(()=>{ if($("saveMsg").textContent===msg) $("saveMsg").textContent=""; },1800); }
function el(tag,cls,txt){ const e=document.createElement(tag); if(cls) e.className=cls; if(txt!=null) e.textContent=txt; return e; }
function ibtn(label,fn,dis){ const b=el("button","ibtn",label); b.disabled=!!dis; b.onclick=(e)=>{ e.stopPropagation(); fn(); }; return b; }
function selectUserList(i){ const arr=userSetlists(); if(!arr[i]) return; slKey="s"+i; transientTitle=null;
loadSetlistObj({title:arr[i].title,items:(arr[i].items||[]).map(it=>({...it}))}); renderLibrary(); }
function selectUserTrack(i,j){ slKey="s"+i; transientTitle=null; savedLists=userSetlists();
setlist={title:savedLists[i].title,items:savedLists[i].items.map(it=>({...it}))}; idx=Math.max(0,Math.min(j,setlist.items.length-1));
loadSetup(setlist.items[idx]); buildSetlistOptions(); buildTrackOptions(); renderAll(); saveState(); }
function doSaveAsNew(){
const name=($("saveName").value||"My track").trim(); const arr=userSetlists(); let i;
if($("saveTo").value==="__new"){ const t=($("saveNewName").value||"My set list").trim(); arr.push({title:t,description:"",items:[]}); i=arr.length-1; }
else { i=+$("saveTo").value.slice(1); if(!arr[i]) return; }
arr[i].items.push({name, ...curSetupObj()}); saveUserSetlists(arr);
selectUserTrack(i, arr[i].items.length-1); $("saveNewName").value=""; buildSaveTo(); renderLibrary(); flashSave("Saved ✓");
}
function doUpdate(){
if(slKey[0]!=="s") return; const arr=userSetlists(), i=+slKey.slice(1); if(!arr[i]||!arr[i].items[idx]) return;
const oldName=arr[i].items[idx].name||"this track"; const nm=($("saveName").value||oldName).trim();
if(!confirm('Overwrite "'+oldName+'" with the current settings?')) return;
arr[i].items[idx]={name:nm, ...curSetupObj()}; saveUserSetlists(arr);
setlist.items[idx]={name:nm, ...curSetupObj()}; lastCur=null; buildTrackOptions(); renderInfo(); renderLibrary(); flashSave("Updated ✓");
}
function moveList(i,dir){ const arr=userSetlists(), j=i+dir; if(j<0||j>=arr.length) return; const t=arr[i]; arr[i]=arr[j]; arr[j]=t; saveUserSetlists(arr);
if(slKey==="s"+i) slKey="s"+j; else if(slKey==="s"+j) slKey="s"+i; buildSetlistOptions(); renderLibrary(); saveState(); }
function moveTrack(i,j,dir){ const arr=userSetlists(), sl=arr[i], k=j+dir; if(!sl||k<0||k>=sl.items.length) return; const t=sl.items[j]; sl.items[j]=sl.items[k]; sl.items[k]=t; saveUserSetlists(arr);
if(slKey==="s"+i){ if(idx===j) idx=k; else if(idx===k) idx=j; setlist.items=sl.items.map(it=>({...it})); buildTrackOptions(); renderInfo(); } renderLibrary(); saveState(); }
function renameList(i){ const arr=userSetlists(); if(!arr[i]) return; const n=prompt("Rename set list:",arr[i].title||""); if(n==null) return; arr[i].title=n.trim()||arr[i].title; saveUserSetlists(arr);
if(slKey==="s"+i&&setlist) setlist.title=arr[i].title; buildSetlistOptions(); renderLibrary(); saveState(); }
function renameTrack(i,j){ const arr=userSetlists(); if(!arr[i]||!arr[i].items[j]) return; const n=prompt("Rename track:",arr[i].items[j].name||""); if(n==null) return; arr[i].items[j].name=n.trim()||arr[i].items[j].name; saveUserSetlists(arr);
if(slKey==="s"+i){ setlist.items[j].name=arr[i].items[j].name; if(idx===j){ lastCur=null; } buildTrackOptions(); renderInfo(); } renderLibrary(); saveState(); }
function deleteTrack(i,j){ const arr=userSetlists(), sl=arr[i]; if(!sl||!sl.items[j]) return; if(!confirm('Delete track "'+(sl.items[j].name||"")+'"?')) return; sl.items.splice(j,1); saveUserSetlists(arr);
if(slKey==="s"+i){ if(!sl.items.length){ deleteListResolved(i); return; } if(idx>=sl.items.length) idx=sl.items.length-1; else if(idx>j) idx--; selectUserTrack(i,idx); } renderLibrary(); }
function deleteList(i){ const arr=userSetlists(); if(!arr[i]) return; if(!confirm('Delete set list "'+(arr[i].title||"")+'" and all its tracks?')) return; deleteListResolved(i); }
function deleteListResolved(i){ const arr=userSetlists(); arr.splice(i,1); saveUserSetlists(arr);
if(slKey==="s"+i){ slKey="b0"; setlist=BUILTIN[0]; idx=0; loadSetup(setlist.items[0]); buildSetlistOptions(); buildTrackOptions(); renderAll(); saveState(); }
else if(slKey[0]==="s"){ const k=+slKey.slice(1); if(k>i) slKey="s"+(k-1); buildSetlistOptions(); }
buildSaveTo(); renderLibrary(); }
function newList(){ const n=prompt("New set list name:","My set list"); if(n==null) return; const arr=userSetlists(); arr.push({title:n.trim()||"My set list",description:"",items:[]}); saveUserSetlists(arr); buildSaveTo(); renderLibrary(); }
function buildSaveTo(){ savedLists=userSetlists(); const sel=$("saveTo"); sel.innerHTML="";
savedLists.forEach((sl,i)=>sel.appendChild(opt("s"+i,(sl.title||("Set list "+(i+1)))+" ("+(sl.items?sl.items.length:0)+")")));
sel.appendChild(opt("__new"," New set list…"));
sel.value = slKey[0]==="s" ? slKey : (savedLists.length?"s0":"__new");
$("saveNewName").style.display = sel.value==="__new" ? "block":"none"; }
$("saveTo").onchange=()=>{ $("saveNewName").style.display = $("saveTo").value==="__new" ? "block":"none"; };
function renderLibrary(){ savedLists=userSetlists(); const box=$("libBody"); box.innerHTML="";
box.appendChild(el("div","liblbl","Your set lists"));
if(!savedLists.length) box.appendChild(el("div","libhint","None yet — “Save as new track” creates one."));
savedLists.forEach((sl,i)=>{ const row=el("div","librow"+(slKey==="s"+i?" active":""));
const nm=el("button","libname",(sl.title||"set list")+" ("+(sl.items?sl.items.length:0)+")"); nm.onclick=()=>selectUserList(i); row.appendChild(nm);
row.appendChild(ibtn("↑",()=>moveList(i,-1),i===0)); row.appendChild(ibtn("↓",()=>moveList(i,1),i===savedLists.length-1));
row.appendChild(ibtn("✎",()=>renameList(i))); row.appendChild(ibtn("🗑",()=>deleteList(i))); box.appendChild(row); });
const addL=el("button","addlane"," New set list"); addL.onclick=newList; box.appendChild(addL);
if(slKey[0]==="s"){ const i=+slKey.slice(1), sl=savedLists[i]; if(sl){
box.appendChild(el("div","liblbl","Tracks in “"+(sl.title||"set list")+"”"));
sl.items.forEach((it,j)=>{ const row=el("div","librow"+(idx===j?" active":""));
const nm=el("button","libname",(j+1)+". "+(it.name||"track")); nm.onclick=()=>{ gotoItem(j,state.running); renderLibrary(); }; row.appendChild(nm);
row.appendChild(ibtn("↑",()=>moveTrack(i,j,-1),j===0)); row.appendChild(ibtn("↓",()=>moveTrack(i,j,1),j===sl.items.length-1));
row.appendChild(ibtn("✎",()=>renameTrack(i,j))); row.appendChild(ibtn("🗑",()=>deleteTrack(i,j))); box.appendChild(row); });
const addT=el("button","addlane"," Add current track here"); addT.onclick=()=>{ $("saveTo").value="s"+i; doSaveAsNew(); }; box.appendChild(addT);
}} else { box.appendChild(el("div","libhint","This set list is built-in (read-only). “Save as new track” copies your edits into one of your own set lists.")); }
}
function openSaveSheet(){
$("saveName").value=currentName()||"My track"; buildSaveTo();
const upd=$("saveUpd"); if(slKey[0]==="s"){ upd.style.display=""; upd.textContent='Update “'+(currentName()||"track")+'”'; } else upd.style.display="none";
$("saveMsg").textContent=""; renderLibrary();
$("laneSheet").classList.remove("open"); $("trackSheet").classList.remove("open"); $("scrim").classList.add("open"); $("saveSheet").classList.add("open"); }
$("saveNew").onclick=doSaveAsNew; $("saveUpd").onclick=doUpdate; $("saveDone").onclick=closeSheets; $("saveBtn").onclick=openSaveSheet;
/* ========================= SET-LIST / TRACK DROPDOWNS ======================== */
function opt(v,t){ const o=document.createElement("option"); o.value=v; o.textContent=t; return o; }
function og(label){ const g=document.createElement("optgroup"); g.label=label; return g; }