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>
144 lines
8.5 KiB
HTML
144 lines
8.5 KiB
HTML
<!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" />
|
||
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)=>({"&":"&","<":"<",">":">",'"':"""}[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>
|