pm-mobile: touch-first phone/tablet PWA player (mobile.html)
A new full-screen, touch-first edition of the player aimed at phones through tablets - no native app, just a web page you can "Add to Home Screen". Reuses the shared engine + look-ahead scheduler (same player loop as player.html); new UI is a big pulsing beat display, beat-dot row with accent grouping, huge BPM (tap to type, vertical drag to scrub), prev/play/next +/- and tap-tempo, and a bottom sheet for set lists / patch+link loading / volume. Mobile concerns handled: - iOS ring/silent switch: navigator.audioSession.type="playback" + a silent buffer warmup inside the play gesture, so audio isn't muted by the switch. - Screen Wake Lock while running (re-acquired on visibilitychange). - PWA: manifest.webmanifest + apple-touch meta + mobile-sw.js (network-first app shell, passthrough for everything else) -> installable + offline. Multi-file is fine here since it targets mobile (waives the single-file rule). - viewport-fit=cover + safe-area insets, no user zoom, touch-action:manipulation, overscroll-behavior:none; transport buttons flex-share the row so they never overflow a narrow phone; responsive portrait/landscape, phone->tablet. - Fullscreen toggle where supported (Android/desktop; iOS uses home-screen PWA). Wired into build.sh + deploy.sh (page + PWA assets) and added to the index gallery as PM_M-1 Mobile. New metronome app icons generated in assets/. Conformance suite unaffected (engine untouched): 47 pass, 1 known. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
76392ab20f
commit
a3a09bc77d
9 changed files with 530 additions and 2 deletions
BIN
assets/icon-180.png
Normal file
BIN
assets/icon-180.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.4 KiB |
BIN
assets/icon-192.png
Normal file
BIN
assets/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.3 KiB |
BIN
assets/icon-512.png
Normal file
BIN
assets/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
8
build.sh
8
build.sh
|
|
@ -46,12 +46,18 @@ def build(name):
|
||||||
out.write_text(src)
|
out.write_text(src)
|
||||||
return out.stat().st_size
|
return out.stat().st_size
|
||||||
|
|
||||||
for name in ("index.html","editor.html","editor-beta.html","pm_e-2.html","player.html","teacher.html","stage.html","micro.html","showcase.html","kit.html","explorer.html","grid.html",
|
for name in ("index.html","editor.html","editor-beta.html","pm_e-2.html","player.html","mobile.html","teacher.html","stage.html","micro.html","showcase.html","kit.html","explorer.html","grid.html",
|
||||||
"embed.html",
|
"embed.html",
|
||||||
"info-editor.html","info-pm_e-2.html","info-player.html","info-teacher.html","info-stage.html","info-micro.html","info-showcase.html","info-kit.html","info-explorer.html","info-grid.html"):
|
"info-editor.html","info-pm_e-2.html","info-player.html","info-teacher.html","info-stage.html","info-micro.html","info-showcase.html","info-kit.html","info-explorer.html","info-grid.html"):
|
||||||
print("built %s (%dKB)" % (name, build(name) // 1024))
|
print("built %s (%dKB)" % (name, build(name) // 1024))
|
||||||
pathlib.Path("dist/embed.js").write_text(pathlib.Path("embed.js").read_text()) # loader, served as-is
|
pathlib.Path("dist/embed.js").write_text(pathlib.Path("embed.js").read_text()) # loader, served as-is
|
||||||
print("copied embed.js")
|
print("copied embed.js")
|
||||||
|
# PWA support files for mobile.html (the phone/tablet app): manifest, service worker, icons.
|
||||||
|
for f in ("manifest.webmanifest", "mobile-sw.js"):
|
||||||
|
pathlib.Path("dist/" + f).write_text(pathlib.Path(f).read_text())
|
||||||
|
for f in ("icon-192.png", "icon-512.png", "icon-180.png"):
|
||||||
|
pathlib.Path("dist/" + f).write_bytes((A / f).read_bytes())
|
||||||
|
print("copied PWA files (manifest.webmanifest, mobile-sw.js, icon-{192,512,180}.png)")
|
||||||
pathlib.Path("dist/pico-main.py").write_text(pathlib.Path("pico/main.py").read_text()) # PM_K-1 firmware, downloadable
|
pathlib.Path("dist/pico-main.py").write_text(pathlib.Path("pico/main.py").read_text()) # PM_K-1 firmware, downloadable
|
||||||
print("copied pico-main.py")
|
print("copied pico-main.py")
|
||||||
_appsrc = pathlib.Path("pico-cp/app.py").read_text()
|
_appsrc = pathlib.Path("pico-cp/app.py").read_text()
|
||||||
|
|
|
||||||
|
|
@ -40,13 +40,17 @@ fi
|
||||||
|
|
||||||
# stamp the version into the built copy only (source stays clean)
|
# stamp the version into the built copy only (source stays clean)
|
||||||
echo "deployed v$BUILD -> $DEST_DIR"
|
echo "deployed v$BUILD -> $DEST_DIR"
|
||||||
for f in index.html editor.html editor-beta.html pm_e-2.html player.html teacher.html stage.html micro.html showcase.html kit.html explorer.html grid.html \
|
for f in index.html editor.html editor-beta.html pm_e-2.html player.html mobile.html teacher.html stage.html micro.html showcase.html kit.html explorer.html grid.html \
|
||||||
embed.html \
|
embed.html \
|
||||||
info-editor.html info-pm_e-2.html info-player.html info-teacher.html info-stage.html info-micro.html info-showcase.html info-kit.html info-explorer.html info-grid.html; do
|
info-editor.html info-pm_e-2.html info-player.html info-teacher.html info-stage.html info-micro.html info-showcase.html info-kit.html info-explorer.html info-grid.html; do
|
||||||
sed "s|const APP_VERSION = \"[^\"]*\";|const APP_VERSION = \"$BUILD\";|" "$DIST_DIR/$f" > "$DEST_DIR/$f"
|
sed "s|const APP_VERSION = \"[^\"]*\";|const APP_VERSION = \"$BUILD\";|" "$DIST_DIR/$f" > "$DEST_DIR/$f"
|
||||||
echo " $f ($(stat -c '%s' "$DEST_DIR/$f") bytes)"
|
echo " $f ($(stat -c '%s' "$DEST_DIR/$f") bytes)"
|
||||||
done
|
done
|
||||||
cp "$DIST_DIR/embed.js" "$DEST_DIR/embed.js"; echo " embed.js ($(stat -c '%s' "$DEST_DIR/embed.js") bytes)"
|
cp "$DIST_DIR/embed.js" "$DEST_DIR/embed.js"; echo " embed.js ($(stat -c '%s' "$DEST_DIR/embed.js") bytes)"
|
||||||
|
# PWA assets for mobile.html (manifest + service worker + icons) — served at the web root
|
||||||
|
for f in manifest.webmanifest mobile-sw.js icon-192.png icon-512.png icon-180.png; do
|
||||||
|
cp "$DIST_DIR/$f" "$DEST_DIR/$f"; echo " $f ($(stat -c '%s' "$DEST_DIR/$f") bytes)"
|
||||||
|
done
|
||||||
cp "$DIST_DIR/pico-main.py" "$DEST_DIR/pico-main.py"; echo " pico-main.py ($(stat -c '%s' "$DEST_DIR/pico-main.py") bytes)" # PM_K-1 firmware download
|
cp "$DIST_DIR/pico-main.py" "$DEST_DIR/pico-main.py"; echo " pico-main.py ($(stat -c '%s' "$DEST_DIR/pico-main.py") bytes)" # PM_K-1 firmware download
|
||||||
# Rust firmware (RP2350) — served if built via rust/pm-kit/build.sh (gitignored artifact, not in dist/)
|
# Rust firmware (RP2350) — served if built via rust/pm-kit/build.sh (gitignored artifact, not in dist/)
|
||||||
if [[ -f "$SRC_DIR/rust/pm-kit/pm-kit.uf2" ]]; then
|
if [[ -f "$SRC_DIR/rust/pm-kit/pm-kit.uf2" ]]; then
|
||||||
|
|
|
||||||
|
|
@ -161,6 +161,7 @@ const SAMPLES = {}; let state = { bpm:120, volume:0.85 }, meters = [], muteWindo
|
||||||
const VERSIONS = [
|
const VERSIONS = [
|
||||||
// PM_E-1 (editor.html) is hidden from the landing — PM_E-2 is the focus. The page still exists.
|
// PM_E-1 (editor.html) is hidden from the landing — PM_E-2 is the focus. The page still exists.
|
||||||
{ key:"pme2", file:"/pm_e-2.html", name:"PM_E‑2 Editor", chip:"app", h:640, sum:"The PolyMeter editor, built around engraved drum notation — a 5‑line percussion staff (Bravura/SMuFL) with Staff / TUBS / Konnakol views, edit‑on‑staff, plus flams/drags/rolls, odd meters & clave." },
|
{ key:"pme2", file:"/pm_e-2.html", name:"PM_E‑2 Editor", chip:"app", h:640, sum:"The PolyMeter editor, built around engraved drum notation — a 5‑line percussion staff (Bravura/SMuFL) with Staff / TUBS / Konnakol views, edit‑on‑staff, plus flams/drags/rolls, odd meters & clave." },
|
||||||
|
{ key:"mobile", file:"/mobile.html", name:"PM_M‑1 Mobile", chip:"app", h:600, sum:"Phone & tablet app — a touch‑first, full‑screen player you can “Add to Home Screen.” Big tap targets, drag‑to‑scrub tempo, a pulsing beat display, screen‑wake‑lock, and an iOS fix for the ring/silent switch. Installable & works offline." },
|
||||||
{ key:"kit", file:"/kit.html", name:"PM_K‑1 Kit", chip:"hw", h:560, sum:"Build it today — a Raspberry Pi Pico on the 52Pi touchscreen kit; tap the 3.5″ screen, joystick tempo, RGB beat light, buzzer. MicroPython firmware included." },
|
{ key:"kit", file:"/kit.html", name:"PM_K‑1 Kit", chip:"hw", h:560, sum:"Build it today — a Raspberry Pi Pico on the 52Pi touchscreen kit; tap the 3.5″ screen, joystick tempo, RGB beat light, buzzer. MicroPython firmware included." },
|
||||||
{ key:"explorer", file:"/explorer.html", name:"PM_X‑1 Explorer", chip:"hw", h:500, sum:"Off‑the‑shelf — the Pimoroni Explorer (RP2350, 2.8″ LCD, 6 buttons, piezo) as a button‑driven sibling to the Kit. Edit on the web with Live sync; the device mirrors play/stop/tempo/track changes both ways." },
|
{ key:"explorer", file:"/explorer.html", name:"PM_X‑1 Explorer", chip:"hw", h:500, sum:"Off‑the‑shelf — the Pimoroni Explorer (RP2350, 2.8″ LCD, 6 buttons, piezo) as a button‑driven sibling to the Kit. Edit on the web with Live sync; the device mirrors play/stop/tempo/track changes both ways." },
|
||||||
{ key:"grid", file:"/grid.html", name:"PM_G‑1 Grid", chip:"hw", h:470, sum:"Off‑the‑shelf — a Pimoroni Pico Scroll Pack (17×7 white LED matrix + 4 buttons) on a Raspberry Pi Pico. The matrix IS the editor's lane × step pad grid in miniature; edit on the web with Live sync." },
|
{ key:"grid", file:"/grid.html", name:"PM_G‑1 Grid", chip:"hw", h:470, sum:"Off‑the‑shelf — a Pimoroni Pico Scroll Pack (17×7 white LED matrix + 4 buttons) on a Raspberry Pi Pico. The matrix IS the editor's lane × step pad grid in miniature; edit on the web with Live sync." },
|
||||||
|
|
|
||||||
18
manifest.webmanifest
Normal file
18
manifest.webmanifest
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
{
|
||||||
|
"name": "VARASYS PolyMeter",
|
||||||
|
"short_name": "PolyMeter",
|
||||||
|
"description": "Polymetric groove-trainer & metronome — touch-first, full-screen.",
|
||||||
|
"id": "/mobile.html",
|
||||||
|
"start_url": "/mobile.html?standalone=1",
|
||||||
|
"scope": "/mobile.html",
|
||||||
|
"display": "standalone",
|
||||||
|
"display_override": ["standalone", "fullscreen"],
|
||||||
|
"orientation": "any",
|
||||||
|
"background_color": "#05070a",
|
||||||
|
"theme_color": "#0b0d11",
|
||||||
|
"categories": ["music", "productivity", "utilities"],
|
||||||
|
"icons": [
|
||||||
|
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" },
|
||||||
|
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" }
|
||||||
|
]
|
||||||
|
}
|
||||||
51
mobile-sw.js
Normal file
51
mobile-sw.js
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
/* Service worker for the PolyMeter mobile app (mobile.html).
|
||||||
|
*
|
||||||
|
* Deliberately minimal and non-intrusive: it only manages its OWN app-shell URLs
|
||||||
|
* (the page, manifest, icons). For every other request it does NOT call
|
||||||
|
* respondWith(), so the rest of the site behaves exactly as if no SW existed.
|
||||||
|
*
|
||||||
|
* Strategy for the shell: network-first, fall back to cache. The page is a single
|
||||||
|
* self-contained file that is version-stamped on deploy, so when the device is
|
||||||
|
* online it always gets the freshest build; offline it still launches from cache.
|
||||||
|
*/
|
||||||
|
const CACHE = "polymeter-mobile-v1";
|
||||||
|
const SHELL = [
|
||||||
|
"/mobile.html",
|
||||||
|
"/manifest.webmanifest",
|
||||||
|
"/icon-192.png",
|
||||||
|
"/icon-512.png",
|
||||||
|
"/icon-180.png",
|
||||||
|
];
|
||||||
|
const SHELL_PATHS = new Set(SHELL);
|
||||||
|
|
||||||
|
self.addEventListener("install", (e) => {
|
||||||
|
self.skipWaiting();
|
||||||
|
e.waitUntil(caches.open(CACHE).then((c) => c.addAll(SHELL)).catch(() => {}));
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("activate", (e) => {
|
||||||
|
e.waitUntil(
|
||||||
|
caches.keys()
|
||||||
|
.then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))))
|
||||||
|
.then(() => self.clients.claim())
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("fetch", (e) => {
|
||||||
|
const req = e.request;
|
||||||
|
if (req.method !== "GET") return;
|
||||||
|
const url = new URL(req.url);
|
||||||
|
if (url.origin !== self.location.origin) return;
|
||||||
|
// Treat any navigation to /mobile.html (with or without ?standalone=1 etc.) as the shell.
|
||||||
|
const path = url.pathname;
|
||||||
|
if (!SHELL_PATHS.has(path)) return; // not ours — let the browser handle it
|
||||||
|
|
||||||
|
e.respondWith(
|
||||||
|
fetch(req)
|
||||||
|
.then((res) => {
|
||||||
|
if (res && res.ok) { const copy = res.clone(); caches.open(CACHE).then((c) => c.put(path, copy)); }
|
||||||
|
return res;
|
||||||
|
})
|
||||||
|
.catch(() => caches.match(path).then((hit) => hit || caches.match("/mobile.html")))
|
||||||
|
);
|
||||||
|
});
|
||||||
448
mobile.html
Normal file
448
mobile.html
Normal file
|
|
@ -0,0 +1,448 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<!-- viewport-fit=cover → draw under the notch/home-indicator; no user zoom (app, not a document) -->
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, user-scalable=no" />
|
||||||
|
<title>VARASYS PolyMeter — Mobile</title>
|
||||||
|
|
||||||
|
<!-- PWA / Add-to-Home-Screen: makes it launch full-screen & chrome-less from the home screen -->
|
||||||
|
<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="mobile-web-app-capable" content="yes" />
|
||||||
|
<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/png" sizes="192x192" href="/icon-192.png" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;base64,@BUILD:favicon@" />
|
||||||
|
|
||||||
|
<script>
|
||||||
|
/* ?embed=1 → running inside the form-factor gallery iframe: skip PWA/fullscreen/wake-lock. */
|
||||||
|
window.EMBED = /[?&]embed=1/.test(location.search);
|
||||||
|
if (window.EMBED) document.documentElement.dataset.embed = "1";
|
||||||
|
</script>
|
||||||
|
<script>
|
||||||
|
// Set theme before first paint (no flash). Shares the editor's "metronome.theme" key.
|
||||||
|
(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;
|
||||||
|
--led-off:#1b2330; --ring:#2a3340; --glow:rgba(10,179,247,.55); --aglow:rgba(255,209,102,.55);
|
||||||
|
--btn1:#2b323d; --btn2:#1b212a; --btn-bd:#39424f;
|
||||||
|
}
|
||||||
|
: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;
|
||||||
|
--led-off:#c4cedb; --ring:#c9d4e1; --glow:rgba(10,179,247,.40); --aglow:rgba(230,160,30,.45);
|
||||||
|
--btn1:#ffffff; --btn2:#e7edf4; --btn-bd:#c8d2de;
|
||||||
|
}
|
||||||
|
html,body{ height:100%; }
|
||||||
|
body{
|
||||||
|
margin:0; overflow:hidden; color:var(--txt);
|
||||||
|
background:radial-gradient(circle at 50% -12%, var(--bg1), var(--bg2));
|
||||||
|
font-family:"Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
-webkit-user-select:none; user-select:none; -webkit-tap-highlight-color:transparent;
|
||||||
|
touch-action:manipulation; overscroll-behavior:none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app{ position:fixed; inset:0; overflow:hidden; display:flex; flex-direction:column;
|
||||||
|
padding:max(8px,env(safe-area-inset-top)) max(12px,env(safe-area-inset-right))
|
||||||
|
max(12px,env(safe-area-inset-bottom)) max(12px,env(safe-area-inset-left)); }
|
||||||
|
|
||||||
|
/* ---- top bar ---- */
|
||||||
|
#bar{ flex:0 0 auto; display:flex; align-items:center; gap:10px; min-height:44px; }
|
||||||
|
#bar .id{ flex:1 1 auto; min-width:0; line-height:1.15; }
|
||||||
|
#bar .nm{ font-size:clamp(15px,2.7vmin,22px); font-weight:600; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||||
|
#bar .sub{ font-size:clamp(11px,1.9vmin,14px); color:var(--muted); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||||
|
.icon{ flex:0 0 auto; width:44px; height:44px; border-radius:50%; display:flex; align-items:center; justify-content:center;
|
||||||
|
font-size:19px; line-height:1; cursor:pointer; color:var(--txt);
|
||||||
|
background:rgba(127,139,154,.14); border:1px solid var(--panel-bd); }
|
||||||
|
.icon:active{ background:rgba(127,139,154,.30); }
|
||||||
|
|
||||||
|
/* ---- stage: beats + pulse ---- */
|
||||||
|
#stage{ flex:1 1 auto; min-height:0; display:flex; flex-direction:column; align-items:center; justify-content:center;
|
||||||
|
gap:clamp(14px,3.4vmin,40px); }
|
||||||
|
#beats{ display:flex; flex-wrap:wrap; justify-content:center; align-items:center; gap:clamp(8px,1.7vmin,16px); max-width:92%; }
|
||||||
|
.dot{ width:clamp(13px,3vmin,30px); height:clamp(13px,3vmin,30px); border-radius:50%;
|
||||||
|
background:var(--led-off); border:1px solid rgba(0,0,0,.35); transition:background .05s, box-shadow .05s, transform .05s; }
|
||||||
|
.dot.group{ outline:1.5px solid var(--amber); outline-offset:3px; opacity:.95; }
|
||||||
|
.dot.on{ background:var(--cyan); box-shadow:0 0 12px var(--glow); transform:scale(1.12); }
|
||||||
|
.dot.on.group{ background:var(--amber); box-shadow:0 0 14px var(--aglow); }
|
||||||
|
|
||||||
|
#pulse{ position:relative; width:clamp(190px,48vmin,460px); height:clamp(190px,48vmin,460px); border-radius:50%;
|
||||||
|
display:flex; flex-direction:column; align-items:center; justify-content:center; text-align:center;
|
||||||
|
border:2px solid var(--ring); background:radial-gradient(circle at 50% 40%, rgba(127,139,154,.07), transparent 70%);
|
||||||
|
transition:transform .12s ease-out, box-shadow .12s ease-out, border-color .12s ease-out;
|
||||||
|
touch-action:none; cursor:ns-resize; }
|
||||||
|
#pulse.hit{ transform:scale(1.045); border-color:var(--cyan); box-shadow:0 0 60px var(--glow); }
|
||||||
|
#pulse.hit.acc{ border-color:var(--amber); box-shadow:0 0 72px var(--aglow); }
|
||||||
|
#bpm{ font-size:clamp(58px,19vmin,180px); font-weight:800; line-height:.85; font-variant-numeric:tabular-nums; letter-spacing:-.01em; }
|
||||||
|
#bpmlab{ font-size:clamp(11px,2.3vmin,18px); letter-spacing:.34em; color:var(--muted); margin-top:.7em; }
|
||||||
|
#bpmIn{ display:none; width:62%; text-align:center; font:inherit; font-size:clamp(50px,16vmin,150px); font-weight:800;
|
||||||
|
background:transparent; color:var(--txt); border:none; border-bottom:2px solid var(--cyan); outline:none;
|
||||||
|
font-variant-numeric:tabular-nums; -moz-appearance:textfield; }
|
||||||
|
#bpmIn::-webkit-outer-spin-button, #bpmIn::-webkit-inner-spin-button{ -webkit-appearance:none; margin:0; }
|
||||||
|
#meterline{ font-size:clamp(12px,2.1vmin,17px); color:var(--muted); min-height:1.2em; letter-spacing:.02em; }
|
||||||
|
|
||||||
|
/* ---- transport ---- */
|
||||||
|
#transport{ flex:0 0 auto; display:flex; flex-direction:column; align-items:center; gap:clamp(8px,1.6vmin,14px);
|
||||||
|
padding-top:clamp(8px,1.6vmin,16px); width:100%; }
|
||||||
|
/* buttons SHARE the row width (flex:1) so 5 of them never overflow a narrow phone — they
|
||||||
|
just get narrower; capped by max-width so they don't sprawl on a tablet. */
|
||||||
|
.row{ display:flex; align-items:center; justify-content:center; gap:clamp(6px,2vmin,16px);
|
||||||
|
width:100%; max-width:560px; }
|
||||||
|
.tbtn{ flex:1 1 0; min-width:0; background:linear-gradient(180deg,var(--btn1),var(--btn2)); color:var(--txt); border:1px solid var(--btn-bd);
|
||||||
|
border-radius:14px; height:clamp(56px,15vmin,84px); font-size:clamp(20px,5vmin,30px);
|
||||||
|
cursor:pointer; box-shadow:0 3px 0 rgba(0,0,0,.28), inset 0 1px 0 rgba(255,255,255,.06);
|
||||||
|
display:flex; align-items:center; justify-content:center; }
|
||||||
|
.tbtn:active{ transform:translateY(2px); box-shadow:0 1px 0 rgba(0,0,0,.28), inset 0 1px 0 rgba(255,255,255,.06); }
|
||||||
|
.tbtn.play{ flex:1.6 1 0; background:linear-gradient(180deg,#1f7a4d,#155f3b); border-color:#2e7d32; color:#eafff3; }
|
||||||
|
.tbtn.play.on{ background:linear-gradient(180deg,#b23b3b,#8f2d2d); border-color:#c0392b; color:#fff; }
|
||||||
|
.tap{ background:transparent; color:var(--muted); border:1px solid var(--panel-bd); border-radius:12px;
|
||||||
|
padding:9px 22px; font-size:clamp(12px,2.2vmin,15px); letter-spacing:.16em; cursor:pointer; }
|
||||||
|
.tap:active{ color:var(--txt); background:rgba(127,139,154,.14); }
|
||||||
|
|
||||||
|
/* shorter viewports (landscape phones): tighten so it all fits */
|
||||||
|
@media (max-height:540px){
|
||||||
|
#stage{ gap:clamp(8px,2vmin,18px); }
|
||||||
|
#bpmlab{ margin-top:.4em; }
|
||||||
|
.tbtn{ height:clamp(48px,18vmin,70px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- menu sheet ---- */
|
||||||
|
#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; }
|
||||||
|
#sheet{ position:fixed; left:0; right:0; bottom:0; z-index:50; max-height:86vh; 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:14px max(16px,env(safe-area-inset-right)) max(20px,env(safe-area-inset-bottom)) max(16px,env(safe-area-inset-left)); }
|
||||||
|
#sheet.open{ transform:none; }
|
||||||
|
#sheet .grab{ width:42px; height:5px; border-radius:3px; background:var(--panel-bd); margin:0 auto 12px; }
|
||||||
|
#sheet h2{ margin:2px 0 4px; font-size:15px; }
|
||||||
|
#sheet label{ display:block; font-size:12px; color:var(--muted); margin:14px 0 5px; }
|
||||||
|
#sheet select, #sheet textarea, #sheet input[type=range]{ width:100%; }
|
||||||
|
#sheet select{ background:var(--field-bg); color:var(--txt); border:1px solid var(--field-bd); border-radius:10px; padding:11px; font-size:15px; }
|
||||||
|
#sheet textarea{ background:var(--field-bg); color:var(--txt); border:1px solid var(--field-bd); border-radius:10px; padding:11px;
|
||||||
|
font-family:"Courier New",monospace; font-size:13px; resize:vertical; min-height:56px; }
|
||||||
|
.srow{ display:flex; gap:10px; align-items:center; margin-top:10px; }
|
||||||
|
.ld{ flex:0 0 auto; cursor:pointer; color:#eafff3; background:linear-gradient(180deg,#1f7a4d,#155f3b); border:1px solid #2e7d32;
|
||||||
|
border-radius:10px; padding:11px 18px; font-size:15px; }
|
||||||
|
#status{ margin-top:10px; font-size:12px; min-height:1.2em; font-family:"Courier New",monospace; color:var(--muted); }
|
||||||
|
#status.ok{ color:#5fd08a; } #status.err{ color:#ff7a7a; }
|
||||||
|
.vol{ display:flex; align-items:center; gap:12px; }
|
||||||
|
.sheet-foot{ display:flex; align-items:center; justify-content:space-between; margin-top:18px; padding-top:12px;
|
||||||
|
border-top:1px solid var(--panel-bd); font-size:12px; color:var(--muted); }
|
||||||
|
.sheet-foot a{ color:var(--link); text-decoration:none; }
|
||||||
|
#installBtn{ display:none; cursor:pointer; color:var(--txt); background:transparent; border:1px solid var(--panel-bd);
|
||||||
|
border-radius:9px; padding:7px 14px; font-size:13px; }
|
||||||
|
.dev-logo{ height:18px; opacity:.85; }
|
||||||
|
[data-theme="light"] .logo-dark{ display:none; } [data-theme="dark"] .logo-light{ display:none; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div id="app">
|
||||||
|
<div id="bar">
|
||||||
|
<div class="icon" id="menuBtn" title="Menu" aria-label="Menu">☰</div>
|
||||||
|
<div class="id">
|
||||||
|
<div class="nm" id="mName">—</div>
|
||||||
|
<div class="sub"><span id="mPos">–/–</span></div>
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="stage">
|
||||||
|
<div id="beats"></div>
|
||||||
|
<div id="pulse">
|
||||||
|
<div id="bpm">120</div>
|
||||||
|
<input id="bpmIn" type="number" inputmode="numeric" min="30" max="300" />
|
||||||
|
<div id="bpmlab">BPM</div>
|
||||||
|
</div>
|
||||||
|
<div id="meterline"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="transport">
|
||||||
|
<div class="row">
|
||||||
|
<button class="tbtn" id="bDown" title="Tempo −">−</button>
|
||||||
|
<button class="tbtn" id="bPrev" title="Previous">⏮</button>
|
||||||
|
<button class="tbtn play" id="bPlay" title="Play / Stop">▶</button>
|
||||||
|
<button class="tbtn" id="bNext" title="Next">⏭</button>
|
||||||
|
<button class="tbtn" id="bUp" title="Tempo +">+</button>
|
||||||
|
</div>
|
||||||
|
<button class="tap" id="bTap" title="Tap tempo">TAP TEMPO</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- bottom sheet: load / set lists / volume / install -->
|
||||||
|
<div id="scrim"></div>
|
||||||
|
<div id="sheet">
|
||||||
|
<div class="grab"></div>
|
||||||
|
<h2>Load a groove</h2>
|
||||||
|
<label for="storedSel">Set list</label>
|
||||||
|
<select id="storedSel"><option value="">— choose a set list —</option></select>
|
||||||
|
<label for="cfg">…or paste a patch / share link</label>
|
||||||
|
<textarea id="cfg" spellcheck="false" placeholder="v1;t120;kick:4;snare:4=.X.X;hatClosed:4/2 — or a #p=… / #sl=… link"></textarea>
|
||||||
|
<div class="srow">
|
||||||
|
<button class="ld" id="bLoad">Load</button>
|
||||||
|
<span id="status"></span>
|
||||||
|
</div>
|
||||||
|
<label for="vol">Volume</label>
|
||||||
|
<div class="vol">🔈<input id="vol" type="range" min="0" max="100" value="85" />🔊</div>
|
||||||
|
<div class="sheet-foot">
|
||||||
|
<span><img class="dev-logo logo-dark" src="data:image/png;base64,@BUILD:logo-dark@" alt="VARASYS" /><img class="dev-logo logo-light" src="data:image/png;base64,@BUILD:logo-light@" alt="VARASYS" /> PolyMeter <span id="appVersion"></span></span>
|
||||||
|
<button id="installBtn">Install app</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const APP_VERSION = "v0.0.1-dev";
|
||||||
|
const $ = (id) => document.getElementById(id);
|
||||||
|
|
||||||
|
/* ========================= ENGINE (synth voices only; shared scheduler) ======= */
|
||||||
|
const SAMPLES = {};
|
||||||
|
/*@BUILD:include:src/engine.js@*/
|
||||||
|
/*@BUILD:include:src/setlists.js@*/
|
||||||
|
const state={ bpm:120, volume:0.85, running:false };
|
||||||
|
let meters=[];
|
||||||
|
let ramp={on:false,startBpm:80,amount:5,everyBars:4}, trainer={on:false,playBars:2,muteBars:2};
|
||||||
|
let segBars=0, segBarCount=0, pendingAdvance=false;
|
||||||
|
let masterBeat=0, masterBeatTime=0, muteWindows=[];
|
||||||
|
|
||||||
|
function setBpm(v){ state.bpm=Math.max(30,Math.min(300,Math.round(v))); }
|
||||||
|
function advanceMaster(ahead){
|
||||||
|
const mbpb=masterBeatsPerBar();
|
||||||
|
while(masterBeatTime<ahead){
|
||||||
|
if(masterBeat%mbpb===0){
|
||||||
|
const barIndex=Math.floor(masterBeat/mbpb);
|
||||||
|
if(barIndex>0&&ramp.on&&(barIndex%ramp.everyBars===0)) setBpm(state.bpm+ramp.amount);
|
||||||
|
if(trainer.on){ const cyc=trainer.playBars+trainer.muteBars; if(cyc>0&&(barIndex%cyc)>=trainer.playBars) muteWindows.push({start:masterBeatTime,end:masterBeatTime+mbpb*(60/state.bpm)}); }
|
||||||
|
segBarCount=barIndex;
|
||||||
|
if(segBars>0&&barIndex>=segBars&&!pendingAdvance){ pendingAdvance=true; }
|
||||||
|
}
|
||||||
|
masterBeat++; masterBeatTime+=60/state.bpm;
|
||||||
|
}
|
||||||
|
if(audioCtx) muteWindows=muteWindows.filter(w=>w.end>audioCtx.currentTime-1);
|
||||||
|
}
|
||||||
|
function scheduler(){
|
||||||
|
const ahead=audioCtx.currentTime+SCHEDULE_AHEAD;
|
||||||
|
advanceMaster(ahead);
|
||||||
|
for(const m of meters){ while(m.nextTime<ahead){ scheduleMeterTick(m,m.nextTime); m.nextTime+=laneStepDur(m,m.tick); m.tick++; } }
|
||||||
|
if(pendingAdvance){ pendingAdvance=false; setTimeout(()=>gotoItem(idx+1,true),0); } // loop set list at end
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================= PLAYER ============================================= */
|
||||||
|
let setlist=null, idx=0;
|
||||||
|
const BUILTIN = SEED_SETLISTS.map((sl) => ({ title: sl.title, items: sl.items.map(([n, p]) => ({ name: n, ...patchToSetup(p) })) }));
|
||||||
|
|
||||||
|
function buildMeters(lanes){
|
||||||
|
return (lanes||[]).map(c=>{
|
||||||
|
const p=parseGroups(c.groupsStr);
|
||||||
|
return {groupsStr:c.groupsStr,groups:p.groups,beatsPerBar:p.beatsPerBar,groupStarts:p.groupStarts,
|
||||||
|
stepsPerBeat:c.stepsPerBeat||1,sound:c.sound,beatsOn:(c.beatsOn||[]).slice(),poly:!!c.poly,swing:!!c.swing,enabled:c.enabled!==false,gainDb:c.gainDb||0,
|
||||||
|
tick:0,nextTime:0,vq:[],vqPtr:0,currentStep:-1,currentBar:0};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function loadSetup(s){
|
||||||
|
ramp=s.ramp?{...s.ramp}:{on:false,startBpm:80,amount:5,everyBars:4};
|
||||||
|
trainer=s.trainer?{...s.trainer}:{on:false,playBars:2,muteBars:2};
|
||||||
|
segBars=s.bars||0; segBarCount=0;
|
||||||
|
setBpm(s.bpm||120);
|
||||||
|
meters=buildMeters(s.lanes);
|
||||||
|
rebuildBeats();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* iOS: ignore the ring/silent hardware switch + warm up the context inside the play gesture. */
|
||||||
|
function unlockAudio(){
|
||||||
|
try{ if(navigator.audioSession) navigator.audioSession.type="playback"; }catch(e){}
|
||||||
|
try{ const b=audioCtx.createBuffer(1,1,audioCtx.sampleRate), s=audioCtx.createBufferSource(); s.buffer=b; s.connect(audioCtx.destination); s.start(0); }catch(e){}
|
||||||
|
}
|
||||||
|
function startAudio(){
|
||||||
|
ensureAudio(); unlockAudio(); audioCtx.resume(); state.running=true;
|
||||||
|
if(ramp.on) setBpm(ramp.startBpm);
|
||||||
|
const t0=audioCtx.currentTime+0.08;
|
||||||
|
for(const m of meters){ m.tick=0; m.nextTime=t0; m.vq=[]; m.vqPtr=0; m.currentStep=-1; m.currentBar=0; }
|
||||||
|
masterBeat=0; masterBeatTime=t0; muteWindows=[]; pendingAdvance=false; lastBeatKey=-1;
|
||||||
|
schedulerTimer=setInterval(scheduler,LOOKAHEAD_MS); scheduler(); renderAll();
|
||||||
|
requestWake();
|
||||||
|
}
|
||||||
|
function stopAudio(){ state.running=false; clearInterval(schedulerTimer); schedulerTimer=null; pendingAdvance=false; for(const m of meters) m.currentStep=-1; renderAll(); releaseWake(); }
|
||||||
|
function toggle(){ state.running?stopAudio():startAudio(); }
|
||||||
|
function gotoItem(i,keepPlaying){
|
||||||
|
if(!setlist||!setlist.items.length) return;
|
||||||
|
const n=setlist.items.length; idx=((i%n)+n)%n;
|
||||||
|
const wasRunning=state.running||keepPlaying;
|
||||||
|
if(state.running){ clearInterval(schedulerTimer); schedulerTimer=null; state.running=false; }
|
||||||
|
loadSetup(setlist.items[idx]);
|
||||||
|
if(wasRunning) startAudio(); else renderAll();
|
||||||
|
}
|
||||||
|
function loadSetlistObj(sl){ setlist=sl; idx=0; const wasRunning=state.running; if(wasRunning){clearInterval(schedulerTimer);schedulerTimer=null;state.running=false;} loadSetup(sl.items[0]); if(wasRunning) startAudio(); else renderAll(); }
|
||||||
|
|
||||||
|
let taps=[];
|
||||||
|
function tapTempo(){ const now=performance.now(); taps=taps.filter(t=>now-t<2000); taps.push(now);
|
||||||
|
if(taps.length>=2){ let s=0; for(let i=1;i<taps.length;i++) s+=taps[i]-taps[i-1]; setBpm(60000/(s/(taps.length-1))); ramp.on=false; renderAll(); } }
|
||||||
|
function nudge(d){ ramp.on=false; setBpm(state.bpm+d); renderAll(); }
|
||||||
|
|
||||||
|
/* ========================= RENDER ============================================ */
|
||||||
|
function rebuildBeats(){
|
||||||
|
const box=$("beats"); box.innerHTML="";
|
||||||
|
const m=meters[0]; const beats=m?m.beatsPerBar:0;
|
||||||
|
for(let i=0;i<beats;i++){ const d=document.createElement("div"); d.className="dot"+(m.groupStarts.has(i)?" group":""); box.appendChild(d); }
|
||||||
|
}
|
||||||
|
function renderBeats(){
|
||||||
|
const m=meters[0]; if(!m) return;
|
||||||
|
const cur=state.running? Math.floor(m.currentStep/m.stepsPerBeat) : -1;
|
||||||
|
const els=$("beats").children;
|
||||||
|
for(let i=0;i<els.length;i++) els[i].classList.toggle("on", i===cur);
|
||||||
|
}
|
||||||
|
function renderInfo(){
|
||||||
|
if(editingBpm) { /* leave the input alone */ } else $("bpm").textContent=state.bpm;
|
||||||
|
$("mName").textContent=setlist? (setlist.items[idx].name||"—") : "—";
|
||||||
|
$("mPos").textContent=setlist? "♪ "+(idx+1)+"/"+setlist.items.length : "–/–";
|
||||||
|
const m=meters[0];
|
||||||
|
$("meterline").textContent = m ? (m.beatsPerBar + " beats" + (m.groups.length>1 ? " · " + m.groups.join("+") : "") + (m.swing?" · swing":"")) : "";
|
||||||
|
}
|
||||||
|
function renderAll(){ renderInfo(); renderBeats(); $("bPlay").textContent=state.running?"■":"▶"; $("bPlay").classList.toggle("on",state.running); }
|
||||||
|
|
||||||
|
let lastBeatKey=-1, pulseTimer=null;
|
||||||
|
function pulseHit(acc){ const p=$("pulse"); p.classList.remove("hit","acc"); void p.offsetWidth; p.classList.add("hit"); if(acc) p.classList.add("acc");
|
||||||
|
clearTimeout(pulseTimer); pulseTimer=setTimeout(()=>p.classList.remove("hit","acc"),130); }
|
||||||
|
function draw(){
|
||||||
|
// latency-compensated playhead so visuals land when the click is HEARD (see engine note)
|
||||||
|
if(audioCtx&&state.running){ const now=audioCtx.currentTime-(audioCtx.outputLatency||audioCtx.baseLatency||0);
|
||||||
|
for(const m of meters){ while(m.vqPtr<m.vq.length&&m.vq[m.vqPtr].time<=now){ m.currentStep=m.vq[m.vqPtr].step; m.currentBar=m.vq[m.vqPtr].bar; m.vqPtr++; } } }
|
||||||
|
renderBeats();
|
||||||
|
const m=meters[0];
|
||||||
|
if(state.running&&m&&m.currentStep>=0){
|
||||||
|
const beat=Math.floor(m.currentStep/m.stepsPerBeat);
|
||||||
|
if(m.currentStep%m.stepsPerBeat===0){ const key=m.currentBar*1000+beat;
|
||||||
|
if(key!==lastBeatKey){ lastBeatKey=key; pulseHit(m.groupStarts.has(beat)); } }
|
||||||
|
}
|
||||||
|
requestAnimationFrame(draw);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================= BPM tap-to-edit + drag-to-scrub ==================== */
|
||||||
|
let editingBpm=false;
|
||||||
|
function openBpmEdit(){ editingBpm=true; const i=$("bpmIn"); $("bpm").style.display="none"; i.style.display="block"; i.value=state.bpm; i.focus(); i.select(); }
|
||||||
|
function closeBpmEdit(commit){ const i=$("bpmIn"); if(commit){ const v=parseInt(i.value,10); if(v){ nudge(0); setBpm(v); } } editingBpm=false; i.style.display="none"; $("bpm").style.display="block"; renderAll(); }
|
||||||
|
$("bpmIn").addEventListener("keydown",(e)=>{ if(e.key==="Enter"){ closeBpmEdit(true); } else if(e.key==="Escape"){ closeBpmEdit(false); } });
|
||||||
|
$("bpmIn").addEventListener("blur",()=>{ if(editingBpm) closeBpmEdit(true); });
|
||||||
|
(function(){ // pointer drag on the pulse = scrub tempo; a clean tap (no drag) = type a value
|
||||||
|
const p=$("pulse"); let dragging=false, moved=false, startY=0, startBpm=120;
|
||||||
|
p.addEventListener("pointerdown",(e)=>{ if(editingBpm) return; dragging=true; moved=false; startY=e.clientY; startBpm=state.bpm; p.setPointerCapture(e.pointerId); });
|
||||||
|
p.addEventListener("pointermove",(e)=>{ if(!dragging) return; const dy=startY-e.clientY; if(Math.abs(dy)>6) moved=true; if(moved){ ramp.on=false; setBpm(startBpm+dy*0.5); renderAll(); } });
|
||||||
|
p.addEventListener("pointerup",(e)=>{ if(!dragging) return; dragging=false; try{p.releasePointerCapture(e.pointerId);}catch(_){} if(!moved) openBpmEdit(); });
|
||||||
|
p.addEventListener("pointercancel",()=>{ dragging=false; });
|
||||||
|
})();
|
||||||
|
|
||||||
|
/* ========================= LOAD / SET LISTS ================================== */
|
||||||
|
function setStatus(msg,ok){ const s=$("status"); s.textContent=msg||""; s.className=ok===undefined?"":(ok?"ok":"err"); }
|
||||||
|
function loadConfig(text,quiet){
|
||||||
|
text=(text||"").trim(); if(!text){ if(!quiet) setStatus("Paste a patch or set-list code first.",false); return false; }
|
||||||
|
let payload=text, kind=null;
|
||||||
|
const m=text.match(/[#?&](p|sl)=([^&\s]+)/);
|
||||||
|
if(m){ kind=m[1]; payload=m[2]; }
|
||||||
|
try{ payload=decodeURIComponent(payload); }catch(e){}
|
||||||
|
try{
|
||||||
|
if(kind==="sl" || (kind!=="p" && !/[;:]/.test(payload))){
|
||||||
|
const sl=codeToSetlist(payload);
|
||||||
|
if(!sl.items.length) throw new Error("set list has no items");
|
||||||
|
loadSetlistObj(sl);
|
||||||
|
setStatus("✓ Loaded “"+sl.title+"” — "+sl.items.length+" item(s).",true); return true;
|
||||||
|
}
|
||||||
|
const setup=patchToSetup(payload);
|
||||||
|
if(!setup.lanes.length) throw new Error("no valid lanes (need e.g. kick:4)");
|
||||||
|
loadSetlistObj({title:"Pasted patch",items:[{name:"Patch",...setup}]});
|
||||||
|
setStatus("✓ Loaded patch — "+setup.lanes.length+" lane(s), "+setup.bpm+" BPM.",true); return true;
|
||||||
|
}catch(e){ if(!quiet) setStatus("✗ Invalid: "+e.message,false); return false; }
|
||||||
|
}
|
||||||
|
function loadStored(){
|
||||||
|
let lists=[]; try{ lists=JSON.parse(localStorage.getItem("metronome.setlists")||"[]"); }catch(e){}
|
||||||
|
const sel=$("storedSel"); sel.innerHTML='<option value="">— choose a set list —</option>';
|
||||||
|
const og1=document.createElement("optgroup"); og1.label="Built-in";
|
||||||
|
BUILTIN.forEach((sl,i)=>{ const o=document.createElement("option"); o.value="b"+i; o.textContent=sl.title+" ("+sl.items.length+")"; og1.appendChild(o); });
|
||||||
|
sel.appendChild(og1);
|
||||||
|
if(lists.length){ const og2=document.createElement("optgroup"); og2.label="Your saved set lists";
|
||||||
|
lists.forEach((sl,i)=>{ const o=document.createElement("option"); o.value="s"+i; o.textContent=(sl.title||("Set list "+(i+1)))+" ("+(sl.items?sl.items.length:0)+")"; og2.appendChild(o); });
|
||||||
|
sel.appendChild(og2); }
|
||||||
|
sel._lists=lists; sel._builtin=BUILTIN;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================= MENU SHEET ======================================== */
|
||||||
|
function openSheet(){ loadStored(); $("scrim").classList.add("open"); $("sheet").classList.add("open"); }
|
||||||
|
function closeSheet(){ $("scrim").classList.remove("open"); $("sheet").classList.remove("open"); }
|
||||||
|
$("menuBtn").onclick=openSheet; $("scrim").onclick=closeSheet;
|
||||||
|
$("bLoad").onclick=()=>{ if(loadConfig($("cfg").value)) closeSheet(); };
|
||||||
|
$("storedSel").onchange=(e)=>{ const v=e.target.value; if(!v) return;
|
||||||
|
const sl = v[0]==="b" ? e.target._builtin[+v.slice(1)] : e.target._lists[+v.slice(1)]; if(!sl) return;
|
||||||
|
loadSetlistObj({title:sl.title,items:(sl.items||[]).map(it=>({...it}))}); setStatus("✓ Loaded “"+(sl.title||"set list")+"”.",true); closeSheet(); };
|
||||||
|
$("vol").oninput=(e)=>{ state.volume=(+e.target.value)/100; if(masterGain) masterGain.gain.value=state.volume; };
|
||||||
|
|
||||||
|
/* ========================= TRANSPORT WIRING ================================== */
|
||||||
|
$("bPlay").onclick=toggle;
|
||||||
|
$("bPrev").onclick=()=>gotoItem(idx-1,state.running);
|
||||||
|
$("bNext").onclick=()=>gotoItem(idx+1,state.running);
|
||||||
|
$("bUp").onclick=()=>nudge(+1); $("bDown").onclick=()=>nudge(-1);
|
||||||
|
$("bTap").onclick=tapTempo;
|
||||||
|
|
||||||
|
/* theme toggle (shared "metronome.theme") + version stamp */
|
||||||
|
/*@BUILD:include:src/chrome.js@*/
|
||||||
|
|
||||||
|
/* ========================= FULLSCREEN + WAKE LOCK ============================ */
|
||||||
|
const docEl=document.documentElement;
|
||||||
|
const reqFS=docEl.requestFullscreen||docEl.webkitRequestFullscreen;
|
||||||
|
const exitFS=document.exitFullscreen||document.webkitExitFullscreen;
|
||||||
|
const fsEl=()=>document.fullscreenElement||document.webkitFullscreenElement;
|
||||||
|
let wakeLock=null;
|
||||||
|
async function requestWake(){ try{ if(navigator.wakeLock) wakeLock=await navigator.wakeLock.request("screen"); }catch(e){} }
|
||||||
|
function releaseWake(){ try{ wakeLock&&wakeLock.release(); }catch(e){} wakeLock=null; }
|
||||||
|
function toggleFS(){
|
||||||
|
if(fsEl()){ if(exitFS) try{ exitFS.call(document); }catch(e){} }
|
||||||
|
else if(reqFS){ try{ reqFS.call(docEl); }catch(e){} }
|
||||||
|
}
|
||||||
|
$("fsBtn").onclick=toggleFS;
|
||||||
|
if(window.EMBED) $("fsBtn").style.display="none"; // pointless inside the gallery iframe
|
||||||
|
document.addEventListener("visibilitychange",()=>{ if(document.visibilityState==="visible"&&state.running) requestWake(); });
|
||||||
|
|
||||||
|
/* ========================= PWA: install + service worker ===================== */
|
||||||
|
let deferredPrompt=null;
|
||||||
|
addEventListener("beforeinstallprompt",(e)=>{ e.preventDefault(); deferredPrompt=e; if(!window.EMBED) $("installBtn").style.display="inline-block"; });
|
||||||
|
$("installBtn").onclick=async()=>{ if(!deferredPrompt) return; deferredPrompt.prompt(); await deferredPrompt.userChoice; deferredPrompt=null; $("installBtn").style.display="none"; };
|
||||||
|
addEventListener("appinstalled",()=>{ $("installBtn").style.display="none"; });
|
||||||
|
if(!window.EMBED && "serviceWorker" in navigator){ addEventListener("load",()=>{ navigator.serviceWorker.register("/mobile-sw.js").catch(()=>{}); }); }
|
||||||
|
|
||||||
|
/* ========================= KEYBOARD (desktop testing) ======================== */
|
||||||
|
addEventListener("keydown",(e)=>{
|
||||||
|
const tag=(e.target&&e.target.tagName)||""; if(tag==="INPUT"||tag==="TEXTAREA"||tag==="SELECT") return;
|
||||||
|
const k=e.key;
|
||||||
|
if(k===" "||e.code==="Space"){ e.preventDefault(); toggle(); }
|
||||||
|
else if(k==="ArrowRight"){ e.preventDefault(); gotoItem(idx+1,state.running); }
|
||||||
|
else if(k==="ArrowLeft"){ e.preventDefault(); gotoItem(idx-1,state.running); }
|
||||||
|
else if(k==="ArrowUp"){ e.preventDefault(); nudge(e.shiftKey?10:1); }
|
||||||
|
else if(k==="ArrowDown"){ e.preventDefault(); nudge(e.shiftKey?-10:-1); }
|
||||||
|
else if(k==="t"||k==="T") tapTempo();
|
||||||
|
else if(k==="f"||k==="F") toggleFS();
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ========================= INIT ============================================== */
|
||||||
|
loadStored();
|
||||||
|
if(location.hash && /(p|sl)=/.test(location.hash)) loadConfig(location.hash, true);
|
||||||
|
if(!setlist){ setlist=BUILTIN[0]; idx=0; loadSetup(setlist.items[0]); }
|
||||||
|
$("vol").value=Math.round(state.volume*100);
|
||||||
|
renderAll();
|
||||||
|
requestAnimationFrame(draw);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Loading…
Reference in a new issue