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:
parent
8f5635af52
commit
582118abf9
1 changed files with 123 additions and 12 deletions
135
mobile.html
135
mobile.html
|
|
@ -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 & 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 & 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; }
|
||||
|
|
|
|||
Loading…
Reference in a new issue