App web per il telefono (HTTPS, PIN, WebSocket/SSE/interrogazione, voce, immagini, PDF, impostazioni), stati dettagliati, maestre di violino e canto, PDF, sessioni Claude Code, plugin manager rifatto
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
a078dff608
commit
706c80cc20
23 files changed
+1944
-50
No files matched your search
+240
@@ -0,0 +1,240 @@
|
||||
// LuZa remoto: WebSocket per stato/registro/audio, microfono via MediaRecorder, audio via AudioContext.
|
||||
const q = new URLSearchParams(location.search);
|
||||
if (q.get('k')) { try { localStorage.setItem('luza_key', q.get('k')); } catch (e) {} history.replaceState(null, '', location.pathname); }
|
||||
let KEY = ''; try { KEY = localStorage.getItem('luza_key') || ''; } catch (e) {}
|
||||
const $ = id => document.getElementById(id);
|
||||
let ws, actx, playing = false, queue = [], rec, chunks = [], recording = false, currentAva = null;
|
||||
|
||||
async function savePair() {
|
||||
const v = $('key').value.trim();
|
||||
if (!v) return;
|
||||
if (/^\d{6}$/.test(v)) { // PIN mostrato da Remote Control: lo scambio con la chiave
|
||||
try {
|
||||
const r = await fetch(`/pair?pin=${v}`, {cache: 'no-store'}); const j = await r.json();
|
||||
if (!r.ok || !j.token) { addMsg(j.error || 'PIN rifiutato', 'err'); return; }
|
||||
KEY = j.token;
|
||||
} catch (e) { addMsg('rete: ' + e.message, 'err'); return; }
|
||||
} else KEY = v;
|
||||
try { localStorage.setItem('luza_key', KEY); } catch (e) {}
|
||||
$('key').value = ''; connect();
|
||||
}
|
||||
function setConn(t, ok) {
|
||||
if (ok) document.querySelectorAll('#log .m.err').forEach(e => e.remove()); $('conn').textContent = t + (mode === 'sse' ? ' · eventi' : mode === 'poll' ? ' · interrogazione' : ''); $('conn').style.color = ok ? '#00ff88' : '#ff3366'; }
|
||||
const HUD = {state: 'LISTENING', level: 0, rec: false};
|
||||
function setState(s) {
|
||||
const base = s.split(' ')[0], detail = s.includes(' · ') ? s.split(' · ').slice(1).join(' · ') : '';
|
||||
HUD.state = base; updateStop();
|
||||
$('stbase').textContent = base; $('stdetail').textContent = detail ? ' · ' + detail : ({LISTENING: ' · in ascolto', THINKING: ' · penso', PROCESSING: ' · elaboro', SPEAKING: ' · parlo', SLEEPING: ' · a riposo'}[base] || '');
|
||||
}
|
||||
function addMsg(text, cls) {
|
||||
const d = document.createElement('div'); d.className = 'm ' + cls; d.textContent = text; $('log').appendChild(d);
|
||||
while ($('log').children.length > 200) $('log').removeChild($('log').firstChild);
|
||||
$('log').scrollTop = $('log').scrollHeight; return d;
|
||||
}
|
||||
function onLog(line) {
|
||||
const m = line.match(/^(You(?: \(telefono\))?|SYS|ERR|FILE|[^:]{1,24}): ?([\s\S]*)$/);
|
||||
if (!m) { addMsg(line, 'sys'); return; }
|
||||
const who = m[1], txt = m[2];
|
||||
if (who.startsWith('You')) addMsg(txt, 'you');
|
||||
else if (who === 'SYS' || who === 'FILE') addMsg(txt, 'sys');
|
||||
else if (who === 'ERR') addMsg(txt, 'err');
|
||||
else addMsg(txt, 'ava');
|
||||
}
|
||||
let wsFailures = 0, es = null, mode = 'ws';
|
||||
function handle(d) {
|
||||
if (d.type === 'state') setState(d.state);
|
||||
else if (d.type === 'log') onLog(d.text);
|
||||
else if (d.type === 'audio') enqueueAudio(d.wav);
|
||||
else if (d.type === 'confirm') { $('ct').textContent = d.title; $('cd').textContent = d.detail || ''; $('confirm').style.display = 'block'; }
|
||||
else if (d.type === 'confirm_hide') $('confirm').style.display = 'none';
|
||||
else if (d.type === 'image') addImage(d.url, d.name);
|
||||
else if (d.type === 'file') { const m = addMsg('', 'ava file'); const a = document.createElement('a'); a.href = d.url; a.target = '_blank'; a.textContent = '📄 ' + d.name; m.appendChild(a); }
|
||||
}
|
||||
function addImage(url, name) {
|
||||
const d = document.createElement('div'); d.className = 'm ava img';
|
||||
const img = document.createElement('img'); img.src = url; img.alt = name || 'immagine'; img.loading = 'lazy';
|
||||
img.onclick = () => { const v = document.createElement('div'); v.className = 'viewer'; const big = document.createElement('img'); big.src = url; v.appendChild(big); v.onclick = () => v.remove(); document.body.appendChild(v); };
|
||||
d.appendChild(img); $('log').appendChild(d); $('log').scrollTop = $('log').scrollHeight;
|
||||
}
|
||||
function unpair(msg) { KEY = ''; try { localStorage.removeItem('luza_key'); } catch (e) {} $('pair').style.display = 'flex'; setConn(msg || 'chiave rifiutata', false); addMsg('Chiave non valida: inquadra di nuovo il QR dal Mac oppure incolla la chiave.', 'err'); }
|
||||
async function connect() {
|
||||
if (!KEY) { $('pair').style.display = 'flex'; setConn('non abbinato', false); return; }
|
||||
$('pair').style.display = 'none';
|
||||
try { // verifica della chiave con una richiesta breve, prima di aprire i canali
|
||||
const r = await fetch(`/auth?k=${encodeURIComponent(KEY)}`, {cache: 'no-store'});
|
||||
if (r.status === 401) return unpair('chiave rifiutata');
|
||||
} catch (e) { setConn('rete non raggiungibile, riprovo…', false); return setTimeout(connect, 3000); }
|
||||
if (mode === 'poll') return startPoll();
|
||||
if (mode === 'sse') return connectSSE();
|
||||
try { ws && ws.close(); } catch (e) {}
|
||||
let opened = false;
|
||||
ws = new WebSocket(`wss://${location.host}/ws?k=${encodeURIComponent(KEY)}`);
|
||||
ws.onopen = () => { opened = true; wsFailures = 0; setConn('collegato', true); };
|
||||
ws.onclose = ev => {
|
||||
if (!opened) wsFailures++;
|
||||
if (ev.code === 1008) return unpair();
|
||||
if (wsFailures >= 2) { mode = 'sse'; return connectSSE(); } // WebSocket bloccato (app sulla Home di iOS): canale di riserva
|
||||
setConn('scollegato, riprovo…', false); setTimeout(connect, 2000);
|
||||
};
|
||||
ws.onerror = () => {};
|
||||
ws.onmessage = ev => handle(JSON.parse(ev.data));
|
||||
}
|
||||
let sseFailures = 0, CID = Math.random().toString(36).slice(2), polling = false;
|
||||
function connectSSE() {
|
||||
try { es && es.close(); } catch (e) {}
|
||||
es = new EventSource(`/events?k=${encodeURIComponent(KEY)}`);
|
||||
es.onopen = () => { sseFailures = 0; setConn('collegato', true); };
|
||||
es.onmessage = ev => handle(JSON.parse(ev.data));
|
||||
es.onerror = () => {
|
||||
es.close(); sseFailures++;
|
||||
if (sseFailures >= 2) { mode = 'poll'; return startPoll(); } // anche gli eventi sono bloccati: interrogazione ripetuta
|
||||
setConn('scollegato, riprovo…', false); setTimeout(connectSSE, 2500);
|
||||
};
|
||||
}
|
||||
async function startPoll() {
|
||||
if (polling) return; polling = true;
|
||||
setConn('collegato', true);
|
||||
while (mode === 'poll') {
|
||||
try {
|
||||
const r = await fetch(`/poll?k=${encodeURIComponent(KEY)}&cid=${CID}`, {cache: 'no-store'});
|
||||
if (r.status === 401) { polling = false; return unpair(); }
|
||||
const j = await r.json(); (j.events || []).forEach(handle); setConn('collegato', true);
|
||||
} catch (e) { setConn('scollegato, riprovo…', false); await new Promise(res => setTimeout(res, 2500)); }
|
||||
}
|
||||
polling = false;
|
||||
}
|
||||
function send(obj) {
|
||||
if (mode === 'ws' && ws && ws.readyState === 1) { ws.send(JSON.stringify(obj)); return; }
|
||||
fetch(`/cmd?k=${encodeURIComponent(KEY)}`, {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(obj)})
|
||||
.then(r => { if (r.status === 401) unpair(); })
|
||||
.catch(() => addMsg('non collegato', 'err'));
|
||||
}
|
||||
function sendText() { const t = $('txt').value.trim(); if (!t) return; unlockAudio(); send({type: 'say', text: t}); $('txt').value = ''; onTyping(); }
|
||||
function onTyping() { $('sendb').classList.toggle('on', $('txt').value.trim().length > 0); }
|
||||
function updateStop() { const busy = playing || ['THINKING', 'PROCESSING', 'SPEAKING'].includes(HUD.state); $('stopb').classList.toggle('on', busy); }
|
||||
function doConfirm(ok) { send({type: 'confirm', ok}); $('confirm').style.display = 'none'; }
|
||||
|
||||
// ── audio in uscita: coda di frasi WAV riprodotte in sequenza ─────────────
|
||||
let analyser = null, adata = null, micAnalyser = null, mdata = null;
|
||||
function unlockAudio() {
|
||||
if (!actx) { actx = new (window.AudioContext || window.webkitAudioContext)(); analyser = actx.createAnalyser(); analyser.fftSize = 256; analyser.connect(actx.destination); adata = new Uint8Array(analyser.frequencyBinCount); }
|
||||
if (actx.state === 'suspended') actx.resume();
|
||||
}
|
||||
function enqueueAudio(b64) { queue.push(b64); if (!playing) playNext(); }
|
||||
async function playNext() {
|
||||
if (!queue.length) { playing = false; updateStop(); return; }
|
||||
playing = true; updateStop();
|
||||
try {
|
||||
unlockAudio();
|
||||
const b64 = queue.shift(); const bin = atob(b64); const buf = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) buf[i] = bin.charCodeAt(i);
|
||||
const audio = await actx.decodeAudioData(buf.buffer);
|
||||
const src = actx.createBufferSource(); src.buffer = audio; src.connect(analyser); src.onended = playNext; src.start();
|
||||
} catch (e) { addMsg('audio non riproducibile: tocca lo schermo e riprova', 'err'); playing = false; }
|
||||
}
|
||||
|
||||
// ── microfono: tocca per registrare, tocca di nuovo per inviare ────────────
|
||||
async function toggleMic() {
|
||||
unlockAudio();
|
||||
if (recording) { recording = false; rec.stop(); $('mic').classList.remove('rec'); return; }
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({audio: true});
|
||||
const mime = ['audio/mp4', 'audio/webm;codecs=opus', 'audio/webm'].find(m => window.MediaRecorder && MediaRecorder.isTypeSupported(m)) || '';
|
||||
try { const msrc = actx.createMediaStreamSource(stream); micAnalyser = actx.createAnalyser(); micAnalyser.fftSize = 256; msrc.connect(micAnalyser); mdata = new Uint8Array(micAnalyser.frequencyBinCount); } catch (e) { micAnalyser = null; }
|
||||
rec = new MediaRecorder(stream, mime ? {mimeType: mime} : undefined); chunks = [];
|
||||
rec.ondataavailable = e => { if (e.data.size) chunks.push(e.data); };
|
||||
rec.onstop = async () => {
|
||||
stream.getTracks().forEach(t => t.stop()); micAnalyser = null; HUD.rec = false;
|
||||
const blob = new Blob(chunks, {type: rec.mimeType || 'audio/webm'});
|
||||
$('stbase').textContent = 'PROCESSING'; $('stdetail').textContent = ' · trascrivo';
|
||||
try {
|
||||
const r = await fetch(`/stt?k=${encodeURIComponent(KEY)}&run=1`, {method: 'POST', body: blob, headers: {'Content-Type': blob.type || 'application/octet-stream'}});
|
||||
const j = await r.json();
|
||||
if (!j.text) addMsg(j.error || 'non ho capito, riprova', 'err');
|
||||
} catch (e) { addMsg('invio audio fallito: ' + e, 'err'); }
|
||||
};
|
||||
rec.start(); recording = true; HUD.rec = true; $('mic').classList.add('rec'); $('stbase').textContent = 'REC'; $('stdetail').textContent = ' · registro, tocca di nuovo per inviare';
|
||||
} catch (e) { addMsg('microfono non disponibile: serve HTTPS con certificato accettato (' + e.message + ')', 'err'); }
|
||||
}
|
||||
if ('serviceWorker' in navigator) navigator.serviceWorker.register('/sw.js').catch(() => {});
|
||||
document.addEventListener('touchstart', unlockAudio, {once: true, passive: true});
|
||||
if (navigator.standalone) addMsg('app sulla Home', 'sys');
|
||||
if (q.get('demo')) { setTimeout(() => { addMsg('Ciao LuZa, che tempo fa oggi a Pescara?', 'you'); addMsg('Lavoro sul Mac (meteo)…', 'sys'); addMsg('Oggi a Pescara è prevalentemente sereno, con una massima di 22 gradi. Domani un po\' di nuvole.', 'ava'); setState('THINKING · genero la risposta · 120 token · 59/s'); HUD.level = 0.55; }, 300); }
|
||||
fetch('/health').then(r => r.ok ? null : addMsg('server risponde ' + r.status, 'err')).catch(e => addMsg('rete: ' + e.message, 'err'));
|
||||
connect();
|
||||
|
||||
// ── impostazioni: motore, modello, ragionamento, voce ─────────────────────
|
||||
const LABELS = {provider: 'Motore', effort: 'Profondità (Claude)', mlx_model: 'Modello interno', mlx_thinking: 'Ragionamento (interno)', claudecode_model: 'Modello Claude Code',
|
||||
tts_engine: 'Voce', kokoro_voice: 'Voce Kokoro', voicebox_profile_id: 'Profilo Voicebox', immagini_famiglia: 'Immagini: famiglia', immagini_modello: 'Immagini: modello'};
|
||||
const SHOW_IF = {mlx_model: v => v.provider === 'mlx', mlx_thinking: v => v.provider === 'mlx', claudecode_model: v => v.provider === 'claudecode', effort: v => v.provider !== 'mlx' && v.provider !== 'local',
|
||||
kokoro_voice: v => v.tts_engine === 'kokoro', voicebox_profile_id: v => v.tts_engine === 'voicebox'};
|
||||
let sdata = null;
|
||||
async function openSettings() {
|
||||
$('settings').style.display = 'block'; $('shint').textContent = 'carico…';
|
||||
try { const r = await fetch(`/settings?k=${encodeURIComponent(KEY)}`, {cache: 'no-store'}); sdata = await r.json(); } catch (e) { $('shint').textContent = 'errore: ' + e.message; return; }
|
||||
renderSettings(); $('shint').textContent = '';
|
||||
}
|
||||
function renderSettings() {
|
||||
const f = $('sform'); f.innerHTML = ''; const v = sdata.values;
|
||||
for (const k of Object.keys(LABELS)) {
|
||||
const opts = sdata.options[k]; if (!opts || (SHOW_IF[k] && !SHOW_IF[k](v))) continue;
|
||||
const lab = document.createElement('label'); lab.textContent = LABELS[k]; f.appendChild(lab);
|
||||
const sel = document.createElement('select'); sel.dataset.key = k;
|
||||
let found = false;
|
||||
for (const [val, name] of opts) { const o = document.createElement('option'); o.value = val; o.textContent = name; if (String(val) === String(v[k] ?? '')) { o.selected = true; found = true; } sel.appendChild(o); }
|
||||
if (!found && v[k]) { const o = document.createElement('option'); o.value = v[k]; o.textContent = v[k] + ' (attuale)'; o.selected = true; sel.appendChild(o); }
|
||||
sel.onchange = () => { v[k] = sel.value; renderSettings(); }; f.appendChild(sel);
|
||||
}
|
||||
}
|
||||
async function saveSettings() {
|
||||
if (!sdata) return; const out = {}; document.querySelectorAll('#sform select').forEach(s => out[s.dataset.key] = s.value);
|
||||
$('shint').textContent = 'applico…';
|
||||
try { const r = await fetch(`/settings?k=${encodeURIComponent(KEY)}`, {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(out)}); const j = await r.json();
|
||||
$('shint').textContent = j.ok ? 'applicato: il Mac ricarica motore e voce' : ('errore: ' + j.error); } catch (e) { $('shint').textContent = 'errore: ' + e.message; }
|
||||
}
|
||||
|
||||
// ── HUD: nucleo con archi rotanti e forma d'onda ──────────────────────────
|
||||
const COL = {LISTENING: '#00ff88', THINKING: '#ffcc00', PROCESSING: '#ffcc00', SPEAKING: '#ff6b00', SLEEPING: '#3a8a9a', REC: '#ff3366'};
|
||||
const ringC = $('ring'), waveC = $('wave');
|
||||
function fitCanvas(c) { const r = window.devicePixelRatio || 1, w = c.clientWidth, h = c.clientHeight; if (c.width !== w * r || c.height !== h * r) { c.width = w * r; c.height = h * r; } return [c.getContext('2d'), w, h, r]; }
|
||||
function level() {
|
||||
if (HUD.rec && micAnalyser) { micAnalyser.getByteTimeDomainData(mdata); let m = 0; for (const v of mdata) m = Math.max(m, Math.abs(v - 128)); return Math.min(1, m / 60); }
|
||||
if (playing && analyser) { analyser.getByteTimeDomainData(adata); let m = 0; for (const v of adata) m = Math.max(m, Math.abs(v - 128)); return Math.min(1, m / 50); }
|
||||
return 0;
|
||||
}
|
||||
let t0 = performance.now();
|
||||
function drawHUD(now) {
|
||||
const t = (now - t0) / 1000; const lv = level(); HUD.level += (lv - HUD.level) * 0.35;
|
||||
const st = HUD.rec ? 'REC' : (playing ? 'SPEAKING' : HUD.state); const col = COL[st] || '#00d4ff';
|
||||
const busy = st === 'THINKING' || st === 'PROCESSING';
|
||||
// nucleo
|
||||
let [g, w, h, r] = fitCanvas(ringC); g.setTransform(r, 0, 0, r, 0, 0); g.clearRect(0, 0, w, h);
|
||||
const cx = w / 2, cy = h / 2 - 8, R = Math.min(w, h) * 0.34;
|
||||
const speed = busy ? 2.2 : st === 'SPEAKING' ? 1.6 : 0.5;
|
||||
g.lineCap = 'round';
|
||||
const arcs = [[1.0, 0.9, 1, 1.4], [0.86, -0.6, 2, 1.1], [0.72, 1.3, 1, 0.8], [0.58, -1.9, 3, 0.6]];
|
||||
for (const [rr, dir, n, span] of arcs) {
|
||||
for (let i = 0; i < n; i++) {
|
||||
const a0 = t * speed * dir + i * (Math.PI * 2 / n); g.beginPath(); g.arc(cx, cy, R * rr, a0, a0 + span); g.strokeStyle = col; g.globalAlpha = 0.85 - (1 - rr) * 0.9; g.lineWidth = 2; g.shadowColor = col; g.shadowBlur = 10; g.stroke();
|
||||
}
|
||||
}
|
||||
g.globalAlpha = 1; g.shadowBlur = 0;
|
||||
// tacche
|
||||
g.strokeStyle = 'rgba(0,212,255,.35)'; g.lineWidth = 1;
|
||||
for (let i = 0; i < 60; i++) { const a = i / 60 * Math.PI * 2 + t * 0.15; const l = i % 5 === 0 ? 8 : 4; g.beginPath(); g.moveTo(cx + Math.cos(a) * (R * 1.12), cy + Math.sin(a) * (R * 1.12)); g.lineTo(cx + Math.cos(a) * (R * 1.12 + l), cy + Math.sin(a) * (R * 1.12 + l)); g.stroke(); }
|
||||
// sfera centrale che respira con l'audio
|
||||
const rad = R * 0.42 * (1 + HUD.level * 0.35 + (busy ? Math.sin(t * 6) * 0.04 : Math.sin(t * 1.5) * 0.02));
|
||||
const grad = g.createRadialGradient(cx, cy, rad * 0.1, cx, cy, rad); grad.addColorStop(0, col); grad.addColorStop(0.55, col + '66'); grad.addColorStop(1, 'rgba(0,0,0,0)');
|
||||
g.fillStyle = grad; g.beginPath(); g.arc(cx, cy, rad, 0, Math.PI * 2); g.fill();
|
||||
g.strokeStyle = col; g.lineWidth = 1.5; g.shadowColor = col; g.shadowBlur = 16; g.beginPath(); g.arc(cx, cy, rad * 0.9, 0, Math.PI * 2); g.stroke(); g.shadowBlur = 0;
|
||||
// forma d'onda
|
||||
[g, w, h, r] = fitCanvas(waveC); g.setTransform(r, 0, 0, r, 0, 0); g.clearRect(0, 0, w, h);
|
||||
const N = 42, bw = w / N;
|
||||
for (let i = 0; i < N; i++) {
|
||||
const env = Math.pow(1 - Math.abs(i - (N - 1) / 2) / ((N - 1) / 2), 0.7);
|
||||
const idle = 2 + 2 * Math.sin(t * 2.2 + i * 0.6);
|
||||
const hh = Math.max(2, Math.min(h - 4, idle + HUD.level * (h - 6) * env * (0.6 + 0.4 * Math.sin(t * 9 + i))));
|
||||
g.fillStyle = HUD.level > 0.05 ? col : 'rgba(0,212,255,.25)'; g.fillRect(i * bw + 1, (h - hh) / 2, bw - 2, hh);
|
||||
}
|
||||
requestAnimationFrame(drawHUD);
|
||||
}
|
||||
requestAnimationFrame(drawHUD);
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 8.7 KiB |
@@ -0,0 +1,104 @@
|
||||
<!doctype html>
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, user-scalable=no">
|
||||
<meta name="theme-color" content="#02070c">
|
||||
<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="LuZa">
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
<link rel="apple-touch-icon" href="/icon-192.png">
|
||||
<title>LuZa</title>
|
||||
<style>
|
||||
:root { --bg:#02070c; --pri:#00d4ff; --pri2:#00ffe1; --dim:#4f7f95; --txt:#d6ecff; --acc:#ffcc00; --spk:#ff6b00; --grn:#00ff88; --red:#ff3366; --glass:rgba(4,20,32,.72); --line:rgba(0,212,255,.22); }
|
||||
* { box-sizing:border-box; -webkit-tap-highlight-color:transparent; }
|
||||
html,body { margin:0; height:100%; background:var(--bg); color:var(--txt); font-family:Menlo,ui-monospace,SFMono-Regular,monospace; overflow:hidden; }
|
||||
body { display:flex; flex-direction:column; padding:env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left); position:relative; }
|
||||
/* sfondo: griglia di punti + scanline + vignetta */
|
||||
body::before { content:""; position:fixed; inset:0; background-image:radial-gradient(rgba(0,212,255,.16) 1px, transparent 1.2px); background-size:22px 22px; pointer-events:none; }
|
||||
body::after { content:""; position:fixed; inset:0; background:repeating-linear-gradient(0deg, rgba(0,0,0,.18) 0 2px, transparent 2px 4px), radial-gradient(ellipse at center, transparent 55%, rgba(0,0,0,.65) 100%); pointer-events:none; }
|
||||
.corner { position:fixed; width:26px; height:26px; border:1px solid var(--line); pointer-events:none; }
|
||||
.corner.tl { top:calc(env(safe-area-inset-top) + 8px); left:8px; border-right:0; border-bottom:0; } .corner.tr { top:calc(env(safe-area-inset-top) + 8px); right:8px; border-left:0; border-bottom:0; }
|
||||
.corner.bl { bottom:calc(env(safe-area-inset-bottom) + 8px); left:8px; border-right:0; border-top:0; } .corner.br { bottom:calc(env(safe-area-inset-bottom) + 8px); right:8px; border-left:0; border-top:0; }
|
||||
header { padding:12px 18px 4px; display:flex; align-items:center; gap:10px; z-index:1; }
|
||||
header .name { font-weight:bold; letter-spacing:5px; color:var(--pri); text-shadow:0 0 12px rgba(0,212,255,.6); font-size:15px; }
|
||||
header .sub { font-size:9px; letter-spacing:2px; color:var(--dim); margin-top:2px; }
|
||||
header .conn { margin-left:auto; font-size:10px; letter-spacing:1px; color:var(--dim); text-transform:uppercase; }
|
||||
header .conn::before { content:"●"; margin-right:5px; }
|
||||
#gear { background:transparent; border:1px solid var(--line); color:var(--pri); border-radius:8px; padding:5px 9px; font-size:15px; }
|
||||
#core { position:relative; height:190px; z-index:1; }
|
||||
#ring { position:absolute; inset:0; width:100%; height:100%; }
|
||||
#stateline { position:absolute; left:0; right:0; bottom:8px; text-align:center; font-size:11px; letter-spacing:2px; color:var(--dim); text-transform:uppercase; }
|
||||
#stateline b { color:var(--pri); font-weight:normal; }
|
||||
#wave { display:block; width:100%; height:34px; z-index:1; }
|
||||
#log { flex:1; overflow-y:auto; padding:6px 12px 10px; display:flex; flex-direction:column; gap:7px; z-index:1; -webkit-overflow-scrolling:touch; }
|
||||
.m { max-width:88%; padding:9px 12px; border-radius:4px; font-size:14px; line-height:1.4; white-space:pre-wrap; word-break:break-word; position:relative; background:var(--glass); backdrop-filter:blur(6px); border:1px solid var(--line); }
|
||||
.m.you { align-self:flex-end; border-color:rgba(0,255,136,.35); border-right:2px solid var(--grn); }
|
||||
.m.ava { align-self:flex-start; border-left:2px solid var(--pri); box-shadow:0 0 14px rgba(0,212,255,.08); }
|
||||
.m.ava::before { content:"LUZA"; display:block; font-size:9px; letter-spacing:2px; color:var(--pri); opacity:.8; margin-bottom:3px; }
|
||||
.m.you::before { content:"TU"; display:block; font-size:9px; letter-spacing:2px; color:var(--grn); opacity:.8; margin-bottom:3px; text-align:right; }
|
||||
.m.img { padding:4px; max-width:78%; } .m.img::before { display:none; } .m.img img { display:block; width:100%; border-radius:3px; }
|
||||
.viewer { position:fixed; inset:0; background:rgba(0,4,8,.96); display:flex; align-items:center; justify-content:center; z-index:50; }
|
||||
.viewer img { max-width:100%; max-height:100%; object-fit:contain; box-shadow:0 0 30px rgba(0,212,255,.25); }
|
||||
.m.file a { color:var(--pri); text-decoration:none; font-weight:bold; } .m.file::before { display:none; }
|
||||
.m.sys { align-self:center; font-size:10px; letter-spacing:1px; color:var(--dim); background:transparent; border:0; padding:1px 8px; backdrop-filter:none; }
|
||||
.m.err { align-self:center; font-size:12px; color:var(--red); border-color:rgba(255,51,102,.4); }
|
||||
#confirm { display:none; margin:0 12px 8px; padding:12px; border:1px solid var(--acc); border-radius:4px; background:rgba(40,32,0,.8); box-shadow:0 0 18px rgba(255,204,0,.25); z-index:1; }
|
||||
#confirm .t { color:var(--acc); font-weight:bold; letter-spacing:1px; margin-bottom:4px; } #confirm .d { font-size:12px; white-space:pre-wrap; margin-bottom:10px; }
|
||||
footer { display:flex; gap:10px; padding:10px 14px 14px; align-items:center; border-top:1px solid var(--line); background:linear-gradient(180deg, rgba(2,7,12,.1), rgba(2,7,12,.96)); z-index:1; }
|
||||
.capsule { flex:1; min-width:0; display:flex; align-items:center; background:rgba(0,18,30,.75); border:1px solid var(--line); border-radius:24px; padding:3px 4px 3px 16px; transition:border-color .2s, box-shadow .2s; }
|
||||
.capsule:focus-within { border-color:var(--pri); box-shadow:0 0 14px rgba(0,212,255,.22); }
|
||||
.capsule input { flex:1; min-width:0; background:transparent; border:0; color:var(--txt); font:inherit; font-size:15px; padding:10px 0; outline:none; caret-color:var(--pri); }
|
||||
.icon { width:38px; height:38px; border-radius:50%; border:1px solid transparent; background:transparent; color:var(--pri); display:flex; align-items:center; justify-content:center; padding:0; }
|
||||
.icon svg { width:20px; height:20px; fill:currentColor; }
|
||||
.icon.send { opacity:.35; transition:opacity .15s, background .15s; } .icon.send.on { opacity:1; background:rgba(0,212,255,.12); }
|
||||
.icon.stop { display:none; color:var(--red); border-color:rgba(255,51,102,.5); background:rgba(40,0,14,.7); box-shadow:0 0 12px rgba(255,51,102,.3); width:44px; height:44px; }
|
||||
.icon.stop.on { display:flex; }
|
||||
.micbtn { position:relative; width:56px; height:56px; border-radius:50%; border:1px solid var(--grn); background:radial-gradient(circle at 50% 40%, rgba(0,255,136,.18), rgba(0,20,30,.9) 70%); color:var(--grn); display:flex; align-items:center; justify-content:center; padding:0; box-shadow:0 0 16px rgba(0,255,136,.25), inset 0 0 12px rgba(0,255,136,.12); }
|
||||
.micbtn svg { width:26px; height:26px; fill:currentColor; }
|
||||
.micbtn .halo { position:absolute; inset:-6px; border-radius:50%; border:1px solid rgba(0,255,136,.35); animation:halo 2.4s ease-out infinite; pointer-events:none; }
|
||||
@keyframes halo { 0% { transform:scale(.9); opacity:.6; } 100% { transform:scale(1.35); opacity:0; } }
|
||||
.micbtn.rec { border-color:var(--red); color:var(--red); background:radial-gradient(circle at 50% 40%, rgba(255,51,102,.35), rgba(40,0,14,.95) 70%); box-shadow:0 0 22px rgba(255,51,102,.55); }
|
||||
.micbtn.rec .halo { border-color:rgba(255,51,102,.6); animation-duration:1s; }
|
||||
button:active { transform:scale(.94); }
|
||||
#pair button, #settings button, #confirm button { background:rgba(0,20,32,.7); border:1px solid var(--line); color:var(--txt); border-radius:4px; font:inherit; font-size:13px; padding:10px 14px; letter-spacing:1px; }
|
||||
#pair button.pri, #settings button.pri, #confirm button.pri { border-color:var(--pri); color:var(--pri); box-shadow:0 0 10px rgba(0,212,255,.2); }
|
||||
@keyframes blink { 50% { opacity:.55; } }
|
||||
#pair { display:none; flex-direction:column; gap:12px; padding:26px 22px; text-align:center; z-index:1; }
|
||||
#pair .box { border:1px solid var(--line); background:var(--glass); padding:18px; border-radius:4px; }
|
||||
#pair input { width:100%; font-size:26px; text-align:center; letter-spacing:8px; }
|
||||
#settings { display:none; margin:0 12px 8px; padding:12px; border:1px solid var(--line); border-radius:4px; background:var(--glass); backdrop-filter:blur(8px); font-size:13px; z-index:1; max-height:55vh; overflow-y:auto; }
|
||||
#settings .h { color:var(--pri); letter-spacing:3px; font-size:11px; margin-bottom:8px; }
|
||||
#sform label { display:block; margin:8px 0 3px; color:var(--dim); font-size:10px; letter-spacing:1px; text-transform:uppercase; }
|
||||
#sform select { width:100%; background:rgba(0,10,18,.9); color:var(--txt); border:1px solid var(--line); border-radius:4px; padding:9px; font:inherit; font-size:14px; }
|
||||
.boot { animation:boot .8s ease-out; } @keyframes boot { from { opacity:0; transform:translateY(6px); } to { opacity:1; transform:none; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="corner tl"></div><div class="corner tr"></div><div class="corner bl"></div><div class="corner br"></div>
|
||||
<header class="boot"><div><div class="name">LUZA</div><div class="sub">ASSISTENTE PERSONALE · REMOTO</div></div><span id="conn" class="conn">…</span><button id="gear" onclick="openSettings()">⚙</button></header>
|
||||
<div id="pair">
|
||||
<div class="box"><div style="color:var(--pri);letter-spacing:3px;font-size:11px;margin-bottom:10px">ABBINAMENTO</div>
|
||||
<div style="font-size:13px;color:var(--dim);margin-bottom:12px">Sul Mac premi <b style="color:var(--txt)">Remote Control</b> e digita il PIN di 6 cifre, oppure inquadra il QR.</div>
|
||||
<input id="key" type="text" inputmode="numeric" placeholder="······" autocapitalize="off" autocorrect="off">
|
||||
<div style="height:10px"></div><button class="pri" onclick="savePair()">COLLEGA</button></div>
|
||||
</div>
|
||||
<div id="core" class="boot"><canvas id="ring"></canvas><div id="stateline"><b id="stbase">LISTENING</b><span id="stdetail"></span></div></div>
|
||||
<canvas id="wave" class="boot"></canvas>
|
||||
<div id="settings"><div class="h">MOTORE E VOCE</div><div id="sform"></div>
|
||||
<div style="margin-top:12px; display:flex; gap:8px"><button class="pri" onclick="saveSettings()">SALVA E APPLICA</button><button onclick="$('settings').style.display='none'">CHIUDI</button></div>
|
||||
<div id="shint" style="margin-top:6px; color:var(--dim); font-size:11px"></div></div>
|
||||
<div id="log"></div>
|
||||
<div id="confirm"><div class="t" id="ct"></div><div class="d" id="cd"></div><button class="pri" onclick="doConfirm(true)">CONFERMA</button> <button onclick="doConfirm(false)">ANNULLA</button></div>
|
||||
<footer>
|
||||
<div class="capsule">
|
||||
<input id="txt" type="text" placeholder="Scrivi a LuZa…" enterkeyhint="send" oninput="onTyping()" onkeydown="if(event.key==='Enter')sendText()">
|
||||
<button id="sendb" class="icon send" onclick="sendText()" aria-label="Invia"><svg viewBox="0 0 24 24"><path d="M3 11.5 21 3l-7.5 18-2.3-7.2L3 11.5z"/></svg></button>
|
||||
</div>
|
||||
<button id="stopb" class="icon stop" onclick="send({type:'interrupt'})" aria-label="Interrompi"><svg viewBox="0 0 24 24"><rect x="6" y="6" width="12" height="12" rx="2"/></svg></button>
|
||||
<button id="mic" class="micbtn" onclick="toggleMic()" aria-label="Parla"><span class="halo"></span><svg viewBox="0 0 24 24"><path d="M12 15a4 4 0 0 0 4-4V6a4 4 0 0 0-8 0v5a4 4 0 0 0 4 4z"/><path d="M5 11a7 7 0 0 0 14 0M12 18v3M9 21h6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg></button>
|
||||
</footer>
|
||||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,2 @@
|
||||
{ "name": "LuZa", "short_name": "LuZa", "start_url": "/", "display": "standalone", "background_color": "#030a10", "theme_color": "#030a10",
|
||||
"icons": [ { "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" }, { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" } ] }
|
||||
@@ -0,0 +1,4 @@
|
||||
// Service worker minimo: rende l'app installabile; la pagina resta sempre aggiornata dalla rete.
|
||||
self.addEventListener('install', e => self.skipWaiting());
|
||||
self.addEventListener('activate', e => self.clients.claim());
|
||||
self.addEventListener('fetch', e => {});
|
||||
Reference in new issue
Block a user