metronome/mobile-sessions.html
Me Here 4b53f917f4 seed lists + mobile icons: rich Styles/Practice, drop Song/Notation, subtle music theme
setlists.js (shared by all pages):
- Removed the "Song (continuous)" and "Notation showcase" seed lists.
- "Styles" is now a rich, genre-true collection (16): rock, pop 16ths, funk,
  disco, Motown, blues shuffle, jazz swing, bossa, samba, reggae one-drop,
  afrobeat, hip-hop, metal, 6/8 ballad, 7/8, 5/4 — full grooves to jam over.
- "Practice" is 15 drummer drills to learn those styles: hat subdivisions,
  ghost-note backbeats, 16th hand control, shuffle/jazz ride, bossa & 3-over-4
  independence, dynamics, double bass, hemiola/5-over-4, tempo & gap trainers.
- Dropped the cartoon emoji from the titles. All patches validated: every lane
  parses and pattern lengths match their meters.

Mobile icons — less cartoonish, subtly musical:
- Volume rail now reads p … f (piano/forte dynamics) instead of speaker emoji.
- Save 💾 -> ↧; library +/✕ instead of /🗑.
- Practice-sessions empty state uses a treble clef instead of 🎼.

Engine untouched; conformance passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 11:14:06 -05:00

214 lines
13 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>VARASYS PolyMeter — Practice sessions</title>
<link rel="manifest" href="/manifest.webmanifest" />
<meta name="theme-color" content="#eef3f9" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#05070a" media="(prefers-color-scheme: dark)" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="PolyMeter" />
<link rel="apple-touch-icon" href="/icon-180.png" />
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;base64,@BUILD:favicon@" />
<script>
(function(){ try{
var p = localStorage.getItem("metronome.theme");
if (p!=="light" && p!=="dark" && p!=="system") p = "system";
document.documentElement.dataset.theme = p==="system" ? (matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark") : p;
} catch(e){ document.documentElement.dataset.theme = "dark"; } })();
</script>
<style>
/*@BUILD:include:src/base.css@*/
:root{
--bg1:#12151c; --bg2:#05070a; --txt:#e7edf5; --muted:#8b96a5; --link:#6cb6ff;
--panel-bg:#161b22; --panel-bd:#2a313c; --field-bg:#0e1116; --field-bd:#222a36;
--cyan:#0AB3F7; --amber:#ffd166; --row:#0e1218;
}
:root[data-theme="light"]{
--bg1:#eef3f9; --bg2:#cfd9e6; --txt:#10202f; --muted:#5c6776; --link:#1769c4;
--panel-bg:#ffffff; --panel-bd:#d2dae4; --field-bg:#f1f4f8; --field-bd:#cdd6e0; --row:#f4f7fb;
}
html,body{ height:100%; }
body{ margin:0; min-height:100%; color:var(--txt); background:radial-gradient(circle at 50% -10%, var(--bg1), var(--bg2));
font-family:"Segoe UI", Roboto, Helvetica, Arial, sans-serif; -webkit-text-size-adjust:100%; overscroll-behavior-y:none; }
.wrap{ max-width:760px; margin:0 auto;
padding:max(10px,env(safe-area-inset-top)) max(14px,env(safe-area-inset-right)) max(28px,env(safe-area-inset-bottom)) max(14px,env(safe-area-inset-left)); }
header{ display:flex; align-items:center; gap:12px; position:sticky; top:0; z-index:5; padding:8px 0 10px;
background:linear-gradient(180deg, var(--bg1) 70%, transparent); }
.back{ flex:0 0 auto; display:flex; align-items:center; gap:6px; text-decoration:none; color:var(--txt);
background:rgba(127,139,154,.14); border:1px solid var(--panel-bd); border-radius:10px; padding:9px 13px; font-size:15px; }
header h1{ flex:1 1 auto; font-size:18px; margin:0; }
.icon{ flex:0 0 auto; width:40px; height:40px; border-radius:50%; display:flex; align-items:center; justify-content:center;
font-size:17px; cursor:pointer; color:var(--txt); background:rgba(127,139,154,.14); border:1px solid var(--panel-bd); }
.card{ background:var(--panel-bg); border:1px solid var(--panel-bd); border-radius:14px; padding:14px; margin-bottom:14px; }
.seclabel{ font-size:11px; letter-spacing:.1em; text-transform:uppercase; color:var(--muted); margin:6px 2px 8px; }
/* current-track aggregate (compare across sessions) */
#trackAgg .ta-head{ display:flex; align-items:center; gap:10px; flex-wrap:wrap; margin-bottom:4px; }
#taSel{ flex:1 1 auto; min-width:0; background:var(--field-bg); color:var(--txt); border:1px solid var(--field-bd);
border-radius:10px; padding:9px 8px; font-size:16px; font-weight:600; }
.stat{ font-size:13px; color:var(--muted); margin:4px 0 12px; }
.stat b{ color:var(--txt); }
table{ width:100%; border-collapse:collapse; font-size:14px; }
th,td{ text-align:left; padding:8px 8px; border-bottom:1px solid var(--panel-bd); }
th{ font-size:11px; letter-spacing:.08em; text-transform:uppercase; color:var(--muted); font-weight:600; }
td.num,th.num{ text-align:right; font-variant-numeric:tabular-nums; }
tr:nth-child(even) td{ background:var(--row); }
tfoot td{ font-weight:700; border-top:2px solid var(--panel-bd); border-bottom:none; }
/* session list — collapsible */
details.sess{ background:var(--panel-bg); border:1px solid var(--panel-bd); border-radius:14px; margin-bottom:12px; overflow:hidden; }
details.sess > summary{ list-style:none; cursor:pointer; padding:13px 14px; display:flex; flex-direction:column; gap:3px; }
details.sess > summary::-webkit-details-marker{ display:none; }
summary .when{ font-size:15px; font-weight:600; display:flex; align-items:center; gap:9px; }
summary .when::before{ content:"▸"; color:var(--muted); font-size:13px; transition:transform .15s; }
details[open] > summary .when::before{ transform:rotate(90deg); }
summary .sstat{ font-size:12px; color:var(--muted); padding-left:22px; }
summary .sstat b{ color:var(--txt); }
.sbody{ padding:2px 14px 14px; }
.sbody .brow{ display:flex; justify-content:flex-end; margin-bottom:10px; }
.del{ background:transparent; border:1px solid var(--panel-bd); color:var(--muted); border-radius:9px; padding:6px 11px; font-size:12px; cursor:pointer; }
.del:hover{ color:#ff7a7a; border-color:#ff7a7a; }
textarea.note{ width:100%; background:var(--field-bg); color:var(--txt); border:1px solid var(--field-bd); border-radius:10px;
padding:10px; font-family:inherit; font-size:14px; resize:vertical; min-height:46px; margin-bottom:12px; }
.empty{ text-align:center; color:var(--muted); padding:60px 16px; }
.empty .big{ font-size:46px; opacity:.5; }
.foot{ text-align:center; color:var(--muted); font-size:12px; margin-top:24px; }
.foot img{ height:16px; vertical-align:middle; opacity:.85; }
[data-theme="light"] .logo-dark{ display:none; } [data-theme="dark"] .logo-light{ display:none; }
</style>
</head>
<body>
<div class="wrap">
<header>
<a class="back" href="/mobile.html"> Metronome</a>
<h1>Practice sessions</h1>
<div class="icon" id="themeBtn" title="Theme" aria-label="Theme"></div>
</header>
<div id="trackAgg" class="card" style="display:none">
<div class="seclabel">This track — across all sessions</div>
<div class="ta-head"><select id="taSel"></select></div>
<div class="stat" id="taStat"></div>
<table id="taTable"></table>
</div>
<div class="seclabel" id="listLabel" style="display:none">Sessions</div>
<div id="list"></div>
<div class="foot">
<img class="logo-dark" src="data:image/png;base64,@BUILD:logo-dark@" alt="VARASYS" /><img class="logo-light" src="data:image/png;base64,@BUILD:logo-light@" alt="VARASYS" />
&nbsp;PolyMeter <span id="appVersion"></span>
</div>
</div>
<script>
const APP_VERSION = "v0.0.1-dev";
const $ = (id) => document.getElementById(id);
const LS_SESSIONS = "metronome.sessions", LS_CURTRACK = "metronome.curtrack";
function lsGet(k,fb){ try{ const v=localStorage.getItem(k); return v?JSON.parse(v):fb; }catch(e){ return fb; } }
function lsSet(k,v){ try{ localStorage.setItem(k,JSON.stringify(v)); }catch(e){} }
function lsGetRaw(k){ try{ return localStorage.getItem(k)||""; }catch(e){ return ""; } }
function fmt(sec){ sec=Math.max(0,Math.round(sec)); const h=Math.floor(sec/3600), m=Math.floor((sec%3600)/60), s=sec%60;
return (h?h+":"+String(m).padStart(2,"0"):m)+":"+String(s).padStart(2,"0"); }
function esc(s){ return String(s).replace(/[&<>"]/g,(c)=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[c])); }
function whenLong(ms){ const d=new Date(ms);
const wd=d.toLocaleDateString(undefined,{weekday:"short"}), mo=d.toLocaleDateString(undefined,{month:"short"});
const t=d.toLocaleTimeString(undefined,{hour:"numeric",minute:"2-digit"});
return wd+" "+mo+" "+d.getDate()+" at "+t; } // "Fri Jun 16 at 2:46 PM"
function whenShort(ms){ const d=new Date(ms);
return d.toLocaleDateString(undefined,{month:"short",day:"numeric"})+" · "+d.toLocaleTimeString(undefined,{hour:"numeric",minute:"2-digit"}); }
function bpmRange(b){ if(!b.length) return "—"; const lo=Math.min(...b), hi=Math.max(...b); return lo===hi?String(lo):lo+""+hi; }
/* per-track aggregate of one session's segments → one row per track */
function aggregate(seg){
const by={};
(seg||[]).forEach((s)=>{ const k=s.name||"(unnamed)"; const a=by[k]||(by[k]={name:k,sec:0,plays:0,bpms:[]}); a.sec+=s.sec; a.plays++; if(s.bpm) a.bpms.push(s.bpm); });
return Object.values(by).sort((x,y)=>y.sec-x.sec);
}
/* one track across all sessions → one row per session that included it (newest first) */
function trackRows(name){
const out=[];
lsGet(LS_SESSIONS,[]).forEach((s)=>{
let sec=0, plays=0, bpms=[];
(s.segments||[]).forEach((g)=>{ if((g.name||"(unnamed)")===name){ sec+=g.sec; plays++; if(g.bpm) bpms.push(g.bpm); } });
if(sec>0) out.push({ at:s.at, sec, plays, bpms });
});
return out;
}
function allTrackNames(){ const set=new Set(); lsGet(LS_SESSIONS,[]).forEach((s)=>(s.segments||[]).forEach((g)=>set.add(g.name||"(unnamed)"))); return [...set].sort((a,b)=>a.localeCompare(b)); }
/* ----- top: current-track comparison across sessions ----- */
let selTrack=null;
function renderTrackAgg(){
const names=allTrackNames();
if(!names.length){ $("trackAgg").style.display="none"; return; }
$("trackAgg").style.display="";
const cur=lsGetRaw(LS_CURTRACK).replace(/^"|"$/g,""); // stored JSON-encoded string
if(selTrack===null) selTrack = names.includes(cur) ? cur : names[0];
if(!names.includes(selTrack)) selTrack=names[0];
// selector
const sel=$("taSel"); sel.innerHTML="";
names.forEach((n)=>{ const o=document.createElement("option"); o.value=n; o.textContent=n+(n===cur?" (current)":""); sel.appendChild(o); });
sel.value=selTrack;
// stats + per-session table
const rows=trackRows(selTrack);
const totSec=rows.reduce((a,r)=>a+r.sec,0), totPlays=rows.reduce((a,r)=>a+r.plays,0), allb=rows.flatMap((r)=>r.bpms);
$("taStat").innerHTML = rows.length+" session"+(rows.length===1?"":"s")+" · total <b>"+fmt(totSec)+"</b> · "+totPlays+" play"+(totPlays===1?"":"s")+" · "+bpmRange(allb)+" bpm";
$("taTable").innerHTML =
'<thead><tr><th>Session</th><th class="num">Time</th><th class="num">Plays</th><th class="num">BPM</th></tr></thead><tbody>'+
(rows.length ? rows.map((r)=>'<tr><td>'+esc(whenShort(r.at))+'</td><td class="num">'+fmt(r.sec)+'</td><td class="num">'+r.plays+'</td><td class="num">'+bpmRange(r.bpms)+'</td></tr>').join("")
: '<tr><td colspan="4" style="color:var(--muted)">No sessions for this track yet.</td></tr>')+
'</tbody>';
}
$("taSel").addEventListener("change",(e)=>{ selTrack=e.target.value; renderTrackAgg(); });
/* ----- session list (collapsible) ----- */
function renderList(){
const sessions=lsGet(LS_SESSIONS,[]);
const list=$("list"); list.innerHTML="";
if(!sessions.length){
$("listLabel").style.display="none";
list.innerHTML='<div class="empty"><div class="big">&#119070;</div><p>No practice sessions yet.<br>On the metronome, press <b>Practice</b> to start a session, then <b>Stop</b> when you\'re done — it\'ll be saved here.</p></div>';
return;
}
$("listLabel").style.display="";
sessions.forEach((s)=>{
const rows=aggregate(s.segments), practiced=rows.reduce((a,r)=>a+r.sec,0);
const d=document.createElement("details"); d.className="sess";
d.innerHTML =
'<summary><span class="when">'+esc(whenLong(s.at))+'</span>'+
'<span class="sstat">Total <b>'+fmt(s.clockSec)+'</b> · practiced <b>'+fmt(practiced)+'</b> · '+rows.length+' track'+(rows.length===1?"":"s")+'</span></summary>'+
'<div class="sbody">'+
'<div class="brow"><button class="del">Delete session</button></div>'+
'<textarea class="note" placeholder="Add a note about this session — what you worked on, how it felt…">'+esc(s.note||"")+'</textarea>'+
'<table><thead><tr><th>Track</th><th class="num">Time</th><th class="num">Plays</th><th class="num">BPM</th></tr></thead><tbody>'+
rows.map((r)=>'<tr><td>'+esc(r.name)+'</td><td class="num">'+fmt(r.sec)+'</td><td class="num">'+r.plays+'</td><td class="num">'+bpmRange(r.bpms)+'</td></tr>').join("")+
'</tbody><tfoot><tr><td>Total</td><td class="num">'+fmt(practiced)+'</td><td class="num">'+rows.reduce((a,r)=>a+r.plays,0)+'</td><td class="num"></td></tr></tfoot></table>'+
'</div>';
d.querySelector(".note").addEventListener("input",(e)=>saveNote(s.at, e.target.value));
d.querySelector(".del").addEventListener("click",()=>{
if(!confirm("Delete this practice session? This can't be undone.")) return;
lsSet(LS_SESSIONS, lsGet(LS_SESSIONS,[]).filter((x)=>x.at!==s.at)); render();
});
list.appendChild(d);
});
}
let saveTimer=null;
function saveNote(at, text){
clearTimeout(saveTimer);
saveTimer=setTimeout(()=>{ const arr=lsGet(LS_SESSIONS,[]); const it=arr.find((x)=>x.at===at); if(it){ it.note=text; lsSet(LS_SESSIONS,arr); } }, 300);
}
function render(){ renderTrackAgg(); renderList(); }
/*@BUILD:include:src/chrome.js@*/
render();
</script>
</body>
</html>