nio-cat 0.9.9 → 0.9.11

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,351 @@
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 LANGS = [['ca', 'Català'], ['es', 'Castellà'], ['en', 'Anglès']];
10
+ const STATES = { rec: 'Gravant', closing: 'Acabant de transcriure…', summarizing: 'Fent el resum…', done: 'Resum fet', error: 'No s’ha pogut resumir' };
11
+ const LI = {
12
+ live: '<path d="M4 10v4M8 7v10M12 4v16M16 8v8M20 11v2"/>',
13
+ 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"/>',
14
+ 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"/>',
15
+ down: '<path d="M6 9l6 6 6-6"/>',
16
+ edit: '<path d="M4 20h4l10.5-10.5a2.1 2.1 0 0 0-3-3L5 17v3z"/>',
17
+ refresh: '<path d="M20 12a8 8 0 1 1-2.4-5.7L20 8.5"/><path d="M20 4v4.5h-4.5"/>',
18
+ };
19
+ 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}`; };
20
+ const LSget = (k, d) => { try { return JSON.parse(localStorage.getItem(k)) ?? d; } catch { return d; } };
21
+ const LSset = (k, v) => { try { localStorage.setItem(k, JSON.stringify(v)); } catch { /* sense desar */ } };
22
+
23
+ export function createLive({ ctx, api, esc, toast, svg, getNote, setNote, loadEngines, openEditor }) {
24
+ let view = null; // { id, el, tab }
25
+ let rec = null; // gravació en marxa en aquest dispositiu
26
+ let pill = null;
27
+
28
+ // Markdown senzill del resum: títols, llistes, caselles i negretes.
29
+ function md(src) {
30
+ const inline = (t) => esc(t).replace(/\*\*([^*]+)\*\*/g, '<b>$1</b>').replace(/(^|[^*])\*([^*]+)\*/g, '$1<i>$2</i>');
31
+ let out = '', list = false;
32
+ const close = () => { if (list) { out += '</ul>'; list = false; } };
33
+ for (const raw of String(src || '').split('\n')) {
34
+ const l = raw.trimEnd();
35
+ let m;
36
+ if (!l.trim()) { close(); continue; }
37
+ if ((m = l.match(/^#{1,4}\s+(.*)/))) { close(); out += `<h3>${inline(m[1])}</h3>`; continue; }
38
+ 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; }
39
+ if ((m = l.match(/^\s*(?:[-*•]|\d+[.)])\s+(.*)/))) { if (!list) { out += '<ul>'; list = true; } out += `<li>${inline(m[1])}</li>`; continue; }
40
+ close(); out += `<p>${inline(l)}</p>`;
41
+ }
42
+ close();
43
+ return out;
44
+ }
45
+
46
+ // ---------------------------------------------------------------- començar
47
+ async function startSheet() {
48
+ 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);
49
+ if (!ctx.voiceOn()) { toast('Cal activar Whisper per transcriure (gratis, a l’ordinador).', 3500); return ctx.voiceSheet(); }
50
+ if (rec) return open(rec.id);
51
+ if (!navigator.mediaDevices?.getUserMedia || !window.MediaRecorder) return toast('Aquest navegador no pot gravar àudio.');
52
+ const kind0 = LSget('nio.live.kind', 'classe'), lang0 = LANGS.some(([k]) => k === LSget('nio.live.lang', 'ca')) ? LSget('nio.live.lang', 'ca') : 'ca';
53
+ ctx.openSheet(`<h2>Classe o reunió</h2>
54
+ <div class="stack lv-start">
55
+ <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>
56
+ <label class="f">Idioma<select id="lvLang">${LANGS.map(([k, l]) => `<option value="${k}" ${k === lang0 ? 'selected' : ''}>${l}</option>`).join('')}</select></label>
57
+ <label class="f">Model per als apunts i el resum<select id="lvEng"><option>Mirant quins models hi ha…</option></select></label>
58
+ <p class="small muted" id="lvWhat"></p>
59
+ <p class="small lv-warn">Deixa nio obert i la pantalla encesa: si el mòbil es bloqueja, el navegador atura el micròfon.</p>
60
+ <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>
61
+ </div>`, async (sh) => {
62
+ let kind = kind0;
63
+ const what = () => { sh.querySelector('#lvWhat').textContent = kind === 'classe'
64
+ ? '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.'
65
+ : 'Transcriu tota la reunió en directe, en fa notes cada pocs minuts i, quan acabis, un resum amb les decisions i les tasques.'; };
66
+ what();
67
+ 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(); }; });
68
+ sh.querySelector('[data-close]').onclick = ctx.closeSheet;
69
+ const sel = sh.querySelector('#lvEng');
70
+ loadEngines().then((list) => {
71
+ if (!list.length) { sel.innerHTML = '<option value="">Cap model: només la transcripció</option>'; return; }
72
+ 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;
73
+ const cur = list.some((e) => e.id === saved) ? saved : auto, groups = [...new Set(list.map((e) => e.group))];
74
+ 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('');
75
+ });
76
+ sh.querySelector('[data-go]').onclick = async (ev) => {
77
+ const b = ev.currentTarget; b.disabled = true;
78
+ // El micròfon es demana dins del clic: Safari no el deixa demanar més tard.
79
+ let stream;
80
+ 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); }
81
+ const lang = sh.querySelector('#lvLang').value, engine = sel.value || '';
82
+ LSset('nio.live.kind', kind); LSset('nio.live.lang', lang); if (engine) LSset('nio.notes.engine', engine);
83
+ try {
84
+ const n = await api('/api/notes/live', { method: 'POST', body: { kind, lang, engine } });
85
+ setNote(n); ctx.closeSheet();
86
+ begin(n, stream); open(n.id);
87
+ } catch (e) { stream.getTracks().forEach((t) => t.stop()); b.disabled = false; toast(e.message, 4500); }
88
+ };
89
+ });
90
+ }
91
+ // Àudio de sala: sense cancel·lació d'eco (no hi ha altaveu) i amb guany automàtic per al professor que és lluny.
92
+ const mic = () => navigator.mediaDevices.getUserMedia({ audio: { channelCount: 1, echoCancellation: false, noiseSuppression: true, autoGainControl: true } });
93
+
94
+ // ---------------------------------------------------------------- gravació per trossos
95
+ function begin(n, stream) {
96
+ const segs = n.live?.segs || [];
97
+ const last = segs[segs.length - 1];
98
+ rec = {
99
+ id: n.id, stream, running: true, seq: last ? last.seq + 1 : 0, base: last ? last.at + last.dur : 0, t0: Date.now(),
100
+ queue: [], sending: false, fails: 0, skipped: 0, peak: 0, level: 0, mr: null, timer: 0, lost: false, wake: null, stopping: false,
101
+ };
102
+ meter(rec);
103
+ wake(rec);
104
+ for (const t of stream.getAudioTracks()) t.onended = () => { if (rec?.stream === stream && rec.running) { rec.lost = true; draw(); } };
105
+ segment(rec);
106
+ rec.tick = setInterval(() => { drawRec(); drawPill(); }, 500);
107
+ }
108
+ const elapsed = (r) => r.base + (Date.now() - r.t0) / 1000;
109
+ function segment(r) {
110
+ if (!r.running || r.lost) return;
111
+ if (r.stream.getAudioTracks().every((t) => t.readyState === 'ended')) { r.lost = true; draw(); return; }
112
+ let mr;
113
+ try { mr = new MediaRecorder(r.stream); } catch { r.lost = true; draw(); return; }
114
+ const chunks = [], start = Date.now(), at = r.base + (start - r.t0) / 1000;
115
+ r.mr = mr; r.peak = 0;
116
+ mr.ondataavailable = (e) => { if (e.data.size) chunks.push(e.data); };
117
+ mr.onstop = () => {
118
+ clearTimeout(r.timer);
119
+ const dur = (Date.now() - start) / 1000, blob = new Blob(chunks, { type: mr.mimeType || 'audio/webm' });
120
+ 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 }); pump(r); }
121
+ else if (dur > 2) r.skipped++;
122
+ if (r.running && !r.lost) segment(r);
123
+ else if (!r.running) finish(r);
124
+ };
125
+ mr.onerror = () => { r.lost = true; draw(); };
126
+ mr.start(1000);
127
+ r.timer = setTimeout(() => { if (mr.state === 'recording') mr.stop(); }, SEG_MS);
128
+ }
129
+ // Envia els trossos en ordre; si no arriba a l'ordinador, ho torna a provar (es queden a la memòria del mòbil).
130
+ async function pump(r) {
131
+ if (r.sending) return;
132
+ r.sending = true;
133
+ while (r.queue.length) {
134
+ const s = r.queue[0];
135
+ try {
136
+ 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 });
137
+ r.queue.shift(); r.fails = 0;
138
+ } catch (e) {
139
+ r.fails++; r.error = e.message; draw();
140
+ await new Promise((ok) => setTimeout(ok, Math.min(30000, 2000 * 2 ** Math.min(r.fails, 4))));
141
+ if (rec !== r && !r.stopping) break;
142
+ }
143
+ draw();
144
+ }
145
+ r.sending = false;
146
+ if (r.stopping && !r.queue.length) closeRec(r);
147
+ }
148
+ function stop() {
149
+ const r = rec; if (!r) return;
150
+ r.running = false; r.stopping = true;
151
+ if (r.mr?.state === 'recording') r.mr.stop(); else finish(r);
152
+ draw();
153
+ }
154
+ function finish(r) {
155
+ clearInterval(r.tick); clearInterval(r.meterT);
156
+ r.stream.getTracks().forEach((t) => t.stop());
157
+ r.ctx?.close?.().catch(() => {});
158
+ r.wake?.release?.().catch(() => {});
159
+ if (!r.queue.length && !r.sending) closeRec(r); else pump(r);
160
+ }
161
+ async function closeRec(r) {
162
+ if (r.closed) return; r.closed = true;
163
+ try { setNote(await api(`/api/notes/${r.id}/live/stop`, { method: 'POST' })); }
164
+ catch (e) { toast(`No he pogut acabar la gravació: ${e.message}`, 5000); }
165
+ if (rec === r) rec = null;
166
+ pill?.remove(); pill = null;
167
+ draw();
168
+ }
169
+ // El micròfon s'ha aturat (pantalla bloquejada, trucada…): es continua a la mateixa nota amb un clic.
170
+ async function resume(id) {
171
+ let stream;
172
+ try { stream = await mic(); } catch { return toast('No tinc permís per al micròfon.', 4000); }
173
+ if (rec && rec.id === id) {
174
+ rec.stream.getTracks().forEach((t) => t.stop());
175
+ rec.base = elapsed(rec); rec.t0 = Date.now(); rec.stream = stream; rec.lost = false; rec.running = true;
176
+ clearInterval(rec.meterT); rec.ctx?.close?.().catch(() => {}); meter(rec); wake(rec);
177
+ for (const t of stream.getAudioTracks()) t.onended = () => { if (rec?.stream === stream && rec.running) { rec.lost = true; draw(); } };
178
+ segment(rec); draw(); return;
179
+ }
180
+ try {
181
+ const n = await api(`/api/notes/${id}/live/resume`, { method: 'POST' });
182
+ setNote(n); begin(n, stream); draw();
183
+ } catch (e) { stream.getTracks().forEach((t) => t.stop()); toast(e.message, 4500); }
184
+ }
185
+ // Nivell del micròfon: per veure que sent i per no enviar els trossos en silenci.
186
+ function meter(r) {
187
+ try {
188
+ const AC = window.AudioContext || window.webkitAudioContext, ac = new AC(), an = ac.createAnalyser();
189
+ ac.createMediaStreamSource(r.stream).connect(an); an.fftSize = 1024;
190
+ const buf = new Float32Array(an.fftSize);
191
+ r.ctx = ac;
192
+ r.meterT = setInterval(() => {
193
+ an.getFloatTimeDomainData(buf);
194
+ let sum = 0; for (const v of buf) sum += v * v;
195
+ const rms = Math.sqrt(sum / buf.length);
196
+ r.peak = Math.max(r.peak, rms); r.level = rms;
197
+ const bar = view && view.el.querySelector('.lv-meter i'); if (bar) bar.style.transform = `scaleX(${Math.min(1, rms * 12).toFixed(3)})`;
198
+ }, 120);
199
+ } catch { r.peak = 1; } // sense AudioContext, s'envia tot
200
+ }
201
+ async function wake(r) { try { r.wake = await navigator.wakeLock?.request('screen'); } catch { /* sense bloqueig de pantalla */ } }
202
+ document.addEventListener('visibilitychange', () => {
203
+ if (document.hidden || !rec) return;
204
+ if (rec.running) wake(rec); // el bloqueig de pantalla es perd en sortir de l'app
205
+ const r = rec, tracks = () => r.stream.getAudioTracks();
206
+ if (r.running && tracks().some((t) => t.readyState === 'ended')) r.lost = true;
207
+ // A l'iPhone, en tornar, la pista pot quedar silenciada uns instants: només és «aturat» si continua així.
208
+ else if (r.running && tracks().some((t) => t.muted)) setTimeout(() => { if (rec === r && tracks().some((t) => t.muted)) { r.lost = true; draw(); } }, 1500);
209
+ draw();
210
+ });
211
+ addEventListener('beforeunload', (e) => { if (rec) { e.preventDefault(); e.returnValue = ''; } });
212
+
213
+ // ---------------------------------------------------------------- vista
214
+ function open(id) {
215
+ const n = getNote(id); if (!n?.live) return false;
216
+ if (!view || view.id !== id) {
217
+ close(true);
218
+ const el = document.createElement('div');
219
+ el.className = 'lv'; el.setAttribute('role', 'dialog'); el.setAttribute('aria-modal', 'true');
220
+ document.body.appendChild(el);
221
+ view = { id, el, tab: n.live.state === 'done' ? 'notes' : 'tr', follow: true };
222
+ el.addEventListener('click', onClick);
223
+ history.pushState({ ...(history.state || {}), nioLive: id }, '', location.href);
224
+ }
225
+ pill?.remove(); pill = null;
226
+ draw(true);
227
+ return true;
228
+ }
229
+ function close(silent) {
230
+ if (!view) return;
231
+ view.el.remove(); view = null;
232
+ if (!silent && history.state?.nioLive) history.back();
233
+ drawPill();
234
+ }
235
+ addEventListener('popstate', () => { if (view && !history.state?.nioLive) { view.el.remove(); view = null; drawPill(); } });
236
+ function drawPill() {
237
+ if (!rec || view) { if (pill && view) { pill.remove(); pill = null; } return; }
238
+ if (!pill) {
239
+ pill = document.createElement('button');
240
+ pill.type = 'button'; pill.className = 'lv-pill';
241
+ pill.onclick = () => open(rec.id);
242
+ document.body.appendChild(pill);
243
+ }
244
+ const n = getNote(rec.id);
245
+ 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>`;
246
+ }
247
+
248
+ function draw(first) {
249
+ if (!view) { drawPill(); return; }
250
+ const n = getNote(view.id); if (!n?.live) return close(true);
251
+ const L = n.live, here = rec && rec.id === n.id, el = view.el;
252
+ const tr = el.querySelector('.lv-tr'), keep = tr && !first ? tr.scrollTop : 0;
253
+ const atEnd = tr ? tr.scrollHeight - tr.scrollTop - tr.clientHeight < 60 : true;
254
+ const pend = L.segs.filter((s) => s.state === 'transcribing').length;
255
+ const dur = here ? elapsed(rec) : L.segs.length ? L.segs[L.segs.length - 1].at + L.segs[L.segs.length - 1].dur : 0;
256
+ const busy = L.state === 'closing' || L.state === 'summarizing';
257
+ el.innerHTML = `<div class="lv-in">
258
+ <header class="lv-h">
259
+ <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>
260
+ <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>
261
+ <button class="icon-btn" type="button" data-lv="edit" aria-label="Edita la nota" title="Edita la nota">${svg(LI.edit)}</button>
262
+ </header>
263
+ ${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>
264
+ ${L.state === 'rec' ? `<button class="btn sm" type="button" data-lv="resume">Continua aquí</button>` : ''}
265
+ ${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>`}
266
+ ${L.error ? `<p class="lv-err" role="alert">${esc(L.error)}</p>` : ''}
267
+ <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>
268
+ <div class="lv-body" data-tab="${view.tab}">
269
+ <section class="lv-notes" data-label="${L.state === 'done' ? 'Resum' : 'Apunts'}">${notesHtml(n)}</section>
270
+ <section class="lv-tr" data-label="Transcripció" aria-live="off">${trHtml(n)}</section>
271
+ </div>
272
+ </div>`;
273
+ const t2 = el.querySelector('.lv-tr');
274
+ if (t2) t2.scrollTop = (first || (atEnd && view.follow)) ? t2.scrollHeight : keep;
275
+ t2?.addEventListener('scroll', () => { if (view) view.follow = t2.scrollHeight - t2.scrollTop - t2.clientHeight < 60; }, { passive: true });
276
+ }
277
+ function recBar(n) {
278
+ const r = rec, waiting = r.queue.length;
279
+ const status = r.stopping ? (waiting ? `Enviant ${waiting} ${waiting === 1 ? 'tros' : 'trossos'}…` : 'Acabant…')
280
+ : r.lost ? 'El micròfon s’ha aturat (pantalla bloquejada o una altra app). Prem «Continua».'
281
+ : waiting > 1 ? `Sense connexió amb l’ordinador: ${waiting} trossos esperant` : 'Grava i transcriu cada 20 s';
282
+ return `<div class="lv-rec ${r.lost ? 'lost' : ''}">
283
+ <i class="lv-dot ${r.lost || r.stopping ? 'off' : ''}"></i><b class="lv-time">${fmt(elapsed(r))}</b>
284
+ <span class="lv-meter" aria-hidden="true"><i></i></span>
285
+ <span class="lv-st">${esc(status)}</span>
286
+ ${r.lost && !r.stopping ? `<button class="btn sm" type="button" data-lv="resume">Continua</button>` : ''}
287
+ ${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>`}
288
+ </div>`;
289
+ }
290
+ function drawRec() {
291
+ if (!view || !rec || view.id !== rec.id) return;
292
+ const t = view.el.querySelector('.lv-time'); if (t) t.textContent = fmt(elapsed(rec));
293
+ }
294
+ 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))}`; };
295
+ function notesHtml(n) {
296
+ const L = n.live, parts = L.parts.filter((p) => p.text || p.error);
297
+ 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('');
298
+ if (L.state === 'done' && n.text.trim()) {
299
+ 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>` : ''}`;
300
+ }
301
+ const busy = L.state === 'closing' || L.state === 'summarizing';
302
+ 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>` : ''}
303
+ ${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>`}`;
304
+ }
305
+ function trHtml(n) {
306
+ const segs = n.live.segs;
307
+ if (!segs.length) return '<p class="lv-empty">La transcripció surt aquí cada 20 segons.</p>';
308
+ let out = '', lastMark = -999;
309
+ for (const s of segs) {
310
+ if (s.at - lastMark >= 60) { out += `<b class="lv-ts">${fmt(s.at)}</b>`; lastMark = s.at; }
311
+ out += s.state === 'transcribing' ? '<span class="lv-seg wait">…</span> '
312
+ : s.state === 'error' ? `<span class="lv-seg err" title="${esc(s.error)}">[no s’ha pogut transcriure]</span> `
313
+ : s.text ? `<span class="lv-seg">${esc(s.text)}</span> ` : '';
314
+ }
315
+ return `<p>${out}</p>`;
316
+ }
317
+ async function onClick(e) {
318
+ const tab = e.target.closest('[data-tab]');
319
+ if (tab) { view.tab = tab.dataset.tab; draw(); return; }
320
+ const a = e.target.closest('[data-lv]')?.dataset.lv; if (!a) return;
321
+ const id = view.id;
322
+ if (a === 'close') return close();
323
+ if (a === 'stop') return stop();
324
+ if (a === 'resume') return resume(id);
325
+ if (a === 'edit') { close(); setTimeout(() => openEditor(id), 60); return; }
326
+ if (a === 'again') {
327
+ const list = await loadEngines(), saved = LSget('nio.notes.engine', '');
328
+ const pick = list.length > 1 ? await chooseEngine(list, getNote(id)?.live?.engine || saved) : list[0]?.id || '';
329
+ if (pick === null) return;
330
+ try { setNote(await api(`/api/notes/${id}/live/summary`, { method: 'POST', body: { engine: pick } })); draw(); }
331
+ catch (er) { toast(er.message, 4500); }
332
+ }
333
+ }
334
+ function chooseEngine(list, cur) {
335
+ return new Promise((done) => {
336
+ const groups = [...new Set(list.map((x) => x.group))];
337
+ 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>
338
+ <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) => {
339
+ sh.querySelector('[data-no]').onclick = () => { ctx.closeSheet(); done(null); };
340
+ sh.querySelector('[data-ok]').onclick = () => { const v = sh.querySelector('#lvAgain').value; LSset('nio.notes.engine', v); ctx.closeSheet(); done(v); };
341
+ });
342
+ });
343
+ }
344
+
345
+ return {
346
+ start: startSheet,
347
+ open,
348
+ update(n) { if (view?.id === n.id) draw(); else drawPill(); },
349
+ get recording() { return !!rec; },
350
+ };
351
+ }
package/web/app/notes.css CHANGED
@@ -223,3 +223,68 @@ body:has(.nt-ov) { overflow: hidden; }
223
223
  .nt-ed { min-height: min(480px, calc(var(--vvh, 100dvh) - 48px)); max-height: min(820px, calc(var(--vvh, 100dvh) - 48px)); }
