Files
CelloEffects/remote/cellofx-remote.html
T

208 lines
10 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="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>CelloFX Remote</title>
<meta name="description" content="Télécommande BLE des effets CelloFX (STM32H7S78-DK)">
<meta name="theme-color" content="#14161c">
<meta name="webapp:icon" content="🎻">
<meta name="webapp:orientation" content="portrait">
<style>
:root{--bg:#14161c;--card:#1e222b;--fg:#e8eaf0;--mut:#8b92a3;--acc:#ff9f43;--ok:#3ddc97;--ko:#ff5d5d;--r:14px}
@media (prefers-color-scheme:light){:root{--bg:#f2f3f7;--card:#fff;--fg:#1b1e26;--mut:#6b7285}}
*{box-sizing:border-box;-webkit-tap-highlight-color:transparent}
body{margin:0;background:var(--bg);color:var(--fg);font:16px system-ui,sans-serif;
padding:env(safe-area-inset-top) 16px calc(env(safe-area-inset-bottom) + 24px)}
header{position:sticky;top:0;z-index:5;background:var(--bg);padding:12px 0;display:flex;gap:10px;align-items:center}
header h1{font-size:20px;margin:0;flex:1}
.dot{width:10px;height:10px;border-radius:50%;background:var(--ko);display:inline-block;margin-right:6px}
.dot.on{background:var(--ok)}
button.main{min-height:44px;padding:0 16px;border:0;border-radius:22px;background:var(--acc);color:#1b1200;font-weight:700;font-size:15px}
.card{background:var(--card);border-radius:var(--r);padding:14px 16px;margin:12px 0}
.card h2{font-size:17px;margin:0;display:flex;align-items:center;justify-content:space-between;min-height:44px}
.row{margin:10px 0}
.row label{display:flex;justify-content:space-between;color:var(--mut);font-size:14px}
.row b{color:var(--fg);font-variant-numeric:tabular-nums}
input[type=range]{width:100%;height:36px;accent-color:var(--acc)}
.sw{position:relative;width:52px;height:30px;flex:none}
.sw input{opacity:0;position:absolute;inset:0;width:100%;height:100%;margin:0;z-index:2}
.sw i{position:absolute;inset:0;border-radius:15px;background:#555;transition:.15s}
.sw i::after{content:"";position:absolute;top:3px;left:3px;width:24px;height:24px;border-radius:50%;background:#fff;transition:.15s}
.sw input:checked+i{background:var(--acc)}
.sw input:checked+i::after{transform:translateX(22px)}
.eq{display:grid;grid-template-columns:repeat(10,1fr);gap:2px;height:210px;margin-top:8px}
.eq div{display:flex;flex-direction:column;align-items:center;font-size:11px;color:var(--mut)}
.eq input{writing-mode:vertical-lr;direction:rtl;flex:1;width:100%;min-width:0;margin:0}
.eq span{margin-top:4px}
#msg{color:var(--mut);font-size:13px;margin:4px 0 0;min-height:1.2em}
</style>
</head>
<body>
<header>
<h1><span class="dot" id="dot"></span>CelloFX</h1>
<button class="main" id="btn">Connecter</button>
</header>
<p id="msg"></p>
<main id="app"></main>
<script>
"use strict";
const SERVICE = "c3110000-7e8a-4b1c-9d52-f0a1e4b7c600";
const CHAR_CTRL = "c3110001-7e8a-4b1c-9d52-f0a1e4b7c600";
const CHAR_STATE = "c3110002-7e8a-4b1c-9d52-f0a1e4b7c600";
const SNAPSHOT = 0xFF, MAX_RECORDS = 6; /* 18 octets : tient dans un MTU par defaut de 23 */
/* kind: b = interrupteur, u = 0..1 (fil 0..1000), g = dB (fil x10), ms = millisecondes */
const U = (id, name) => ({id, name, kind: "u", min: 0, max: 1000});
const B = id => ({id, kind: "b"});
const EQ = ["31","62","125","250","500","1k","2k","4k","8k","16k"];
const SECTIONS = [
{title: "Général", on: B(0x00), params: [U(0x01, "Volume")]},
{title: "Octaver", on: B(0x10), params: [U(0x11,"−2 oct"),U(0x12,"−1 oct"),U(0x13,"Sec"),U(0x14,"+1 oct"),U(0x15,"+2 oct")]},
{title: "Égaliseur", on: B(0x20), eq: true},
{title: "Overdrive", on: B(0x50), params: [U(0x51,"Drive"),U(0x52,"Tone"),U(0x53,"Niveau")]},
{title: "Saturation", on: B(0x60), params: [U(0x61,"Drive"),U(0x62,"Niveau")]},
{title: "Echo", on: B(0x70), params: [{id:0x71,name:"Délai",kind:"ms",min:0,max:1000},{id:0x72,name:"Feedback",kind:"u",min:0,max:950},U(0x73,"Mix")]},
{title: "Reverb", on: B(0x80), params: [U(0x81,"Taille"),U(0x82,"Amortissement"),U(0x83,"Mix")]},
];
const state = new Map(); /* id -> valeur fil */
const widgets = new Map(); /* id -> fonction de mise a jour de l'UI */
let link = null, pending = new Map(), timer = 0, demo = false;
const $ = s => document.querySelector(s);
const msg = t => { $("#msg").textContent = t || ""; };
function fmt(p, v) {
if (p.kind === "g") return (v / 10).toFixed(1) + " dB";
if (p.kind === "ms") return v + " ms";
return Math.round(v / 10) + " %";
}
function slider(p, label, extraClass) {
const wrap = document.createElement("div");
wrap.className = extraClass || "row";
const input = document.createElement("input");
input.type = "range"; input.min = p.min; input.max = p.max; input.step = p.kind === "g" ? 5 : (p.kind === "ms" ? 5 : 5);
input.value = p.min;
const val = document.createElement("b");
const upd = v => { input.value = v; val.textContent = fmt(p, v); };
input.addEventListener("input", () => { val.textContent = fmt(p, +input.value); send(p.id, +input.value); });
widgets.set(p.id, upd);
if (extraClass) { wrap.append(input); wrap.dataset.val = ""; const s = document.createElement("span"); s.textContent = label; wrap.append(s); }
else { const l = document.createElement("label"); l.append(label, val); wrap.append(l, input); }
upd(p.min);
return wrap;
}
function build() {
const app = $("#app");
for (const s of SECTIONS) {
const card = document.createElement("section"); card.className = "card";
const h = document.createElement("h2"); h.append(s.title);
const sw = document.createElement("label"); sw.className = "sw";
const cb = document.createElement("input"); cb.type = "checkbox";
cb.addEventListener("change", () => { navigator.vibrate?.(15); send(s.on.id, cb.checked ? 1 : 0); });
sw.append(cb, document.createElement("i")); h.append(sw); card.append(h);
widgets.set(s.on.id, v => { cb.checked = !!v; });
if (s.eq) {
const eq = document.createElement("div"); eq.className = "eq";
EQ.forEach((f, i) => eq.append(slider({id: 0x30 + i, kind: "g", min: -240, max: 240}, f, "col")));
card.append(eq);
eq.querySelectorAll(".col").forEach(c => c.style.cssText = "display:flex;flex-direction:column;align-items:center;font-size:11px;color:var(--mut)");
} else for (const p of s.params) card.append(slider(p, p.name));
app.append(card);
}
}
/* ---- envoi : on garde la derniere valeur par id et on groupe ~40 ms ---- */
function send(id, value) {
state.set(id, value);
pending.set(id, value);
if (!timer) timer = setTimeout(flush, 40);
}
async function flush() {
timer = 0;
const items = [...pending]; pending = new Map();
if (!link) return;
try {
for (let i = 0; i < items.length; i += MAX_RECORDS) {
const chunk = items.slice(i, i + MAX_RECORDS);
const buf = new DataView(new ArrayBuffer(chunk.length * 3));
chunk.forEach(([id, v], k) => { buf.setUint8(k * 3, id); buf.setInt16(k * 3 + 1, v, true); });
await link.write(new Uint8Array(buf.buffer), false);
}
} catch (e) { msg("Envoi impossible : " + e.message); }
}
function onState(dv) {
for (let o = 0; o + 3 <= dv.byteLength; o += 3) {
const id = dv.getUint8(o), v = dv.getInt16(o + 1, true);
state.set(id, v); widgets.get(id)?.(v);
}
}
/* ---- connexion ----
* Deux transports, meme interface {write(bytes, avecReponse), close()} :
* - app WebApp : pont natif Android.ble (Web Bluetooth n'existe pas dans sa WebView)
* - Chrome : Web Bluetooth (page en contexte securise) */
function setConnected(on) { $("#dot").classList.toggle("on", on); $("#btn").textContent = on ? "Déconnecter" : "Connecter"; }
function lost() { link = null; setConnected(false); msg("Déconnecté."); }
async function connectNative() {
const B = window.Android.ble;
const dev = await B.connect({name: "CelloFX", service: SERVICE, timeout: 10000});
Android.on("ble.disconnect", e => { if (e.id === dev.id) lost(); });
await B.subscribe(dev.id, SERVICE, CHAR_STATE, bytes => onState(new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)));
link = {
write: (bytes, resp) => B.write(dev.id, SERVICE, CHAR_CTRL, bytes, {response: resp}),
close: () => B.disconnect(dev.id),
};
await link.write(new Uint8Array([SNAPSHOT, 0, 0]), true);
}
async function connectWeb() {
const device = await navigator.bluetooth.requestDevice({filters: [{name: "CelloFX"}], optionalServices: [SERVICE]});
device.addEventListener("gattserverdisconnected", lost);
const server = await device.gatt.connect();
const svc = await server.getPrimaryService(SERVICE);
const ctrl = await svc.getCharacteristic(CHAR_CTRL);
const st = await svc.getCharacteristic(CHAR_STATE);
st.addEventListener("characteristicvaluechanged", ev => onState(ev.target.value));
await st.startNotifications(); /* le module envoie alors un instantane */
link = {
write: (bytes, resp) => resp ? ctrl.writeValueWithResponse(bytes) : ctrl.writeValueWithoutResponse(bytes),
close: () => device.gatt.connected && device.gatt.disconnect(),
};
await link.write(new Uint8Array([SNAPSHOT, 0, 0]), true); /* au cas ou */
}
async function connect() {
const native = window.Android?.isNative && window.Android.ble;
if (!native && !navigator.bluetooth) {
demo = true; setConnected(true);
msg(window.isSecureContext
? "Web Bluetooth indisponible dans ce navigateur : mode démo (rien n'est envoyé). Utilise l'app WebApp ou Chrome Android."
: "Page non sécurisée : Web Bluetooth est bloqué (mode démo). Sers la page en HTTPS ou autorise l'origine dans chrome://flags (voir remote/serve.sh).");
return;
}
try {
msg("Recherche de CelloFX…");
await (native ? connectNative() : connectWeb());
setConnected(true); msg("Connecté.");
} catch (e) {
link = null; setConnected(false);
msg(e.name === "NotFoundError" ? "Aucun appareil choisi."
: e.code === "not_found" ? "CelloFX introuvable : la carte est-elle allumée et à portée ?"
: e.code === "permission_denied" ? "Autorisation Bluetooth refusée (menu WebApp → Autorisations)."
: "Connexion échouée : " + e.message);
}
}
function disconnect() { link?.close(); link = null; demo = false; setConnected(false); msg(""); }
$("#btn").addEventListener("click", () => ($("#dot").classList.contains("on") ? disconnect() : connect()));
build();
</script>
</body>
</html>