metronome/mobile-sessions.html
Me Here 5b11363520 pm-mobile: practice sessions, richer readout, BPM-tap, lane/feature detail
Bigger rework of the mobile player around a new "practice session" concept,
plus a second page to review sessions.

Transport / sessions:
- Practice now starts a continuous SESSION clock and begins practicing the
  current track. While practicing, the Play button becomes Stop and Practice
  becomes Pause, so Practice starts/stops individual tracks while the session
  clock keeps running. Stop (the Play button) ends the session and records it.
- Plain Play still runs the metronome with no session/recording.
- Each track-practice is one segment {name, at, sec, bpm}; sub-3s blips are
  skipped. A session = {at, endedAt, clockSec, note, segments[]} stored under
  metronome.sessions (replaces the old per-track metronome.logs sheet).
- Switching track / set list mid-session rolls the current segment over.

Display:
- Removed the Tap Tempo button; the BPM display now does it: tap = tap tempo,
  hold = type an exact value, vertical drag = scrub.
- Detail panel shows every lane (canonical share-token chips, disabled lanes
  struck through) and the active features: bar count, end behavior, ramp, and
  gaps (trainer play/mute).
- Meter line shows live bar count with total (e.g. "bar 4 / 16") and elapsed
  play time; the bottom bar shows live session time + track count while
  recording, and links to the sessions page otherwise.

New page mobile-sessions.html: lists saved sessions, each with an editable note
(autosaved) and an aggregate table of tracks practiced in that session
(track - time - plays - bpm range), with per-session delete. PWA scope widened
to /mobile so both pages stay in the installed app + offline (SW v2).

Engine untouched; conformance suite unaffected.

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

144 lines
8.5 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); }
.summary{ color:var(--muted); font-size:13px; margin:2px 2px 14px; }
.card{ background:var(--panel-bg); border:1px solid var(--panel-bd); border-radius:14px; padding:14px; margin-bottom:14px; }
.chead{ display:flex; align-items:baseline; justify-content:space-between; gap:10px; flex-wrap:wrap; }
.chead .when{ font-size:15px; font-weight:600; }
.chead .del{ background:transparent; border:1px solid var(--panel-bd); color:var(--muted); border-radius:9px; padding:6px 11px; font-size:12px; cursor:pointer; }
.chead .del:hover{ color:#ff7a7a; border-color:#ff7a7a; }
.stat{ font-size:13px; color:var(--muted); margin:4px 0 12px; }
.stat b{ color:var(--txt); }
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; }
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; }
.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 class="summary" id="summary"></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";
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 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])); }
/* aggregate a 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);
}
function bpmRange(b){ if(!b.length) return "—"; const lo=Math.min(...b), hi=Math.max(...b); return lo===hi?String(lo):lo+""+hi; }
function render(){
const sessions = lsGet(LS_SESSIONS,[]);
const list=$("list"); list.innerHTML="";
if(!sessions.length){
$("summary").textContent="";
list.innerHTML='<div class="empty"><div class="big">🎼</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;
}
const totalClock = sessions.reduce((a,s)=>a+(s.clockSec||0),0);
$("summary").innerHTML = sessions.length+" session"+(sessions.length>1?"s":"")+" · "+fmt(totalClock)+" total practice time";
sessions.forEach((s)=>{
const rows=aggregate(s.segments);
const practiced=rows.reduce((a,r)=>a+r.sec,0);
const card=document.createElement("div"); card.className="card";
const when=new Date(s.at).toLocaleString();
card.innerHTML =
'<div class="chead"><span class="when">'+esc(when)+'</span><button class="del">Delete</button></div>'+
'<div class="stat">Total <b>'+fmt(s.clockSec)+'</b> · practiced <b>'+fmt(practiced)+'</b> · '+rows.length+' track'+(rows.length===1?"":"s")+'</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>';
card.querySelector(".note").addEventListener("input",(e)=>{ saveNote(s.at, e.target.value); });
card.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(card);
});
}
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);
}
/*@BUILD:include:src/chrome.js@*/
render();
</script>
</body>
</html>