nio-cat 0.9.10 → 0.9.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,427 @@
1
+ // nio · Notes en directe: Classe o Reunió.
2
+ // El mòbil (o l'ordinador) grava trossos de 20 s i els envia en ordre a l'ordinador, que els transcriu amb Whisper.
3
+ // Cada ~4-5 minuts de veu, un model en fa apunts; en acabar, el resum de tota la classe o reunió.
4
+ // Mentrestant, aquesta vista mostra la transcripció i els apunts a mesura que arriben.
5
+
6
+ const SEG_MS = Number(globalThis.NIO_LIVE_SEG_MS) || 20000; // durada de cada tros: prou curt per veure-ho gairebé en directe, prou llarg perquè Whisper tingui context
7
+ const SILENCE = 0.003; // per sota d'aquest volum (RMS) tot el tros, no s'envia: Whisper s'inventa frases en el silenci
8
+ const KINDS = { classe: 'Classe', reunio: 'Reunió' };
9
+ const LIVE_MIN = '0.9.11', OLD = /ruta no existeix|Error 404/i; // un nio antic, sense classes ni reunions
10
+ // Dins l'app d'Android, grava un servei natiu: continua amb la pantalla bloquejada (android/LiveService.java).
11
+ const NA = typeof window !== 'undefined' && window.NioAndroid?.liveStart ? window.NioAndroid : null;
12
+ const LANGS = [['ca', 'Català'], ['es', 'Castellà'], ['en', 'Anglès']];
13
+ const STATES = { rec: 'Gravant', closing: 'Acabant de transcriure…', summarizing: 'Fent el resum…', done: 'Resum fet', error: 'No s’ha pogut resumir' };
14
+ const LI = {
15
+ live: '<path d="M4 10v4M8 7v10M12 4v16M16 8v8M20 11v2"/>',
16
+ cap: '<path d="M2.5 9 12 4.5 21.5 9 12 13.5z"/><path d="M6.5 11v4.5c0 1.2 2.5 2.5 5.5 2.5s5.5-1.3 5.5-2.5V11M21.5 9v5"/>',
17
+ people: '<circle cx="9" cy="8" r="3"/><path d="M3.5 19a5.5 5.5 0 0 1 11 0"/><circle cx="17" cy="9" r="2.3"/><path d="M15.5 14.2A4.5 4.5 0 0 1 21 18.5"/>',
18
+ down: '<path d="M6 9l6 6 6-6"/>',
19
+ edit: '<path d="M4 20h4l10.5-10.5a2.1 2.1 0 0 0-3-3L5 17v3z"/>',
20
+ refresh: '<path d="M20 12a8 8 0 1 1-2.4-5.7L20 8.5"/><path d="M20 4v4.5h-4.5"/>',
21
+ };
22
+ const fmt = (s) => { s = Math.max(0, Math.round(s)); const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), x = String(s % 60).padStart(2, '0'); return h ? `${h}:${String(m).padStart(2, '0')}:${x}` : `${m}:${x}`; };
23
+ const LSget = (k, d) => { try { return JSON.parse(localStorage.getItem(k)) ?? d; } catch { return d; } };
24
+ const LSset = (k, v) => { try { localStorage.setItem(k, JSON.stringify(v)); } catch { /* sense desar */ } };
25
+
26
+ export function createLive({ ctx, api, esc, toast, svg, getNote, setNote, loadEngines, openEditor }) {
27
+ let view = null; // { id, el, tab }
28
+ let rec = null; // gravació en marxa en aquest dispositiu
29
+ let pill = null;
30
+
31
+ // Markdown senzill del resum: títols, llistes, caselles i negretes.
32
+ function md(src) {
33
+ const inline = (t) => esc(t).replace(/\*\*([^*]+)\*\*/g, '<b>$1</b>').replace(/(^|[^*])\*([^*]+)\*/g, '$1<i>$2</i>');
34
+ let out = '', list = false;
35
+ const close = () => { if (list) { out += '</ul>'; list = false; } };
36
+ for (const raw of String(src || '').split('\n')) {
37
+ const l = raw.trimEnd();
38
+ let m;
39
+ if (!l.trim()) { close(); continue; }
40
+ if ((m = l.match(/^#{1,4}\s+(.*)/))) { close(); out += `<h3>${inline(m[1])}</h3>`; continue; }
41
+ if ((m = l.match(/^\s*[-*•]\s+\[( |x)\]\s+(.*)/i))) { if (!list) { out += '<ul class="lv-check">'; list = true; } out += `<li data-done="${m[1] !== ' '}">${inline(m[2])}</li>`; continue; }
42
+ if ((m = l.match(/^\s*(?:[-*•]|\d+[.)])\s+(.*)/))) { if (!list) { out += '<ul>'; list = true; } out += `<li>${inline(m[1])}</li>`; continue; }
43
+ close(); out += `<p>${inline(l)}</p>`;
44
+ }
45
+ close();
46
+ return out;
47
+ }
48
+
49
+ // ---------------------------------------------------------------- començar
50
+ // L’ordinador té un nio d’abans de les classes: cal actualitzar-lo allà (la web ja és nova).
51
+ function oldSheet() {
52
+ const v = ctx.nodeVersion(), cmd = ctx.installCmd;
53
+ ctx.openSheet(`<h2>Cal actualitzar nio</h2>
54
+ <div class="stack">
55
+ <p>${v ? `Aquest ordinador té nio ${esc(v)}` : 'Aquest ordinador té un nio antic'} i «Classe o reunió» necessita la ${LIVE_MIN} o més nova.</p>
56
+ <p class="small muted">Obre un terminal a l’ordinador i executa:</p>
57
+ <pre class="mono small" style="white-space:pre-wrap;user-select:all">${esc(cmd)}</pre>
58
+ <p class="small muted">Quan acabi, torna aquí i prova-ho de nou.</p>
59
+ <div class="row sheet-foot"><button class="btn ghost" type="button" data-close>Tanca</button><button class="btn ink" type="button" data-copy>Copia l’ordre</button></div>
60
+ </div>`, (sh) => {
61
+ sh.querySelector('[data-close]').onclick = ctx.closeSheet;
62
+ sh.querySelector('[data-copy]').onclick = () => navigator.clipboard?.writeText(cmd).then(() => toast('Ordre copiada'), () => toast('No l’he pogut copiar'));
63
+ });
64
+ }
65
+
66
+ async function startSheet() {
67
+ if (ctx.isDemo()) return toast('A la demo no hi ha micròfon. Amb nio instal·lat, grava una classe i Whisper la transcriu a l’ordinador.', 5000);
68
+ if (!ctx.nodeAtLeast(LIVE_MIN)) return oldSheet();
69
+ if (!ctx.voiceOn()) { toast('Cal activar Whisper per transcriure (gratis, a l’ordinador).', 3500); return ctx.voiceSheet(); }
70
+ if (rec) return open(rec.id);
71
+ if (!NA && (!navigator.mediaDevices?.getUserMedia || !window.MediaRecorder)) return toast('Aquest navegador no pot gravar àudio.');
72
+ const kind0 = LSget('nio.live.kind', 'classe'), lang0 = LANGS.some(([k]) => k === LSget('nio.live.lang', 'ca')) ? LSget('nio.live.lang', 'ca') : 'ca';
73
+ ctx.openSheet(`<h2>Classe o reunió</h2>
74
+ <div class="stack lv-start">
75
+ <div class="seg" role="group" aria-label="Tipus">${Object.entries(KINDS).map(([k, l]) => `<button type="button" data-kind="${k}" aria-pressed="${k === kind0}">${svg(k === 'classe' ? LI.cap : LI.people)}${l}</button>`).join('')}</div>
76
+ <label class="f">Idioma<select id="lvLang">${LANGS.map(([k, l]) => `<option value="${k}" ${k === lang0 ? 'selected' : ''}>${l}</option>`).join('')}</select></label>
77
+ <label class="f">Model per als apunts i el resum<select id="lvEng"><option>Mirant quins models hi ha…</option></select></label>
78
+ <p class="small muted" id="lvWhat"></p>
79
+ <p class="small lv-warn">${NA ? 'Pots bloquejar el mòbil: l’app continua gravant i ho envia a l’ordinador.' : 'Deixa nio obert i la pantalla encesa: si el mòbil es bloqueja, el navegador atura el micròfon. Amb l’app d’Android, no cal.'}</p>
80
+ <div class="row sheet-foot"><button class="btn ghost" type="button" data-close>Cancel·la</button><button class="btn ink" type="button" data-go>${svg('<circle cx="12" cy="12" r="6" fill="currentColor" stroke="none"/>')}Comença a gravar</button></div>
81
+ </div>`, async (sh) => {
82
+ let kind = kind0;
83
+ const what = () => { sh.querySelector('#lvWhat').textContent = kind === 'classe'
84
+ ? 'Transcriu tota la classe en directe, en fa apunts cada pocs minuts i, quan acabis, un resum amb els conceptes clau i preguntes per repassar.'
85
+ : 'Transcriu tota la reunió en directe, en fa notes cada pocs minuts i, quan acabis, un resum amb les decisions i les tasques.'; };
86
+ what();
87
+ sh.querySelectorAll('[data-kind]').forEach((b) => { b.onclick = () => { kind = b.dataset.kind; sh.querySelectorAll('[data-kind]').forEach((x) => x.setAttribute('aria-pressed', String(x === b))); what(); }; });
88
+ sh.querySelector('[data-close]').onclick = ctx.closeSheet;
89
+ const sel = sh.querySelector('#lvEng');
90
+ loadEngines().then((list) => {
91
+ if (!list.length) { sel.innerHTML = '<option value="">Cap model: només la transcripció</option>'; return; }
92
+ const saved = LSget('nio.notes.engine', ''), auto = (list.find((e) => e.local && e.id.startsWith('ollama:') && e.params >= 3) || list.find((e) => e.id === 'claude:haiku') || list[0]).id;
93
+ const cur = list.some((e) => e.id === saved) ? saved : auto, groups = [...new Set(list.map((e) => e.group))];
94
+ sel.innerHTML = groups.map((g) => `<optgroup label="${esc(g)}">${list.filter((e) => e.group === g).map((e) => `<option value="${esc(e.id)}" ${e.id === cur ? 'selected' : ''}>${esc(e.label)}</option>`).join('')}</optgroup>`).join('');
95
+ });
96
+ sh.querySelector('[data-go]').onclick = async (ev) => {
97
+ const b = ev.currentTarget; b.disabled = true;
98
+ if (NA) {
99
+ const lang = sh.querySelector('#lvLang').value, engine = sel.value || '';
100
+ LSset('nio.live.kind', kind); LSset('nio.live.lang', lang); if (engine) LSset('nio.notes.engine', engine);
101
+ try {
102
+ const n = await api('/api/notes/live', { method: 'POST', body: { kind, lang, engine } });
103
+ setNote(n); ctx.closeSheet(); open(n.id);
104
+ await nativeStart(n);
105
+ } catch (e) { b.disabled = false; if (OLD.test(e.message)) return oldSheet(); toast(e.message, 4500); }
106
+ return;
107
+ }
108
+ // El micròfon es demana dins del clic: Safari no el deixa demanar més tard.
109
+ let stream;
110
+ try { stream = await mic(); } catch { b.disabled = false; return toast('No tinc permís per al micròfon. Activa’l als ajustos del navegador.', 4500); }
111
+ const lang = sh.querySelector('#lvLang').value, engine = sel.value || '';
112
+ LSset('nio.live.kind', kind); LSset('nio.live.lang', lang); if (engine) LSset('nio.notes.engine', engine);
113
+ try {
114
+ const n = await api('/api/notes/live', { method: 'POST', body: { kind, lang, engine } });
115
+ setNote(n); ctx.closeSheet();
116
+ begin(n, stream); open(n.id);
117
+ } catch (e) { stream.getTracks().forEach((t) => t.stop()); b.disabled = false; if (OLD.test(e.message)) return oldSheet(); toast(e.message, 4500); }
118
+ };
119
+ });
120
+ }
121
+ // Àudio de sala: sense cancel·lació d'eco (no hi ha altaveu) i amb guany automàtic per al professor que és lluny.
122
+ const mic = () => navigator.mediaDevices.getUserMedia({ audio: { channelCount: 1, echoCancellation: false, noiseSuppression: true, autoGainControl: true } });
123
+
124
+ // ---------------------------------------------------------------- gravació per trossos
125
+ function begin(n, stream) {
126
+ const segs = n.live?.segs || [];
127
+ const last = segs[segs.length - 1];
128
+ rec = {
129
+ id: n.id, stream, running: true, seq: last ? last.seq + 1 : 0, base: last ? last.at + last.dur : 0, t0: Date.now(),
130
+ queue: [], sending: false, fails: 0, skipped: 0, peak: 0, level: 0, mr: null, timer: 0, lost: false, wake: null, stopping: false,
131
+ };
132
+ meter(rec);
133
+ wake(rec);
134
+ for (const t of stream.getAudioTracks()) t.onended = () => { if (rec?.stream === stream && rec.running) { rec.lost = true; draw(); } };
135
+ segment(rec);
136
+ rec.tick = setInterval(() => { drawRec(); drawPill(); }, 500);
137
+ }
138
+ const elapsed = (r) => (r.native ? r.st.elapsed || 0 : r.base + (Date.now() - r.t0) / 1000);
139
+
140
+ // ---------------------------------------------------------------- gravació nativa (app d'Android)
141
+ async function nativeStart(n) {
142
+ const m = ctx.machine(), segs = n.live?.segs || [], last = segs[segs.length - 1];
143
+ const cfg = { url: m.url, token: m.token, id: n.id, seq: last ? last.seq + 1 : 0, base: last ? last.at + last.dur : 0, segMs: SEG_MS, kind: KINDS[n.live?.kind] || 'Classe' };
144
+ let r = NA.liveStart(JSON.stringify(cfg));
145
+ if (r === 'perm') {
146
+ // L'app demana el permís del micròfon i avisa quan l'usuari ha respost.
147
+ r = await new Promise((done) => addEventListener('nio-native', function on(e) { if (!e.detail?.live) return; removeEventListener('nio-native', on); done(e.detail.live === 'started' ? 'ok' : 'denied'); }));
148
+ }
149
+ if (r !== 'ok') { toast(r === 'denied' ? 'Sense permís per al micròfon no puc gravar. Activa’l a Ajustos → Apps → nio → Permisos.' : 'No he pogut començar a gravar.', 5000); draw(); return; }
150
+ nativeWatch(n.id, m.url);
151
+ // HyperOS (Xiaomi) i altres aturen les apps en segon pla: es demana que no ho faci amb nio.
152
+ try { if (NA.batteryOk && !NA.batteryOk() && !LSget('nio.live.battery', false)) { LSset('nio.live.battery', true); toast('Perquè no s’aturi amb la pantalla bloquejada, permet que nio funcioni sense restriccions de bateria.', 6000); NA.batteryHelp(); } } catch { /* res */ }
153
+ }
154
+ function nativeWatch(id, url) {
155
+ if (rec?.native) clearInterval(rec.tick);
156
+ const r = rec = { native: true, id, url, running: true, stopping: false, lost: false, queue: [], st: {} };
157
+ const tick = () => {
158
+ let st = {}; try { st = JSON.parse(NA.liveStatus()); } catch { /* res */ }
159
+ r.st = st; r.lost = !!st.lost; r.stopping = !!st.stopping;
160
+ // Si el túnel ha canviat d'adreça, els trossos pendents van a la nova.
161
+ const m = ctx.machine(); if (m && m.url !== r.url) { r.url = m.url; try { NA.liveTarget(m.url, m.token); } catch { /* res */ } }
162
+ if (!st.active) { clearInterval(r.tick); if (rec === r) { rec = null; pill?.remove(); pill = null; draw(); } return; }
163
+ const bar = view && view.el.querySelector('.lv-meter i'); if (bar) bar.style.transform = `scaleX(${Math.min(1, (st.level || 0) * 1.2).toFixed(3)})`;
164
+ // La vista es redibuixa quan canvia l'estat (o si encara no mostra la gravació d'aquí).
165
+ if (view?.id === id) { const st2 = view.el.querySelector('.lv-st'); if (!st2 || st2.textContent !== nativeText(r)) draw(); }
166
+ drawRec(); drawPill();
167
+ };
168
+ r.tick = setInterval(tick, 500); tick();
169
+ }
170
+ function nativeText(r) {
171
+ const q = r.st.queue || 0;
172
+ return r.stopping ? (q ? `Enviant ${q} ${q === 1 ? 'tros' : 'trossos'}…` : 'Acabant…')
173
+ : r.lost ? 'El micròfon és ocupat (una trucada?). Tornarà a gravar sol quan quedi lliure.'
174
+ : q > 1 ? `Sense connexió amb l’ordinador: ${q} trossos esperant` : 'Grava amb la pantalla bloquejada i transcriu cada 20 s';
175
+ }
176
+ // Si l'app es torna a obrir amb una gravació en marxa, se'n recupera l'estat.
177
+ if (NA) { try { const st = JSON.parse(NA.liveStatus()); if (st.active && st.id) setTimeout(() => nativeWatch(st.id, ctx.machine()?.url), 0); } catch { /* res */ } }
178
+ function segment(r) {
179
+ if (!r.running || r.lost) return;
180
+ if (r.stream.getAudioTracks().every((t) => t.readyState === 'ended')) { r.lost = true; draw(); return; }
181
+ let mr;
182
+ try { mr = new MediaRecorder(r.stream); } catch { r.lost = true; draw(); return; }
183
+ const chunks = [], start = Date.now(), at = r.base + (start - r.t0) / 1000;
184
+ r.mr = mr; r.peak = 0;
185
+ mr.ondataavailable = (e) => { if (e.data.size) chunks.push(e.data); };
186
+ mr.onstop = () => {
187
+ clearTimeout(r.timer);
188
+ const dur = (Date.now() - start) / 1000, blob = new Blob(chunks, { type: mr.mimeType || 'audio/webm' });
189
+ if (blob.size > 1500 && r.peak >= SILENCE) { r.queue.push({ seq: r.seq++, at: Math.round(at * 10) / 10, dur: Math.round(dur * 10) / 10, blob }); if (r.quiet) { r.quiet = 0; draw(); } pump(r); }
190
+ else if (dur > 2) { r.skipped++; r.quiet = (r.quiet || 0) + 1; if (r.quiet === 2) draw(); } // trossos en silenci seguits: el micròfon no capta la veu
191
+ if (r.running && !r.lost) segment(r);
192
+ else if (!r.running) finish(r);
193
+ };
194
+ mr.onerror = () => { r.lost = true; draw(); };
195
+ mr.start(1000);
196
+ r.timer = setTimeout(() => { if (mr.state === 'recording') mr.stop(); }, SEG_MS);
197
+ }
198
+ // Envia els trossos en ordre; si no arriba a l'ordinador, ho torna a provar (es queden a la memòria del mòbil).
199
+ async function pump(r) {
200
+ if (r.sending) return;
201
+ r.sending = true;
202
+ while (r.queue.length) {
203
+ const s = r.queue[0];
204
+ try {
205
+ await ctx.nodeAudio(`/api/notes/${r.id}/live/seg?seq=${s.seq}&at=${s.at}&dur=${s.dur}`, { headers: { 'content-type': s.blob.type || 'audio/webm' }, body: s.blob });
206
+ r.queue.shift(); r.fails = 0;
207
+ } catch (e) {
208
+ r.fails++; r.error = e.message; draw();
209
+ await new Promise((ok) => setTimeout(ok, Math.min(30000, 2000 * 2 ** Math.min(r.fails, 4))));
210
+ if (rec !== r && !r.stopping) break;
211
+ }
212
+ draw();
213
+ }
214
+ r.sending = false;
215
+ if (r.stopping && !r.queue.length) closeRec(r);
216
+ }
217
+ function stop() {
218
+ const r = rec; if (!r) return;
219
+ if (r.native) { NA.liveStop(); r.stopping = true; draw(); return; }
220
+ r.running = false; r.stopping = true;
221
+ if (r.mr?.state === 'recording') r.mr.stop(); else finish(r);
222
+ draw();
223
+ }
224
+ function finish(r) {
225
+ clearInterval(r.tick); clearInterval(r.meterT);
226
+ r.stream.getTracks().forEach((t) => t.stop());
227
+ r.ctx?.close?.().catch(() => {});
228
+ r.wake?.release?.().catch(() => {});
229
+ if (!r.queue.length && !r.sending) closeRec(r); else pump(r);
230
+ }
231
+ async function closeRec(r) {
232
+ if (r.closed) return; r.closed = true;
233
+ try { setNote(await api(`/api/notes/${r.id}/live/stop`, { method: 'POST' })); }
234
+ catch (e) { toast(`No he pogut acabar la gravació: ${e.message}`, 5000); }
235
+ if (rec === r) rec = null;
236
+ pill?.remove(); pill = null;
237
+ draw();
238
+ }
239
+ // El micròfon s'ha aturat (pantalla bloquejada, trucada…): es continua a la mateixa nota amb un clic.
240
+ async function resume(id) {
241
+ if (NA) {
242
+ if (rec?.native && rec.id === id) return; // l'app ja hi torna sola quan el micròfon queda lliure
243
+ try { const n = await api(`/api/notes/${id}/live/resume`, { method: 'POST' }); setNote(n); await nativeStart(n); draw(); } catch (e) { toast(e.message, 4500); }
244
+ return;
245
+ }
246
+ let stream;
247
+ try { stream = await mic(); } catch { return toast('No tinc permís per al micròfon.', 4000); }
248
+ if (rec && rec.id === id) {
249
+ rec.stream.getTracks().forEach((t) => t.stop());
250
+ rec.base = elapsed(rec); rec.t0 = Date.now(); rec.stream = stream; rec.lost = false; rec.running = true;
251
+ clearInterval(rec.meterT); rec.ctx?.close?.().catch(() => {}); meter(rec); wake(rec);
252
+ for (const t of stream.getAudioTracks()) t.onended = () => { if (rec?.stream === stream && rec.running) { rec.lost = true; draw(); } };
253
+ segment(rec); draw(); return;
254
+ }
255
+ try {
256
+ const n = await api(`/api/notes/${id}/live/resume`, { method: 'POST' });
257
+ setNote(n); begin(n, stream); draw();
258
+ } catch (e) { stream.getTracks().forEach((t) => t.stop()); toast(e.message, 4500); }
259
+ }
260
+ // Nivell del micròfon: per veure que sent i per no enviar els trossos en silenci.
261
+ function meter(r) {
262
+ try {
263
+ const AC = window.AudioContext || window.webkitAudioContext, ac = new AC(), an = ac.createAnalyser();
264
+ ac.createMediaStreamSource(r.stream).connect(an); an.fftSize = 1024;
265
+ const buf = new Float32Array(an.fftSize);
266
+ r.ctx = ac;
267
+ r.meterT = setInterval(() => {
268
+ an.getFloatTimeDomainData(buf);
269
+ let sum = 0; for (const v of buf) sum += v * v;
270
+ const rms = Math.sqrt(sum / buf.length);
271
+ r.peak = Math.max(r.peak, rms); r.level = rms;
272
+ const bar = view && view.el.querySelector('.lv-meter i'); if (bar) bar.style.transform = `scaleX(${Math.min(1, rms * 12).toFixed(3)})`;
273
+ }, 120);
274
+ } catch { r.peak = 1; } // sense AudioContext, s'envia tot
275
+ }
276
+ async function wake(r) { try { r.wake = await navigator.wakeLock?.request('screen'); } catch { /* sense bloqueig de pantalla */ } }
277
+ document.addEventListener('visibilitychange', () => {
278
+ if (document.hidden || !rec) return;
279
+ if (rec.running) wake(rec); // el bloqueig de pantalla es perd en sortir de l'app
280
+ const r = rec, tracks = () => r.stream.getAudioTracks();
281
+ if (r.running && tracks().some((t) => t.readyState === 'ended')) r.lost = true;
282
+ // A l'iPhone, en tornar, la pista pot quedar silenciada uns instants: només és «aturat» si continua així.
283
+ else if (r.running && tracks().some((t) => t.muted)) setTimeout(() => { if (rec === r && tracks().some((t) => t.muted)) { r.lost = true; draw(); } }, 1500);
284
+ draw();
285
+ });
286
+ addEventListener('beforeunload', (e) => { if (rec) { e.preventDefault(); e.returnValue = ''; } });
287
+
288
+ // ---------------------------------------------------------------- vista
289
+ function open(id) {
290
+ const n = getNote(id); if (!n?.live) return false;
291
+ if (!view || view.id !== id) {
292
+ close(true);
293
+ const el = document.createElement('div');
294
+ el.className = 'lv'; el.setAttribute('role', 'dialog'); el.setAttribute('aria-modal', 'true');
295
+ document.body.appendChild(el);
296
+ view = { id, el, tab: n.live.state === 'done' ? 'notes' : 'tr', follow: true };
297
+ el.addEventListener('click', onClick);
298
+ history.pushState({ ...(history.state || {}), nioLive: id }, '', location.href);
299
+ }
300
+ pill?.remove(); pill = null;
301
+ draw(true);
302
+ return true;
303
+ }
304
+ function close(silent) {
305
+ if (!view) return;
306
+ view.el.remove(); view = null;
307
+ if (!silent && history.state?.nioLive) history.back();
308
+ drawPill();
309
+ }
310
+ addEventListener('popstate', () => { if (view && !history.state?.nioLive) { view.el.remove(); view = null; drawPill(); } });
311
+ function drawPill() {
312
+ if (!rec || view) { if (pill && view) { pill.remove(); pill = null; } return; }
313
+ if (!pill) {
314
+ pill = document.createElement('button');
315
+ pill.type = 'button'; pill.className = 'lv-pill';
316
+ pill.onclick = () => open(rec.id);
317
+ document.body.appendChild(pill);
318
+ }
319
+ const n = getNote(rec.id);
320
+ pill.innerHTML = `<i class="lv-dot ${rec.lost ? 'off' : ''}"></i><b>${fmt(elapsed(rec))}</b><span>${esc(rec.lost ? 'Micròfon aturat' : KINDS[n?.live?.kind] || 'Gravant')}</span>`;
321
+ }
322
+
323
+ function draw(first) {
324
+ if (!view) { drawPill(); return; }
325
+ const n = getNote(view.id); if (!n?.live) return close(true);
326
+ const L = n.live, here = rec && rec.id === n.id, el = view.el;
327
+ const tr = el.querySelector('.lv-tr'), keep = tr && !first ? tr.scrollTop : 0;
328
+ const atEnd = tr ? tr.scrollHeight - tr.scrollTop - tr.clientHeight < 60 : true;
329
+ const pend = L.segs.filter((s) => s.state === 'transcribing').length;
330
+ const dur = here ? elapsed(rec) : L.segs.length ? L.segs[L.segs.length - 1].at + L.segs[L.segs.length - 1].dur : 0;
331
+ const busy = L.state === 'closing' || L.state === 'summarizing';
332
+ el.innerHTML = `<div class="lv-in">
333
+ <header class="lv-h">
334
+ <button class="icon-btn" type="button" data-lv="close" aria-label="${here ? 'Amaga (continua gravant)' : 'Tanca'}">${svg(here ? LI.down : '<path d="m10 5-7 7 7 7M3 12h18"/>')}</button>
335
+ <div class="lv-ttl"><h2>${esc(n.title || KINDS[L.kind])}</h2><small>${KINDS[L.kind]} · ${esc(LANGS.find(([k]) => k === L.lang)?.[1] || L.lang)} · ${fmt(dur)}</small></div>
336
+ <button class="icon-btn" type="button" data-lv="edit" aria-label="Edita la nota" title="Edita la nota">${svg(LI.edit)}</button>
337
+ </header>
338
+ ${here ? recBar(n) : `<div class="lv-state" data-s="${L.state}">${busy ? '<i class="nt-spin"></i>' : ''}<span>${esc(STATES[L.state] || '')}${L.state === 'rec' ? ' en un altre dispositiu' : ''}${pend ? ` · ${pend} trossos per transcriure` : ''}</span>
339
+ ${L.state === 'rec' ? `<button class="btn sm" type="button" data-lv="resume">Continua aquí</button>` : ''}
340
+ ${L.state === 'done' || L.state === 'error' ? `<button class="btn ghost sm" type="button" data-lv="resume">Continua gravant</button><button class="btn sm" type="button" data-lv="again">${svg(LI.refresh)}Torna a resumir</button>` : ''}</div>`}
341
+ ${L.error ? `<p class="lv-err" role="alert">${esc(L.error)}</p>` : ''}
342
+ <div class="seg lv-tabs" role="tablist">${[['notes', L.state === 'done' ? 'Resum' : 'Apunts'], ['tr', 'Transcripció']].map(([k, l]) => `<button type="button" role="tab" data-tab="${k}" aria-pressed="${view.tab === k}">${l}</button>`).join('')}</div>
343
+ <div class="lv-body" data-tab="${view.tab}">
344
+ <section class="lv-notes" data-label="${L.state === 'done' ? 'Resum' : 'Apunts'}">${notesHtml(n)}</section>
345
+ <section class="lv-tr" data-label="Transcripció" aria-live="off">${trHtml(n)}</section>
346
+ </div>
347
+ </div>`;
348
+ const t2 = el.querySelector('.lv-tr');
349
+ if (t2) t2.scrollTop = (first || (atEnd && view.follow)) ? t2.scrollHeight : keep;
350
+ t2?.addEventListener('scroll', () => { if (view) view.follow = t2.scrollHeight - t2.scrollTop - t2.clientHeight < 60; }, { passive: true });
351
+ }
352
+ function recBar(n) {
353
+ const r = rec, waiting = r.native ? r.st.queue || 0 : r.queue.length;
354
+ const status = r.native ? nativeText(r) : r.stopping ? (waiting ? `Enviant ${waiting} ${waiting === 1 ? 'tros' : 'trossos'}…` : 'Acabant…')
355
+ : r.lost ? 'El micròfon s’ha aturat (pantalla bloquejada o una altra app). Prem «Continua».'
356
+ : waiting > 1 ? `Sense connexió amb l’ordinador: ${waiting} trossos esperant`
357
+ : r.quiet >= 2 ? 'No sento res: apropa el micròfon o tria’n un altre a la icona del navegador' : 'Grava i transcriu cada 20 s';
358
+ return `<div class="lv-rec ${r.lost ? 'lost' : ''}">
359
+ <i class="lv-dot ${r.lost || r.stopping ? 'off' : ''}"></i><b class="lv-time">${fmt(elapsed(r))}</b>
360
+ <span class="lv-meter" aria-hidden="true"><i></i></span>
361
+ <span class="lv-st">${esc(status)}</span>
362
+ ${r.lost && !r.stopping && !r.native ? `<button class="btn sm" type="button" data-lv="resume">Continua</button>` : ''}
363
+ ${r.stopping ? '' : `<button class="btn ink sm" type="button" data-lv="stop">${svg('<rect x="7" y="7" width="10" height="10" rx="2"/>')}Atura i resumeix</button>`}
364
+ </div>`;
365
+ }
366
+ function drawRec() {
367
+ if (!view || !rec || view.id !== rec.id) return;
368
+ const t = view.el.querySelector('.lv-time'); if (t) t.textContent = fmt(elapsed(rec));
369
+ }
370
+ const rangeTime = (n, p) => { const a = n.live.segs.find((s) => s.seq === p.from), b = n.live.segs.find((s) => s.seq === p.to); return `${fmt(a?.at || 0)}–${fmt((b?.at || 0) + (b?.dur || 0))}`; };
371
+ function notesHtml(n) {
372
+ const L = n.live, parts = L.parts.filter((p) => p.text || p.error);
373
+ const partsHtml = parts.map((p) => `<article class="lv-part"><h4>${rangeTime(n, p)}</h4>${p.text ? md(p.text) : `<p class="muted small">No s’han pogut fer aquests apunts (${esc(p.error)}). Es faran amb el resum.</p>`}</article>`).join('');
374
+ if (L.state === 'done' && n.text.trim()) {
375
+ return `<div class="lv-sum">${md(n.text)}</div>${L.summaryEngine ? `<p class="lv-by small muted">Resum fet amb ${esc(L.summaryEngine.replace(/^ollama:/, ''))}</p>` : ''}${parts.length ? `<details class="lv-parts"><summary>Apunts per trossos (${parts.length})</summary>${partsHtml}</details>` : ''}`;
376
+ }
377
+ const busy = L.state === 'closing' || L.state === 'summarizing';
378
+ return `${busy ? `<div class="lv-busy"><i class="nt-spin"></i><span>${esc(STATES[L.state])} Amb una classe llarga i un model local pot trigar uns minuts.</span></div>` : ''}
379
+ ${partsHtml || `<p class="lv-empty">${L.segs.length ? 'Els primers apunts surten quan hi hagi uns 4 minuts de veu transcrits.' : 'Quan comenci a parlar, aquí aniran sortint els apunts cada pocs minuts.'}</p>`}`;
380
+ }
381
+ function trHtml(n) {
382
+ const segs = n.live.segs;
383
+ if (!segs.length) return '<p class="lv-empty">La transcripció surt aquí cada 20 segons.</p>';
384
+ let out = '', lastMark = -999;
385
+ for (const s of segs) {
386
+ if (s.at - lastMark >= 60) { out += `<b class="lv-ts">${fmt(s.at)}</b>`; lastMark = s.at; }
387
+ out += s.state === 'transcribing' ? '<span class="lv-seg wait">…</span> '
388
+ : s.state === 'error' ? `<span class="lv-seg err" title="${esc(s.error)}">[no s’ha pogut transcriure]</span> `
389
+ : s.text ? `<span class="lv-seg">${esc(s.text)}</span> ` : '';
390
+ }
391
+ return `<p>${out}</p>`;
392
+ }
393
+ async function onClick(e) {
394
+ const tab = e.target.closest('[data-tab]');
395
+ if (tab) { view.tab = tab.dataset.tab; draw(); return; }
396
+ const a = e.target.closest('[data-lv]')?.dataset.lv; if (!a) return;
397
+ const id = view.id;
398
+ if (a === 'close') return close();
399
+ if (a === 'stop') return stop();
400
+ if (a === 'resume') return resume(id);
401
+ if (a === 'edit') { close(); setTimeout(() => openEditor(id), 60); return; }
402
+ if (a === 'again') {
403
+ const list = await loadEngines(), saved = LSget('nio.notes.engine', '');
404
+ const pick = list.length > 1 ? await chooseEngine(list, getNote(id)?.live?.engine || saved) : list[0]?.id || '';
405
+ if (pick === null) return;
406
+ try { setNote(await api(`/api/notes/${id}/live/summary`, { method: 'POST', body: { engine: pick } })); draw(); }
407
+ catch (er) { toast(er.message, 4500); }
408
+ }
409
+ }
410
+ function chooseEngine(list, cur) {
411
+ return new Promise((done) => {
412
+ const groups = [...new Set(list.map((x) => x.group))];
413
+ ctx.openSheet(`<h2>Torna a resumir</h2><div class="stack"><label class="f">Model<select id="lvAgain">${groups.map((g) => `<optgroup label="${esc(g)}">${list.filter((x) => x.group === g).map((x) => `<option value="${esc(x.id)}" ${x.id === cur ? 'selected' : ''}>${esc(x.label)}</option>`).join('')}</optgroup>`).join('')}</select></label>
414
+ <div class="row sheet-foot"><button class="btn ghost" type="button" data-no>Cancel·la</button><button class="btn ink" type="button" data-ok>Resumeix</button></div></div>`, (sh) => {
415
+ sh.querySelector('[data-no]').onclick = () => { ctx.closeSheet(); done(null); };
416
+ sh.querySelector('[data-ok]').onclick = () => { const v = sh.querySelector('#lvAgain').value; LSset('nio.notes.engine', v); ctx.closeSheet(); done(v); };
417
+ });
418
+ });
419
+ }
420
+
421
+ return {
422
+ start: startSheet,
423
+ open,
424
+ update(n) { if (view?.id === n.id) draw(); else drawPill(); },
425
+ get recording() { return !!rec; },
426
+ };
427
+ }
package/web/app/notes.css CHANGED
@@ -173,6 +173,8 @@ body:has(.nt-ov) { overflow: hidden; }
173
173
  .nt-ai-f { display: flex; justify-content: flex-end; gap: 8px; margin-top: 10px; }
174
174
 
175
175
  @media (max-width: 700px) {
176
+ /* Els desplegables de la nota (color, etiqueta, assistent) ocupen l'amplada, amb marge: no se surten pels costats. */
177
+ .nt-pop { left: 10px !important; right: 10px; max-width: none; min-width: 0; }
176
178
  .page:has(.nt) { padding-inline: 14px; }
177
179
  .nt-title h1 { font-size: 26px; } .nt-title p { display: none; }
178
180
  .nt-top { margin-bottom: 12px; } .nt-tools .btn span { display: none; } .nt-tools .btn { padding: 0 11px; }
@@ -223,3 +225,68 @@ body:has(.nt-ov) { overflow: hidden; }
223
225
  .nt-ed { min-height: min(480px, calc(var(--vvh, 100dvh) - 48px)); max-height: min(820px, calc(var(--vvh, 100dvh) - 48px)); }
224
226
  }
225
227
  @media (prefers-reduced-motion: reduce) { .nt-card, .nt-new, .nt-pop, .nt-ai { transition: none; animation: none; } .nt-rec-dot, .nt-spin, .nt-skel { animation: none; } }
228
+
229
+ /* ---------- Classe o reunió en directe (notes-live.js) */
230
+ .nt-tools { display: flex; gap: 8px; flex-wrap: wrap; }
231
+ .nt-badge.nt-lvb { color: var(--fg); }
232
+ .nt-badge .lv-dot { width: 8px; height: 8px; }
233
+ .lv-dot { display: inline-block; width: 11px; height: 11px; border-radius: 50%; background: var(--red); flex: none; animation: nt-pulse 1.1s ease-in-out infinite; }
234
+ .lv-dot.off { background: var(--fg-muted); animation: none; }
235
+ .lv-start .seg button { height: 44px; }
236
+ .lv-start .seg .ico { width: 18px; height: 18px; }
237
+ .lv-warn { padding: 10px 12px; border-radius: 10px; background: color-mix(in srgb, var(--lemon, #f6e9a0) 45%, transparent); }
238
+
239
+ .lv { position: fixed; inset: 0; z-index: 72; display: flex; justify-content: center; background: var(--bg); overscroll-behavior: contain; }
240
+ .lv-in { display: flex; flex-direction: column; width: min(1100px, 100%); height: 100%; padding: calc(10px + var(--safe-t, 0px)) 20px calc(10px + var(--safe-b, 0px)); gap: 12px; min-height: 0; }
241
+ .lv-h { display: flex; align-items: center; gap: 8px; }
242
+ .lv-ttl { flex: 1; min-width: 0; }
243
+ .lv-ttl h2 { font-size: 20px; font-weight: 600; letter-spacing: -.03em; line-height: 1.2; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
244
+ .lv-ttl small { color: var(--fg-muted); font-size: 13px; font-variant-numeric: tabular-nums; }
245
+ .lv-rec { display: flex; align-items: center; flex-wrap: wrap; gap: 10px 12px; padding: 12px 14px; border-radius: 14px; background: color-mix(in srgb, var(--red) 9%, var(--white)); }
246
+ .lv-rec.lost { background: color-mix(in srgb, var(--lemon, #f6e9a0) 55%, var(--white)); }
247
+ .lv-time { font-size: 22px; font-weight: 600; font-variant-numeric: tabular-nums; letter-spacing: -.02em; }
248
+ .lv-meter { width: 64px; height: 6px; border-radius: 3px; background: color-mix(in srgb, var(--fg) 12%, transparent); overflow: hidden; }
249
+ .lv-meter i { display: block; height: 100%; background: var(--moss); transform-origin: left; transform: scaleX(0); transition: transform .12s linear; }
250
+ .lv-st { flex: 1 1 160px; min-width: 0; font-size: 13px; color: var(--fg-muted); }
251
+ .lv-rec .btn .ico { width: 15px; height: 15px; }
252
+ .lv-state { display: flex; align-items: center; flex-wrap: wrap; gap: 8px 10px; padding: 10px 14px; border-radius: 14px; background: var(--white); border: 1px solid var(--border); font-size: 14px; }
253
+ .lv-state span { flex: 1 1 180px; }
254
+ .lv-state[data-s="done"] { background: color-mix(in srgb, var(--moss) 10%, var(--white)); }
255
+ .lv-err { padding: 10px 14px; border-radius: 12px; background: color-mix(in srgb, var(--red) 10%, var(--white)); color: var(--red); font-size: 14px; }
256
+ .lv-tabs { display: none; }
257
+ .lv-body { flex: 1; min-height: 0; display: grid; grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr); gap: 16px; }
258
+ .lv-body > section { min-height: 0; overflow-y: auto; overscroll-behavior: contain; padding: 16px 18px; border-radius: 16px; background: var(--white); border: 1px solid var(--border); }
259
+ .lv-tr { font-size: 15px; line-height: 1.65; }
260
+ @media (min-width: 761px) { .lv-body > section::before { content: attr(data-label); display: block; margin-bottom: 10px; font-size: 12px; font-weight: 600; color: var(--fg-muted); } }
261
+ .lv-tr p { margin: 0; }
262
+ .lv-ts { display: inline-block; margin: 0 6px 0 0; padding: 0 6px; border-radius: 6px; background: var(--bg-2); color: var(--fg-muted); font: 600 11px var(--mono); vertical-align: 1px; }
263
+ .lv-tr .lv-ts:not(:first-child) { margin-top: 10px; }
264
+ .lv-seg.wait { color: var(--fg-muted); }
265
+ .lv-seg.err { color: var(--red); font-size: 13px; }
266
+ .lv-empty { color: var(--fg-muted); font-size: 14px; }
267
+ .lv-notes h3 { margin: 16px 0 6px; font-size: 16px; font-weight: 600; letter-spacing: -.01em; }
268
+ .lv-notes h3:first-child { margin-top: 0; }
269
+ .lv-notes p { margin: 0 0 8px; line-height: 1.55; }
270
+ .lv-notes ul { margin: 0 0 8px; padding-left: 20px; display: grid; gap: 4px; line-height: 1.5; }
271
+ .lv-notes ul.lv-check { list-style: none; padding-left: 0; }
272
+ .lv-notes ul.lv-check li::before { content: ''; display: inline-block; width: 13px; height: 13px; margin-right: 8px; border: 1.6px solid var(--fg-muted); border-radius: 4px; vertical-align: -2px; }
273
+ .lv-part + .lv-part { margin-top: 14px; padding-top: 14px; border-top: 1px solid var(--border); }
274
+ .lv-part h4 { margin-bottom: 6px; font: 600 12px var(--mono); color: var(--fg-muted); }
275
+ .lv-busy { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; color: var(--fg-muted); font-size: 14px; }
276
+ .lv-parts { margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--border); }
277
+ .lv-parts summary { cursor: pointer; font-weight: 600; font-size: 14px; margin-bottom: 10px; }
278
+ .lv-by { margin-top: 12px; }
279
+ .lv-pill { position: fixed; z-index: 66; left: 50%; bottom: calc(76px + var(--safe-b, 0px)); transform: translateX(-50%); display: flex; align-items: center; gap: 8px; height: 40px; padding: 0 16px; border: 0; border-radius: 20px; background: var(--fg); color: var(--bg); font: inherit; font-size: 14px; box-shadow: 0 10px 30px -10px rgba(39,34,36,.6); cursor: pointer; }
280
+ .lv-pill b { font-variant-numeric: tabular-nums; }
281
+ @media (max-width: 760px) {
282
+ .lv-in { padding-left: 12px; padding-right: 12px; gap: 10px; }
283
+ .lv-tabs { display: flex; }
284
+ .lv-body { display: block; }
285
+ .lv-body > section { height: 100%; }
286
+ .lv-body[data-tab="notes"] .lv-tr, .lv-body[data-tab="tr"] .lv-notes { display: none; }
287
+ .lv-time { font-size: 19px; }
288
+ .nt-tools .btn span { white-space: nowrap; }
289
+ .nt-tools .btn.nt-live-btn span { display: inline; }
290
+ .nt-tools .btn.nt-live-btn { padding: 0 13px; }
291
+ }
292
+ @media (prefers-reduced-motion: reduce) { .lv-dot { animation: none; } .lv-meter i { transition: none; } }
package/web/app/notes.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { createLive } from './notes-live.js';
2
+
1
3
  // nio · Notes. Com Google Keep, però a l'ordinador: targetes, llistes, colors, etiquetes, veu (Whisper local)
2
4
  // i un assistent que resumeix i ordena amb un model local, Claude Code o Codex.
3
5
  // Es carrega des d'app.js, que li passa el que necessita (ctx).
@@ -24,6 +26,7 @@ const I = {
24
26
  pause: '<path d="M8 5.500v13M16 5.500v13"/>',
25
27
  phone: '<rect x="7" y="3" width="10" height="18" rx="2.500"/><path d="M11 18h2"/>',
26
28
  check: '<path d="M5 12.500l4.500 4.500L19 7.500"/>',
29
+ live: '<path d="M4 10v4M8 7v10M12 4v16M16 8v8M20 11v2"/>',
27
30
  more: '<circle cx="6" cy="12" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="18" cy="12" r="1.5"/>',
28
31
  };
29
32
  const COLORS = [['', 'Sense color'], ['lemon', 'Llimona'], ['peach', 'Préssec'], ['moss', 'Molsa'], ['sky', 'Cel'], ['lilac', 'Lila'], ['rose', 'Rosa'], ['sand', 'Sorra']];
@@ -45,6 +48,8 @@ const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
45
48
  export function createNotes(ctx) {
46
49
  const { api, esc, toast } = ctx;
47
50
  const $ = (s, e = document) => e.querySelector(s);
51
+ // Classe o reunió: grava i transcriu en directe, en fa apunts i en acabar el resum (notes-live.js).
52
+ const live = createLive({ ctx, api, esc, toast, svg, getNote: (id) => notes.get(id), setNote: (n) => { upsert(n); if (root) { drawFilters(); drawGrid(); } schedule(); }, loadEngines: () => loadEngines(), openEditor: (id) => openEditor(id, null) });
48
53
  let root = null, notes = new Map(), loaded = false, loading = false, engines = null;
49
54
  let view = 'notes', q = '', layout = LSget('nio.notes.layout', 'grid');
50
55
  let ed = null, aborter = null;
@@ -101,7 +106,7 @@ export function createNotes(ctx) {
101
106
  root.innerHTML = `<div class="nt">
102
107
  <div class="nt-top">
103
108
  <div class="nt-title"><h1>Notes</h1><p>Escriu, grava i deixa que un model ho ordeni. Es desa a l’ordinador.</p></div>
104
- ${ctx.isPhone() ? '' : `<div class="nt-tools"><button class="btn" type="button" data-nt="phone">${svg(I.phone)}<span>Mòbil com a micròfon</span></button></div>`}
109
+ <div class="nt-tools"><button class="btn nt-live-btn" type="button" data-nt="live">${svg(I.live)}<span>Classe o reunió</span></button>${ctx.isPhone() ? '' : `<button class="btn" type="button" data-nt="phone">${svg(I.phone)}<span>Mòbil com a micròfon</span></button>`}</div>
105
110
  </div>
106
111
  <div class="nt-bar">
107
112
  <label class="nt-search">${svg(I.search)}<input type="search" id="ntQ" placeholder="Busca a les notes" autocomplete="off" aria-label="Busca a les notes"></label>
@@ -143,6 +148,8 @@ export function createNotes(ctx) {
143
148
  const chip = (v, label, extra = '') => `<button type="button" class="nt-chip" data-nt="view" data-v="${esc(v)}" ${view === v ? 'aria-current="true"' : ''}>${extra}${esc(label)}</button>`;
144
149
  $('#ntFilters', root).innerHTML = `<div class="nt-filter-views">${chip('notes', 'Notes')}${chip('archive', 'Arxivades', svg(I.archive))}${chip('trash', nTrash ? `Paperera · ${nTrash}` : 'Paperera', svg(I.trash))}</div>${labs.length ? `<div class="nt-filter-labels"><span>Etiquetes</span>${labs.map((l) => chip(`l:${l}`, l, svg(I.tag))).join('')}</div>` : ''}`;
145
150
  }
151
+ // A la targeta, el Markdown (resums de classe, notes organitzades) es veu com a text net.
152
+ const plain = (t) => t.trim().replace(/^#{1,4}\s+/gm, '').replace(/\*\*([^*]+)\*\*/g, '$1').replace(/^(\s*)[-*]\s+\[[ x]\]\s+/gim, '$1☐ ').replace(/^(\s*)[-*]\s+/gm, '$1• ');
146
153
  function cardHtml(n) {
147
154
  const items = n.items || [];
148
155
  const open = items.filter((i) => !i.done), doneN = items.length - open.length;
@@ -151,9 +158,10 @@ export function createNotes(ctx) {
151
158
  return `<article class="nt-card" data-id="${n.id}" data-color="${n.color}" tabindex="0" aria-label="${esc(n.title || 'Nota sense títol')}">
152
159
  ${view === 'trash' ? '' : `<button class="nt-pin ${n.pinned ? 'on' : ''}" type="button" data-nt="pin" aria-label="${n.pinned ? 'Deixa de fixar' : 'Fixa la nota'}" aria-pressed="${n.pinned}">${svg(I.pin)}</button>`}
153
160
  ${n.title ? `<h3>${esc(n.title)}</h3>` : ''}
154
- ${n.text.trim() ? `<div class="nt-txt">${esc(n.text.trim())}</div>` : ''}
161
+ ${n.text.trim() ? `<div class="nt-txt">${esc(plain(n.text))}</div>` : ''}
155
162
  ${items.length ? `<div class="nt-its">${shown.map((i) => `<div class="nt-it ${i.done ? 'done' : ''}"><input type="checkbox" data-nt="tick" data-i="${i.id}" ${i.done ? 'checked' : ''} aria-label="Fet"><span>${esc(i.text)}</span></div>`).join('')}${items.length > 8 ? `<span class="nt-more">+ ${items.length - 8} més</span>` : ''}${doneN && items.length <= 8 ? '' : ''}</div>` : ''}
156
163
  ${!n.title && !n.text.trim() && !items.length ? `<div class="nt-txt muted">${n.audio.length ? 'Nota de veu' : 'Nota buida'}</div>` : ''}
164
+ ${n.live ? liveBadge(n) : ''}
157
165
  ${n.audio.length ? `<div class="nt-badge ${err ? 'err' : ''}">${svg(I.mic)}<span>${pend ? 'Transcrivint…' : err ? 'No s’ha pogut transcriure' : `${n.audio.length} ${n.audio.length === 1 ? 'àudio' : 'àudios'}`}</span></div>` : ''}
158
166
  ${n.labels.length ? `<div class="nt-labels">${n.labels.map((l) => `<span class="nt-label">${esc(l)}</span>`).join('')}</div>` : ''}
159
167
  <div class="nt-acts">${view === 'trash'
@@ -161,6 +169,12 @@ export function createNotes(ctx) {
161
169
  : `<button type="button" data-nt="archive" title="${n.archived ? 'Desarxiva' : 'Arxiva'}">${svg(I.archive)}</button><button type="button" data-nt="trash" title="Paperera">${svg(I.trash)}</button>`}</div>
162
170
  </article>`;
163
171
  }
172
+ function liveBadge(n) {
173
+ const L = n.live, last = L.segs[L.segs.length - 1], mins = last ? Math.max(1, Math.round((last.at + last.dur) / 60)) : 0;
174
+ const kind = L.kind === 'reunio' ? 'Reunió' : 'Classe';
175
+ const st = L.state === 'rec' ? '<i class="lv-dot"></i>Gravant' : L.state === 'closing' || L.state === 'summarizing' ? 'Resumint…' : L.state === 'error' ? 'Sense resum' : `${kind}${mins ? ` · ${mins} min` : ''}`;
176
+ return `<div class="nt-badge nt-lvb ${L.state === 'error' ? 'err' : ''}">${L.state === 'rec' ? '' : svg(I.live)}<span>${st}</span></div>`;
177
+ }
164
178
  function drawGrid() {
165
179
  if (!root) return;
166
180
  if (swipe?.card) { swipe.redraw = true; return; } // no es toca la llista amb una targeta sota el dit
@@ -194,6 +208,7 @@ export function createNotes(ctx) {
194
208
  if (act === 'view') { view = t.dataset.v; drawFilters(); drawGrid(); return; }
195
209
  if (act === 'layout') { layout = layout === 'grid' ? 'list' : 'grid'; LSset('nio.notes.layout', layout); drawLayout(); return; }
196
210
  if (act === 'phone') return phoneSheet();
211
+ if (act === 'live') return live.start();
197
212
  if (act === 'new') return openEditor(null, $('#ntNew', root));
198
213
  if (act === 'newlist') { e.stopPropagation(); return openEditor(null, $('#ntNew', root), { list: true }); }
199
214
  if (act === 'newmic') { e.stopPropagation(); return openEditor(null, $('#ntNew', root), { mic: true }); }
@@ -207,7 +222,7 @@ export function createNotes(ctx) {
207
222
  if (act === 'restore') return patch(n, { trashed: false });
208
223
  if (act === 'forever') { if (confirm('Aquesta nota s’esborrarà per sempre.')) api(`/api/notes/${n.id}?forever=1`, { method: 'DELETE' }).catch((er) => toast(er.message)); return; }
209
224
  }
210
- if (card && n) return openEditor(n.id, card);
225
+ if (card && n) { if (n.live && live.open(n.id)) return; return openEditor(n.id, card); }
211
226
  }
212
227
  // Lliscar una targeta cap a qualsevol costat l'arxiva (o la desarxiva a «Arxivades»), com a Keep.
213
228
  // Segueix el dit 1:1; si passa d'un terç de l'amplada o es llença ràpid, marxa; si no, torna al lloc.
@@ -289,14 +304,16 @@ export function createNotes(ctx) {
289
304
  ed.n = mine; notes.set(n.id, mine); syncEditor();
290
305
  }
291
306
  if (root) { drawFilters(); drawGrid(); }
292
- if (n.audio?.some((a) => a.state === 'transcribing')) schedule();
307
+ live.update(n);
308
+ if (n.audio?.some((a) => a.state === 'transcribing') || liveBusy(n)) schedule();
293
309
  }
310
+ const liveBusy = (n) => !!n.live && (['closing', 'summarizing'].includes(n.live.state) || n.live.segs.some((s) => s.state === 'transcribing'));
294
311
  const reload = () => { if (root || loaded) load(); };
295
312
 
296
313
  // Per si el flux en directe s'ha perdut (mòbil en segon pla, túnel que talla): es consulta l'ordinador
297
314
  // cada poc mentre hi ha àudio transcrivint-se i, si no, de tant en tant. Només s'aplica el que ha canviat.
298
315
  let pollT = 0, polling = false;
299
- const busy = () => all().some((n) => n.audio.some((a) => a.state === 'transcribing')) || !!ed?.uploading;
316
+ const busy = () => all().some((n) => n.audio.some((a) => a.state === 'transcribing') || liveBusy(n)) || !!ed?.uploading || live.recording;
300
317
  async function refresh() {
301
318
  if (polling || inflight || loading || !loaded || ctx.isDemo?.()) return; polling = true;
302
319
  const mk = machineKey();