224
224
  }
225
225
  @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; } }
226
+
227
+ /* ---------- Classe o reunió en directe (notes-live.js) */
228
+ .nt-tools { display: flex; gap: 8px; flex-wrap: wrap; }
229
+ .nt-badge.nt-lvb { color: var(--fg); }
230
+ .nt-badge .lv-dot { width: 8px; height: 8px; }
231
+ .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; }
232
+ .lv-dot.off { background: var(--fg-muted); animation: none; }
233
+ .lv-start .seg button { height: 44px; }
234
+ .lv-start .seg .ico { width: 18px; height: 18px; }
235
+ .lv-warn { padding: 10px 12px; border-radius: 10px; background: color-mix(in srgb, var(--lemon, #f6e9a0) 45%, transparent); }
236
+
237
+ .lv { position: fixed; inset: 0; z-index: 72; display: flex; justify-content: center; background: var(--bg); overscroll-behavior: contain; }
238
+ .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; }
239
+ .lv-h { display: flex; align-items: center; gap: 8px; }
240
+ .lv-ttl { flex: 1; min-width: 0; }
241
+ .lv-ttl h2 { font-size: 20px; font-weight: 600; letter-spacing: -.03em; line-height: 1.2; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
242
+ .lv-ttl small { color: var(--fg-muted); font-size: 13px; font-variant-numeric: tabular-nums; }
243
+ .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)); }
244
+ .lv-rec.lost { background: color-mix(in srgb, var(--lemon, #f6e9a0) 55%, var(--white)); }
245
+ .lv-time { font-size: 22px; font-weight: 600; font-variant-numeric: tabular-nums; letter-spacing: -.02em; }
246
+ .lv-meter { width: 64px; height: 6px; border-radius: 3px; background: color-mix(in srgb, var(--fg) 12%, transparent); overflow: hidden; }
247
+ .lv-meter i { display: block; height: 100%; background: var(--moss); transform-origin: left; transform: scaleX(0); transition: transform .12s linear; }
248
+ .lv-st { flex: 1 1 160px; min-width: 0; font-size: 13px; color: var(--fg-muted); }
249
+ .lv-rec .btn .ico { width: 15px; height: 15px; }
250
+ .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; }
251
+ .lv-state span { flex: 1 1 180px; }
252
+ .lv-state[data-s="done"] { background: color-mix(in srgb, var(--moss) 10%, var(--white)); }
253
+ .lv-err { padding: 10px 14px; border-radius: 12px; background: color-mix(in srgb, var(--red) 10%, var(--white)); color: var(--red); font-size: 14px; }
254
+ .lv-tabs { display: none; }
255
+ .lv-body { flex: 1; min-height: 0; display: grid; grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr); gap: 16px; }
256
+ .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); }
257
+ .lv-tr { font-size: 15px; line-height: 1.65; }
258
+ @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); } }
259
+ .lv-tr p { margin: 0; }
260
+ .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; }
261
+ .lv-tr .lv-ts:not(:first-child) { margin-top: 10px; }
262
+ .lv-seg.wait { color: var(--fg-muted); }
263
+ .lv-seg.err { color: var(--red); font-size: 13px; }
264
+ .lv-empty { color: var(--fg-muted); font-size: 14px; }
265
+ .lv-notes h3 { margin: 16px 0 6px; font-size: 16px; font-weight: 600; letter-spacing: -.01em; }
266
+ .lv-notes h3:first-child { margin-top: 0; }
267
+ .lv-notes p { margin: 0 0 8px; line-height: 1.55; }
268
+ .lv-notes ul { margin: 0 0 8px; padding-left: 20px; display: grid; gap: 4px; line-height: 1.5; }
269
+ .lv-notes ul.lv-check { list-style: none; padding-left: 0; }
270
+ .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; }
271
+ .lv-part + .lv-part { margin-top: 14px; padding-top: 14px; border-top: 1px solid var(--border); }
272
+ .lv-part h4 { margin-bottom: 6px; font: 600 12px var(--mono); color: var(--fg-muted); }
273
+ .lv-busy { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; color: var(--fg-muted); font-size: 14px; }
274
+ .lv-parts { margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--border); }
275
+ .lv-parts summary { cursor: pointer; font-weight: 600; font-size: 14px; margin-bottom: 10px; }
276
+ .lv-by { margin-top: 12px; }
277
+ .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; }
278
+ .lv-pill b { font-variant-numeric: tabular-nums; }
279
+ @media (max-width: 760px) {
280
+ .lv-in { padding-left: 12px; padding-right: 12px; gap: 10px; }
281
+ .lv-tabs { display: flex; }
282
+ .lv-body { display: block; }
283
+ .lv-body > section { height: 100%; }
284
+ .lv-body[data-tab="notes"] .lv-tr, .lv-body[data-tab="tr"] .lv-notes { display: none; }
285
+ .lv-time { font-size: 19px; }
286
+ .nt-tools .btn span { white-space: nowrap; }
287
+ .nt-tools .btn.nt-live-btn span { display: inline; }
288
+ .nt-tools .btn.nt-live-btn { padding: 0 13px; }
289
+ }
290
+ @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();