208 lines
10 KiB
HTML
208 lines
10 KiB
HTML
<!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>
|