create-pmos 0.241.0 → 0.242.0
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.
- package/package.json +1 -1
- package/payload/KIT-VERSION +1 -1
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/state/discoveries.md +1 -1
- package/workspace/guide/console-calendar.md +14 -0
- package/workspace/guide/notes.md +22 -1
- package/workspace/lib/meeting-material.js +151 -0
- package/workspace/lib/meeting-replay.js +170 -0
- package/workspace/lib/notes-screen.js +128 -8
- package/workspace/lib/route-modules.js +2 -0
- package/workspace/lib/routes-meeting-focus.js +5 -3
- package/workspace/lib/routes-meeting-material.js +143 -0
- package/workspace/lib/routes-meeting-replay.js +75 -0
- package/workspace/lib/routes-notes.js +37 -4
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/c3-calendar.js +15 -3
- package/workspace/public/pm/c3-demo-material.js +58 -0
- package/workspace/public/pm/c3-demo.js +5 -1
- package/workspace/public/pm/c3-focus.js +83 -16
- package/workspace/public/pm/c3-listen.css +10 -0
- package/workspace/public/pm/c3-material.css +22 -0
- package/workspace/public/pm/c3-material.js +311 -0
- package/workspace/public/pm/c3-notes.js +12 -4
- package/workspace/public/pm/c3-replay.css +127 -0
- package/workspace/public/pm/c3-replay.js +413 -0
- package/workspace/public/pm/dest-notes.js +4 -2
- package/workspace/public/pm/listen-sheet.js +94 -0
- package/workspace/public/pm/listen-sources.js +82 -0
- package/workspace/public/pm/notes-aside.js +62 -0
- package/workspace/public/pm/notes-screen-capture.js +23 -10
- package/workspace/public/pm/notes-screen-view.js +18 -5
- package/workspace/public/pm/notes-shots.js +55 -0
- package/workspace/public/pm/shell-recording.js +10 -2
- package/workspace/public/pm/view-notes.js +48 -8
- package/workspace/test-lists/meeting-material.json +6 -0
- package/workspace/test-lists/meeting-replay.json +6 -0
- package/workspace/test-lists/mm-capture.json +15 -0
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
/* pm/c3-replay.js — the meeting replay of console3 (initiative meeting-material-replay, part 2; the mockup the PM approved on 2026-10-14, #replay).
|
|
2
|
+
* openReplay({ note, event, date }) closeReplay() window.C3Replay = { open, close, state }
|
|
3
|
+
* Focus played back, full screen over console3 like Focus (inside #c3-root; the header, tabs and dock stay in the DOM, set inert).
|
|
4
|
+
* One read, GET /api/meeting-replay: the note (transcript, decisions, next steps, questions), the Focus state (topics, stretches, PMOS's notes)
|
|
5
|
+
* and the kept shots. The page plays the note's audio (GET /api/notes/recording) and ticks every 500 ms:
|
|
6
|
+
* header title and time, Back 10 s, Play or Pause, 1x 1.5x 2x; the topic track as the scrub bar: stretches numbered by topic, a mark for each
|
|
7
|
+
* decision, question and next step (amber when it needs you: no owner, still open), a dot for each PMOS note, the playhead
|
|
8
|
+
* now line the topic the room was on, what is on screen, the count of key points
|
|
9
|
+
* stage the shot on screen at the playing time (a press opens it large), the strip of shots, "N set aside"
|
|
10
|
+
* key points decisions, next steps and questions, each with the time to play from; Confirm all makes a task of each next step
|
|
11
|
+
* topics minutes and the first time each came up
|
|
12
|
+
* conversation the transcript lines and PMOS's notes in time order; the current line is marked and followed; All, Key points, PMOS; search
|
|
13
|
+
* A press on a stretch, a mark, a key point, a topic, a shot or a line plays from there. Without audio (none was kept) the page keeps its own
|
|
14
|
+
* clock, so the track, the shots and the lines still follow. Everything stays on this machine. */
|
|
15
|
+
import { el } from './dom.js';
|
|
16
|
+
import { pmGet } from './http.js';
|
|
17
|
+
import { ensureC3Css } from './pm-work-view.js';
|
|
18
|
+
import { ensureComponentsCss } from './primitives.js';
|
|
19
|
+
import { icon, sect, pbtn, lk, btn, nrow, put, geo } from './c3-ini-ui.js';
|
|
20
|
+
import { toast } from './c3-toast.js';
|
|
21
|
+
import { openShot, whyText, shotIndexAt } from './notes-screen-view.js';
|
|
22
|
+
|
|
23
|
+
const TICK_MS = 500;
|
|
24
|
+
const NEAR_KEY_MS = 30000; // a key point is "now" this long after its line
|
|
25
|
+
const BACK_MS = 10000;
|
|
26
|
+
const KIC = { decision: 'scale', action: 'todo', question: 'reply' };
|
|
27
|
+
const KWORD = { decision: 'Decision', action: 'Next step', question: 'Question' };
|
|
28
|
+
const KPLURAL = { decision: 'Decisions', action: 'Next steps', question: 'Questions' };
|
|
29
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
30
|
+
|
|
31
|
+
/** 3:07, or 1:03:07 from an hour on. Pure. */
|
|
32
|
+
export function mmss(ms) {
|
|
33
|
+
const s = Math.max(0, Math.round((Number(ms) || 0) / 1000));
|
|
34
|
+
const h = Math.floor(s / 3600);
|
|
35
|
+
return (h ? h + ':' + pad(Math.floor(s / 60) % 60) : String(Math.floor(s / 60))) + ':' + pad(s % 60);
|
|
36
|
+
}
|
|
37
|
+
/** The index of the stretch the room was in at the time: the last stretch that began at or before it, or -1 before the first. Pure. */
|
|
38
|
+
export function runIndexAt(runs, t) {
|
|
39
|
+
let at = -1;
|
|
40
|
+
(runs || []).forEach((r, i) => { if (r.from_ms <= t) at = i; });
|
|
41
|
+
return at;
|
|
42
|
+
}
|
|
43
|
+
/** The index of the last line said at or before the time, or -1. Pure. */
|
|
44
|
+
export function lineIndexAt(lines, t) {
|
|
45
|
+
let lo = 0; let hi = (lines || []).length - 1; let at = -1;
|
|
46
|
+
while (lo <= hi) { const mid = (lo + hi) >> 1; if (lines[mid].t <= t) { at = mid; lo = mid + 1; } else hi = mid - 1; }
|
|
47
|
+
return at;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
let S = null; // the open page
|
|
51
|
+
|
|
52
|
+
function ensureCss() {
|
|
53
|
+
ensureC3Css(); ensureComponentsCss();
|
|
54
|
+
if (document.querySelector('link[data-c3-replay]')) return;
|
|
55
|
+
const l = document.createElement('link');
|
|
56
|
+
l.rel = 'stylesheet'; l.href = new URL('./c3-replay.css', import.meta.url).href; l.setAttribute('data-c3-replay', '');
|
|
57
|
+
document.head.appendChild(l);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/* ── open and close ───────────────────────────────────────────────────────────────────────────────────────────── */
|
|
61
|
+
|
|
62
|
+
export function closeReplay() {
|
|
63
|
+
if (!S) return;
|
|
64
|
+
const s = S; S = null;
|
|
65
|
+
clearInterval(s.timer);
|
|
66
|
+
try { s.audio.pause(); s.audio.removeAttribute('src'); s.audio.load(); } catch { /* no audio */ }
|
|
67
|
+
s.root.remove();
|
|
68
|
+
const host = document.getElementById('c3-root');
|
|
69
|
+
if (host) host.classList.remove('rpl-open');
|
|
70
|
+
const grid = host && host.querySelector('.c3grid'); if (grid) grid.inert = false;
|
|
71
|
+
const comp = host && host.querySelector('[data-c3ph]'); if (comp) comp.inert = false;
|
|
72
|
+
if (s.opener && document.contains(s.opener) && typeof s.opener.focus === 'function') { try { s.opener.focus({ preventScroll: true }); } catch { /* not focusable */ } }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function openReplay({ note, event, date } = {}) {
|
|
76
|
+
closeReplay();
|
|
77
|
+
ensureCss();
|
|
78
|
+
const host = document.getElementById('c3-root') || document.body;
|
|
79
|
+
const root = el('div', { class: 'c3replay', role: 'region', 'aria-label': 'Meeting replay', 'data-c3replay': '', 'data-rpl-state': 'loading' },
|
|
80
|
+
el('div', { class: 'rpl-page' }, el('div', { class: 'fold', 'data-rpl': 'status' }, icon('clock', 'sm'), 'Reading the meeting …')));
|
|
81
|
+
S = { root, note: String(note || ''), event: event && (event.id || event) ? String(event.id || event) : '', date: String(date || ''), data: null, t: 0, play: false, speed: 1, filter: 'all', q: '',
|
|
82
|
+
audio: null, soundOff: false, timer: null, done: new Set(), r: {}, ix: { run: -2, shot: -2, line: -2 },
|
|
83
|
+
opener: document.activeElement && document.activeElement !== document.body ? document.activeElement : null };
|
|
84
|
+
host.classList.add('rpl-open');
|
|
85
|
+
host.append(root);
|
|
86
|
+
const grid = host.querySelector && host.querySelector('.c3grid'); if (grid) grid.inert = true;
|
|
87
|
+
const comp = host.querySelector && host.querySelector('[data-c3ph]'); if (comp) comp.inert = true;
|
|
88
|
+
load(S);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function load(s) {
|
|
92
|
+
const p = new URLSearchParams();
|
|
93
|
+
if (s.note) p.set('note', s.note);
|
|
94
|
+
if (s.event) p.set('event', s.event);
|
|
95
|
+
if (s.date) p.set('date', s.date);
|
|
96
|
+
let d = null;
|
|
97
|
+
try { d = await pmGet('/api/meeting-replay?' + p.toString()); } catch (e) { d = { ok: false, error: String((e && e.message) || e) }; }
|
|
98
|
+
if (S !== s) return;
|
|
99
|
+
if (!d || !d.ok) { fail(s, (d && d.error) || 'The replay could not be read.'); return; }
|
|
100
|
+
s.data = d;
|
|
101
|
+
s.root.dataset.rplState = 'ok';
|
|
102
|
+
if (d.audio) {
|
|
103
|
+
s.audio = el('audio', { preload: 'metadata', src: d.audio.url, 'data-rpl': 'audio' });
|
|
104
|
+
s.audio.addEventListener('ended', () => { if (S === s) { s.play = false; paintTop(s); } });
|
|
105
|
+
s.audio.addEventListener('error', () => { if (S === s) s.soundOff = true; });
|
|
106
|
+
}
|
|
107
|
+
build(s);
|
|
108
|
+
s.timer = setInterval(() => tick(s), TICK_MS);
|
|
109
|
+
const back = s.root.querySelector('[data-rpl="leave"]'); if (back) back.focus({ preventScroll: true });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function fail(s, message) {
|
|
113
|
+
s.root.dataset.rplState = 'error';
|
|
114
|
+
put(s.root, el('div', { class: 'rpl-page' }, el('div', { class: 'fold', 'data-rpl': 'status' }, icon('alert', 'sm'), message),
|
|
115
|
+
el('div', { class: 'iacts' }, pbtn('ghost', 'Back', 'prev', { 'data-rpl': 'close' }))));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/* ── time ─────────────────────────────────────────────────────────────────────────────────────────────────────── */
|
|
119
|
+
|
|
120
|
+
const lenOf = (s) => Math.max(1, s.data.length_ms || 0);
|
|
121
|
+
const wall = (s, ms) => (s.data.start_ms == null ? mmss(ms) : (() => { const d = new Date(s.data.start_ms + ms); return pad(d.getHours()) + ':' + pad(d.getMinutes()); })());
|
|
122
|
+
const pct = (s, ms) => Math.max(0, Math.min(100, ms / lenOf(s) * 100));
|
|
123
|
+
|
|
124
|
+
function playNow(s) {
|
|
125
|
+
s.play = true;
|
|
126
|
+
if (s.audio && !s.soundOff) {
|
|
127
|
+
s.audio.playbackRate = s.speed;
|
|
128
|
+
const p = s.audio.play();
|
|
129
|
+
if (p && typeof p.catch === 'function') p.catch(() => { s.soundOff = true; });
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
function pauseNow(s) { s.play = false; if (s.audio) { try { s.audio.pause(); } catch { /* none */ } } }
|
|
133
|
+
|
|
134
|
+
/** Move the playhead. `andPlay`: start playing from there (a track, a key point, a topic, a shot or a line); Back 10 s keeps the state. */
|
|
135
|
+
function seek(s, ms, andPlay) {
|
|
136
|
+
const len = lenOf(s);
|
|
137
|
+
s.t = Math.max(0, Math.min(len, Math.round(ms)));
|
|
138
|
+
if (s.audio && !s.soundOff) { try { s.audio.currentTime = s.t / 1000; } catch { /* not ready yet */ } }
|
|
139
|
+
if (andPlay) playNow(s);
|
|
140
|
+
paintTop(s);
|
|
141
|
+
paintTime(s, true);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function tick(s) {
|
|
145
|
+
if (S !== s || !s.data) return;
|
|
146
|
+
const len = lenOf(s);
|
|
147
|
+
if (s.play) {
|
|
148
|
+
if (s.audio && !s.soundOff && !s.audio.paused) s.t = Math.round(s.audio.currentTime * 1000);
|
|
149
|
+
else s.t = Math.min(len, s.t + TICK_MS * s.speed); // no sound (none kept, or it cannot play): the page keeps the time
|
|
150
|
+
if (s.t >= len) { s.t = len; s.play = false; if (s.audio) { try { s.audio.pause(); } catch { /* none */ } } paintTop(s); }
|
|
151
|
+
}
|
|
152
|
+
paintTime(s, false);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/* ── building ─────────────────────────────────────────────────────────────────────────────────────────────────── */
|
|
156
|
+
|
|
157
|
+
function build(s) {
|
|
158
|
+
const r = s.r;
|
|
159
|
+
r.top = el('header', { class: 'card rpl-top', 'aria-label': 'Meeting replay' });
|
|
160
|
+
r.now = el('div', { class: 'rpl-nowl', 'data-rpl': 'now-line' });
|
|
161
|
+
r.stage = el('div', { class: 'rpl-stagewrap' });
|
|
162
|
+
r.keys = el('div', { class: 'rpl-keywrap' });
|
|
163
|
+
r.topics = el('div', { class: 'rpl-topwrap' });
|
|
164
|
+
r.convo = el('section', { class: 'rpl-cv', 'aria-label': 'What was said, with PMOS\'s notes' });
|
|
165
|
+
put(s.root, el('div', { class: 'rpl-page' }, r.top, r.now,
|
|
166
|
+
el('div', { class: 'rpl-main' }, el('div', { class: 'rpl-s' }, r.stage, r.keys, r.topics), el('aside', { class: 'rpl-w', 'aria-label': 'Conversation' }, r.convo)), s.audio));
|
|
167
|
+
buildTop(s);
|
|
168
|
+
buildKeys(s);
|
|
169
|
+
buildTopics(s);
|
|
170
|
+
buildConvo(s);
|
|
171
|
+
s.ix = { run: -2, shot: -2, line: -2 };
|
|
172
|
+
paintTime(s, true);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function chipsOf(s) {
|
|
176
|
+
const d = s.data;
|
|
177
|
+
const start = d.meeting.at ? new Date(d.meeting.at) : null;
|
|
178
|
+
const day = start && Number.isFinite(start.getTime()) ? start.toLocaleDateString(undefined, { weekday: 'short', day: 'numeric', month: 'short' }) : '';
|
|
179
|
+
const len = Math.round(lenOf(s) / 60000);
|
|
180
|
+
const kept = d.shots.length;
|
|
181
|
+
return el('div', { class: 'chips2 dim mono' },
|
|
182
|
+
el('span', null, [day, d.start_ms != null ? wall(s, 0) + ' to ' + wall(s, lenOf(s)) : '', len + ' min'].filter(Boolean).join(' · ')),
|
|
183
|
+
d.meeting.attendees.length ? el('span', null, d.meeting.attendees.length + (d.meeting.attendees.length === 1 ? ' person' : ' people')) : null,
|
|
184
|
+
el('span', { title: 'Audio and screen shots are kept on this machine' }, icon('lock', 'sm'), (d.audio ? 'audio' : 'no audio') + (kept ? ' and ' + kept + (kept === 1 ? ' screen shot' : ' screen shots') : '')),
|
|
185
|
+
d.aside_count ? lk({ 'data-rpl': 'aside', class: 'rpl-warnc', title: 'Shots that looked like another app. They are not in the replay.' }, d.aside_count + ' set aside') : null);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function trackOf(s) {
|
|
189
|
+
const d = s.data;
|
|
190
|
+
const marks = d.keys.filter((k) => k.t != null).map((k) => {
|
|
191
|
+
const b = btn({ class: 'rpl-mkdot k-' + k.k + (k.warn ? ' warn' : ''), 'data-rpl-seek': String(k.t), 'data-rpl-mark': k.id, title: KWORD[k.k] + ' at ' + wall(s, k.t) + ': ' + k.text, 'aria-label': KWORD[k.k] + ' at ' + wall(s, k.t) }, icon(KIC[k.k], 'sm'));
|
|
192
|
+
return geo(b, { '--fc-left': pct(s, k.t) + '%' });
|
|
193
|
+
});
|
|
194
|
+
const pms = d.notes.map((n) => geo(btn({ class: 'rpl-mkdot pm', 'data-rpl-seek': String(n.t), 'data-rpl-pm': n.id, title: 'PMOS note at ' + wall(s, n.t) + ': ' + n.body, 'aria-label': 'PMOS note at ' + wall(s, n.t) }), { '--fc-left': pct(s, n.t) + '%' }));
|
|
195
|
+
const topicText = (id) => (d.topics.find((t) => t.id === id) || {}).text || '';
|
|
196
|
+
const runs = d.runs.map((rn, i) => {
|
|
197
|
+
const w = (rn.to_ms - rn.from_ms) / lenOf(s) * 100;
|
|
198
|
+
const b = btn({ class: 'rpl-run', 'data-rpl-seek': String(rn.from_ms), 'data-rpl-run': String(i), title: (rn.n ? rn.n + '. ' : '') + topicText(rn.topic) + ' · ' + wall(s, rn.from_ms) + ' · ' + Math.max(1, Math.round((rn.to_ms - rn.from_ms) / 60000)) + ' min' }, w >= 5 && rn.n ? String(rn.n) : '');
|
|
199
|
+
return geo(b, { '--fc-left': pct(s, rn.from_ms) + '%', '--fc-width': Math.max(0.5, w) + '%' });
|
|
200
|
+
});
|
|
201
|
+
const head = geo(el('span', { class: 'rpl-now', 'data-rpl': 'head' }), { '--fc-left': '0%' });
|
|
202
|
+
const ticks = [0, 15, 30, 45, 60, 75, 90].map((m) => m * 60000).filter((ms) => ms < lenOf(s) * 0.92)
|
|
203
|
+
.map((ms) => geo(el('span', { class: 'rpl-tick' }, wall(s, ms)), { '--fc-left': pct(s, ms) + '%' }));
|
|
204
|
+
return el('div', { class: 'rpl-track', role: 'group', 'aria-label': 'The meeting by topic, with decisions, questions and next steps. Press a stretch or a mark to play from there.' },
|
|
205
|
+
el('div', { class: 'rpl-rail', 'data-rpl': 'rail' }, el('div', { class: 'rpl-mks' }, ...marks), el('div', { class: 'rpl-runs', 'data-rpl': 'bar' }, ...runs), el('div', { class: 'rpl-pms' }, ...pms), head),
|
|
206
|
+
el('div', { class: 'rpl-ticks dim mono' }, ...ticks, el('span', { class: 'end' }, wall(s, lenOf(s)))));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function buildTop(s) {
|
|
210
|
+
const d = s.data;
|
|
211
|
+
const legend = el('div', { class: 'rpl-legend dim' },
|
|
212
|
+
...['decision', 'question', 'action'].map((k) => el('span', { class: 'rpl-lg k-' + k }, icon(KIC[k], 'sm'), KPLURAL[k])),
|
|
213
|
+
el('span', { class: 'rpl-lg pm' }, el('i', { class: 'rpl-lgdot' }), 'PMOS notes'), el('span', { class: 'rpl-lg warn' }, el('i', { class: 'rpl-lgdot' }), 'needs you'));
|
|
214
|
+
put(s.r.top,
|
|
215
|
+
el('div', { class: 'rpl-id' },
|
|
216
|
+
pbtn('ghost', 'Back to the note', 'prev', { 'data-rpl': 'leave', title: 'Back to the note in Calendar' }),
|
|
217
|
+
el('div', { class: 'rpl-tt' }, el('h1', null, d.meeting.title), chipsOf(s))),
|
|
218
|
+
el('div', { class: 'rpl-clock' }, el('div', null, el('b', { class: 'mono', 'data-rpl': 't' }, mmss(s.t)),
|
|
219
|
+
el('span', { class: 'dim mono' }, 'of ' + mmss(lenOf(s)) + ' · ', el('span', { 'data-rpl': 'clk' }, wall(s, s.t))))),
|
|
220
|
+
el('div', { class: 'rpl-acts', 'data-rpl': 'acts' }),
|
|
221
|
+
trackOf(s), legend);
|
|
222
|
+
paintTop(s);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** The play controls: they change with Play, Pause and the speed, so they are drawn on their own. */
|
|
226
|
+
function paintTop(s) {
|
|
227
|
+
const acts = s.r.top && s.r.top.querySelector('[data-rpl="acts"]');
|
|
228
|
+
if (!acts) return;
|
|
229
|
+
const play = pbtn('primary', s.play ? 'Pause' : 'Play', s.play ? 'pause' : 'play', { 'data-rpl': 'play', 'aria-pressed': String(s.play) });
|
|
230
|
+
put(acts, pbtn('ghost', 'Back 10 s', 'undo', { 'data-rpl': 'back' }), play,
|
|
231
|
+
el('div', { class: 'seg ini-seg', role: 'group', 'aria-label': 'Speed' }, ...[1, 1.5, 2].map((v) => btn({ 'aria-pressed': String(s.speed === v), 'data-rpl-speed': String(v) }, v + '×'))));
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function nowLine(s) {
|
|
235
|
+
const d = s.data;
|
|
236
|
+
const ri = runIndexAt(d.runs, s.t);
|
|
237
|
+
const run = ri >= 0 ? d.runs[ri] : null;
|
|
238
|
+
const tp = run ? d.topics.find((t) => t.id === run.topic) : null;
|
|
239
|
+
const si = shotIndexAt(d.shots, s.t);
|
|
240
|
+
const need = d.keys.filter((k) => k.warn).length;
|
|
241
|
+
return [el('span', { class: 'rpl-nowt' }, el('span', { class: 'ki hot' }, icon('play', 'sm')),
|
|
242
|
+
tp ? [el('span', { class: 'mono dim' }, run.n || ''), el('b', { 'data-rpl': 'topic' }, tp.text), el('span', { class: 'mono dim' }, wall(s, run.from_ms) + ' to ' + wall(s, run.to_ms))] : el('span', { class: 'dim' }, d.topics.length ? 'Before the first topic' : 'No topics were kept for this meeting')),
|
|
243
|
+
el('span', { class: 'rpl-grow' }),
|
|
244
|
+
d.shots.length ? el('span', { class: 'chip2', 'data-rpl': 'shot-state' }, icon('eye', 'sm'), si >= 0 ? 'Screen shot from ' + wall(s, d.shots[si].t) : 'Nothing shared yet') : null,
|
|
245
|
+
d.keys.length ? lk({ 'data-rpl': 'keys' }, icon('check', 'sm'), d.keys.length + (d.keys.length === 1 ? ' key point' : ' key points') + (need ? ' · ' + need + ' need you' : '')) : null];
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function stageNode(s) {
|
|
249
|
+
const d = s.data;
|
|
250
|
+
const si = shotIndexAt(d.shots, s.t);
|
|
251
|
+
const sh = si >= 0 ? d.shots[si] : null;
|
|
252
|
+
const strip = d.shots.map((x, i) => btn({ class: 'chip2' + (i === si ? ' on' : ''), 'data-rpl-seek': String(x.t), 'data-rpl-shot': String(x.t), 'aria-pressed': String(i === si), title: whyText(x.why) }, wall(s, x.t)));
|
|
253
|
+
const screen = sh
|
|
254
|
+
? btn({ class: 'rpl-shotb', 'data-rpl': 'open-shot', title: 'Open this shot large' },
|
|
255
|
+
el('img', { class: 'rpl-shot', src: '/api/notes/screen?id=' + encodeURIComponent(d.note.id) + '&t=' + Math.round(sh.t), alt: sh.text ? 'Screen at ' + mmss(sh.t) + ': ' + sh.text.slice(0, 200) : 'Screen at ' + mmss(sh.t), 'data-rpl-img': String(sh.t) }))
|
|
256
|
+
: el('div', { class: 'fold', 'data-rpl': 'no-shot' }, icon('moon', 'sm'), d.shots.length ? 'Nothing was shared yet. The first screen shot is at ' + wall(s, d.shots[0].t) + '.' : 'No screen shots were kept for this meeting.');
|
|
257
|
+
return sect({ ic: 'eye', title: 'On screen', attrs: { class: 'rpl-stage', 'data-rpl': 'stage' }, right: sh ? el('span', { class: 'dim mono' }, (whyText(sh.why) ? whyText(sh.why) + ' · ' : '') + wall(s, sh.t)) : null },
|
|
258
|
+
el('div', { class: 'rpl-screen' }, screen),
|
|
259
|
+
d.shots.length || d.aside_count ? el('div', { class: 'rpl-strip', role: 'group', 'aria-label': 'Screen shots kept, by time' }, ...strip, el('span', { class: 'rpl-grow' }),
|
|
260
|
+
d.aside_count ? pbtn('ghost', d.aside_count + ' set aside', 'eye', { 'data-rpl': 'aside' }) : null) : null);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function buildKeys(s) {
|
|
264
|
+
const d = s.data;
|
|
265
|
+
const col = (k) => {
|
|
266
|
+
const items = d.keys.filter((x) => x.k === k);
|
|
267
|
+
return el('div', { class: 'rpl-col' }, el('div', { class: 'rpl-lbl' }, icon(KIC[k], 'sm'), ' ' + KPLURAL[k] + ' ', el('span', { class: 'bdg g' }, String(items.length))),
|
|
268
|
+
items.length ? el('ul', { class: 'list', role: 'list' }, ...items.map((x) => keyRow(s, x))) : el('div', { class: 'dim rpl-none' }, 'None caught.'));
|
|
269
|
+
};
|
|
270
|
+
put(s.r.keys, d.keys.length ? sect({ ic: 'check', title: 'Key points', attrs: { 'data-rpl': 'keys-sect' }, right: d.keys.some((x) => x.k === 'action') ? pbtn('primary', 'Confirm all', 'check', { 'data-rpl': 'confirm', title: 'Make a task of each next step on the note\'s initiative' }) : null },
|
|
271
|
+
el('div', { class: 'rpl-cap' }, col('decision'), col('action'), col('question')),
|
|
272
|
+
el('p', { class: 'dim rpl-src' }, 'Each one plays from the moment it was said. Confirm all makes a task of each next step on the note\'s initiative.')) : null);
|
|
273
|
+
}
|
|
274
|
+
function keyRow(s, x) {
|
|
275
|
+
const made = s.done.has(x.id);
|
|
276
|
+
const meta = x.k === 'decision' ? x.by : x.k === 'action' ? [x.owner, x.due && 'due ' + x.due].filter(Boolean).join(' · ') : x.state === 'answered' ? 'answered' : 'still open';
|
|
277
|
+
return nrow({ title: x.text, ic: KIC[x.k], hot: x.warn, cls: 'rpl-key', attrs: { 'data-rpl-key': x.id, ...(x.t != null ? { 'data-rpl-keyt': String(x.t) } : {}) },
|
|
278
|
+
chips: [x.t != null ? lk({ class: 'mono', 'data-rpl-seek': String(x.t), 'data-rpl-play': x.id, title: 'Play from ' + wall(s, x.t) }, icon('play', 'sm'), wall(s, x.t)) : null,
|
|
279
|
+
x.state === 'owner' ? el('span', { class: 'tag rpl-warn' }, icon('user', 'sm'), 'no owner') : meta ? el('span', { class: 'tag' + (x.state === 'open' ? ' rpl-warn' : '') }, meta) : null,
|
|
280
|
+
made ? el('span', { class: 'tag' }, 'task made') : x.k !== 'question' ? el('span', { class: 'tag' }, 'draft') : null] });
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function buildTopics(s) {
|
|
284
|
+
const d = s.data;
|
|
285
|
+
const rows = d.topics.map((t) => el('li', { class: 'nrow rpl-topic', 'data-rpl-topic': t.id },
|
|
286
|
+
el('span', { class: 'ki' }, t.n ? el('span', { class: 'mono' }, String(t.n)) : icon('clock', 'sm')),
|
|
287
|
+
el('div', { class: 'nb' }, el('span', { class: 'tt' }, t.text),
|
|
288
|
+
el('div', { class: 'chips' },
|
|
289
|
+
t.first_t != null ? lk({ class: 'mono', 'data-rpl-seek': String(t.first_t), title: 'Play from the first time it came up' }, icon('play', 'sm'), wall(s, t.first_t)) : null,
|
|
290
|
+
t.parked ? el('span', { class: 'tag rpl-warn' }, 'parked') : t.first_t == null ? el('span', { class: 'tag' }, 'not reached') : el('span', { class: 'tag' }, (t.minutes < 1 ? 'under 1 min' : t.minutes + ' min') + (t.stretches > 1 ? ' in ' + t.stretches + ' stretches' : '')),
|
|
291
|
+
t.by ? el('span', { class: 'tag' }, 'added by ' + t.by) : null)),
|
|
292
|
+
el('span')));
|
|
293
|
+
put(s.r.topics, d.topics.length ? sect({ ic: 'todo', title: 'Topics', count: d.topics.length, attrs: { 'data-rpl': 'topics-sect' } }, el('ul', { class: 'list', role: 'list' }, ...rows)) : null);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/* the conversation: built when the filter or the search changes; the current line is marked in place as time moves */
|
|
297
|
+
function buildConvo(s) {
|
|
298
|
+
const d = s.data;
|
|
299
|
+
const q = s.q.trim().toLowerCase();
|
|
300
|
+
const items = [...d.lines.map((l) => ({ t: l.t, l })), ...d.notes.map((n) => ({ t: n.t, n }))].sort((a, b) => a.t - b.t || (a.n ? 1 : 0) - (b.n ? 1 : 0))
|
|
301
|
+
.filter((x) => s.filter === 'all' || (s.filter === 'key' ? x.l && x.l.key : x.n))
|
|
302
|
+
.filter((x) => !q || (x.l ? x.l.text : x.n.body).toLowerCase().includes(q));
|
|
303
|
+
const rows = items.map((x) => {
|
|
304
|
+
if (x.n) {
|
|
305
|
+
return el('li', { class: 'rpl-pm', 'data-rpl-pmt': String(x.t) }, btn({ class: 'rpl-lb', 'data-rpl-seek': String(x.t) },
|
|
306
|
+
el('span', { class: 'rpl-cvwho' }, el('span', { class: 'fc-avdot', 'aria-hidden': 'true' }, icon('spark', 'sm')), el('b', { class: 'mono' }, 'PMOS'), el('span', { class: 'rpl-cat' }, x.n.cat), el('span', { class: 'dim mono rpl-time' }, wall(s, x.t))),
|
|
307
|
+
el('span', { class: 'rpl-pb' }, x.n.body)));
|
|
308
|
+
}
|
|
309
|
+
const l = x.l;
|
|
310
|
+
const k = l.key ? d.keys.find((z) => z.id === l.key.id) : null;
|
|
311
|
+
return el('li', { class: 'rpl-l', 'data-rpl-line': String(l.i), 'data-rpl-linet': String(l.t) }, btn({ class: 'rpl-lb', 'data-rpl-seek': String(l.t) },
|
|
312
|
+
el('span', { class: 'rpl-lh' }, el('b', null, l.who || 'You'), el('span', { class: 'dim mono' }, wall(s, l.t)), k ? el('span', { class: 'tag rpl-kt k-' + k.k }, icon(KIC[k.k], 'sm'), KWORD[k.k]) : null),
|
|
313
|
+
el('span', { class: 'rpl-lt' }, l.text)));
|
|
314
|
+
});
|
|
315
|
+
const seg = el('div', { class: 'seg ini-seg', role: 'group', 'aria-label': 'Show' }, ...[['all', 'All'], ['key', 'Key points'], ['pm', 'PMOS']].map(([k, label]) => btn({ 'aria-pressed': String(s.filter === k), 'data-rpl-filter': k }, label)));
|
|
316
|
+
const input = el('input', { type: 'search', 'data-rpl-q': '', 'aria-label': 'Search what was said', placeholder: 'Search what was said', value: s.q });
|
|
317
|
+
put(s.r.convo, el('div', { class: 'rpl-cvh' }, el('b', null, 'Conversation'), seg),
|
|
318
|
+
el('ol', { class: 'rpl-cvs', role: 'list', 'data-rpl': 'list' }, rows.length ? rows : el('li', { class: 'dim' }, d.lines.length || d.notes.length ? 'Nothing matches.' : 'No words were kept for this meeting.')),
|
|
319
|
+
el('form', { class: 'rpl-cvin', onSubmit: (e) => e.preventDefault() }, input, icon('search', 'sm')));
|
|
320
|
+
s.ix.line = -2;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/* ── painting the time ────────────────────────────────────────────────────────────────────────────────────────── */
|
|
324
|
+
|
|
325
|
+
function paintTime(s, force) {
|
|
326
|
+
const d = s.data; const root = s.root;
|
|
327
|
+
const set = (k, v) => { const n = root.querySelector('[data-rpl="' + k + '"]'); if (n && n.textContent !== v) n.textContent = v; };
|
|
328
|
+
set('t', mmss(s.t)); set('clk', wall(s, s.t));
|
|
329
|
+
const head = root.querySelector('[data-rpl="head"]'); if (head) head.style.setProperty('--fc-left', pct(s, s.t) + '%');
|
|
330
|
+
root.dataset.rplT = String(Math.round(s.t));
|
|
331
|
+
const ri = runIndexAt(d.runs, s.t); const si = shotIndexAt(d.shots, s.t); const li = lineIndexAt(d.lines, s.t);
|
|
332
|
+
if (force || ri !== s.ix.run) {
|
|
333
|
+
root.querySelectorAll('.rpl-run').forEach((b) => b.classList.toggle('c', Number(b.dataset.rplRun) === ri));
|
|
334
|
+
const cur = ri >= 0 ? d.runs[ri].topic : null;
|
|
335
|
+
root.querySelectorAll('.rpl-topic').forEach((n) => n.classList.toggle('cur', n.dataset.rplTopic === cur));
|
|
336
|
+
}
|
|
337
|
+
if (force || ri !== s.ix.run || si !== s.ix.shot) put(s.r.now, nowLine(s));
|
|
338
|
+
if (force || si !== s.ix.shot) put(s.r.stage, stageNode(s));
|
|
339
|
+
s.ix.run = ri; s.ix.shot = si;
|
|
340
|
+
root.querySelectorAll('.rpl-key[data-rpl-keyt]').forEach((n) => n.classList.toggle('cur', s.t >= Number(n.dataset.rplKeyt) && s.t - Number(n.dataset.rplKeyt) < NEAR_KEY_MS));
|
|
341
|
+
const list = root.querySelector('[data-rpl="list"]');
|
|
342
|
+
if (list && (force || li !== s.ix.line)) {
|
|
343
|
+
let curNode = null;
|
|
344
|
+
list.querySelectorAll('.rpl-l').forEach((n) => { const at = Number(n.dataset.rplLinet); const on = Number(n.dataset.rplLine) === li; n.classList.toggle('on', on); n.classList.toggle('fut', at > s.t); if (on) { curNode = n; n.setAttribute('aria-current', 'true'); } else n.removeAttribute('aria-current'); });
|
|
345
|
+
list.querySelectorAll('.rpl-pm').forEach((n) => n.classList.toggle('fut', Number(n.dataset.rplPmt) > s.t));
|
|
346
|
+
if (!curNode) { // under a filter the current line may be hidden: follow the last one shown that is not in the future
|
|
347
|
+
const shown = [...list.querySelectorAll('.rpl-l, .rpl-pm')].filter((n) => Number(n.dataset.rplLinet != null ? n.dataset.rplLinet : n.dataset.rplPmt) <= s.t);
|
|
348
|
+
curNode = shown[shown.length - 1] || null;
|
|
349
|
+
}
|
|
350
|
+
if (curNode) list.scrollTop = Math.max(0, curNode.offsetTop - list.offsetTop - list.clientHeight / 3);
|
|
351
|
+
s.ix.line = li;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/* ── presses ──────────────────────────────────────────────────────────────────────────────────────────────────── */
|
|
356
|
+
|
|
357
|
+
async function confirmAll(s) {
|
|
358
|
+
const d = s.data;
|
|
359
|
+
const todo = d.keys.filter((k) => k.k === 'action' && !s.done.has(k.id));
|
|
360
|
+
if (!todo.length) { toast('Every next step is already a task.'); return; }
|
|
361
|
+
const { makeTask } = await import('./notes-task.js');
|
|
362
|
+
const n = { id: d.note.id, title: d.note.title, initiative: d.note.initiative, ws: d.note.ws };
|
|
363
|
+
for (const k of todo) {
|
|
364
|
+
const id = await makeTask(n, { text: k.text, owner: k.owner, due: k.due });
|
|
365
|
+
if (!id) break; // makeTask has said why (no initiative, no stage); the rest wait
|
|
366
|
+
s.done.add(k.id);
|
|
367
|
+
if (S !== s) return;
|
|
368
|
+
}
|
|
369
|
+
buildKeys(s); paintTime(s, true);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function asideSheet(s) {
|
|
373
|
+
const aside = window.NotesAside;
|
|
374
|
+
if (aside && typeof aside.open === 'function') { aside.open(s.data.note.id); return; }
|
|
375
|
+
toast(s.data.aside_count + (s.data.aside_count === 1 ? ' shot' : ' shots') + ' looked like another app and ' + (s.data.aside_count === 1 ? 'is' : 'are') + ' not in the replay. They are deleted after 7 days.');
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function onClick(ev) {
|
|
379
|
+
if (!S || !S.data) { if (S && ev.target.closest && ev.target.closest('[data-rpl="close"]')) closeReplay(); return; }
|
|
380
|
+
const s = S;
|
|
381
|
+
if (!s.root.contains(ev.target)) return;
|
|
382
|
+
const t = ev.target.closest('[data-rpl-seek],[data-rpl-speed],[data-rpl-filter],[data-rpl]');
|
|
383
|
+
const rail = ev.target.closest('[data-rpl="rail"]');
|
|
384
|
+
if (!t && !rail) return;
|
|
385
|
+
if (t && t.dataset.rplSeek !== undefined) { ev.preventDefault(); seek(s, Number(t.dataset.rplSeek), true); return; }
|
|
386
|
+
if (t && t.dataset.rplSpeed) { s.speed = Number(t.dataset.rplSpeed); if (s.audio) s.audio.playbackRate = s.speed; paintTop(s); return; }
|
|
387
|
+
if (t && t.dataset.rplFilter) { s.filter = t.dataset.rplFilter; buildConvo(s); paintTime(s, true); return; }
|
|
388
|
+
const k = t && t.dataset.rpl;
|
|
389
|
+
if (k === 'play') { if (s.play) pauseNow(s); else { if (s.t >= lenOf(s)) seek(s, 0, false); playNow(s); } paintTop(s); return; }
|
|
390
|
+
if (k === 'back') { seek(s, s.t - BACK_MS, false); return; }
|
|
391
|
+
if (k === 'leave') { const id = s.data.note.id; closeReplay(); window.dispatchEvent(new CustomEvent('c3-open-note', { detail: { id } })); return; }
|
|
392
|
+
if (k === 'keys') { const n = s.root.querySelector('.rpl-cap'); if (n && n.scrollIntoView) n.scrollIntoView({ behavior: 'smooth', block: 'center' }); return; }
|
|
393
|
+
if (k === 'open-shot') { const i = shotIndexAt(s.data.shots, s.t); if (i >= 0) openShot({ id: s.data.note.id, frames: s.data.shots, index: i, seek: (ms) => seek(s, ms, true) }); return; }
|
|
394
|
+
if (k === 'aside') { asideSheet(s); return; }
|
|
395
|
+
if (k === 'confirm') { confirmAll(s); return; }
|
|
396
|
+
// a press on the empty rail scrubs: the time under the pointer
|
|
397
|
+
if (rail) { const b = rail.getBoundingClientRect(); if (b.width > 0) seek(s, (ev.clientX - b.left) / b.width * lenOf(s), true); }
|
|
398
|
+
}
|
|
399
|
+
function onInput(ev) {
|
|
400
|
+
if (!S || !S.data) return;
|
|
401
|
+
const q = ev.target.closest && ev.target.closest('[data-rpl-q]'); if (!q || !S.root.contains(q)) return;
|
|
402
|
+
const s = S; s.q = q.value; const p = q.selectionStart;
|
|
403
|
+
buildConvo(s); paintTime(s, true);
|
|
404
|
+
const n = s.root.querySelector('[data-rpl-q]'); if (n) { n.focus(); try { n.setSelectionRange(p, p); } catch { /* not a text field */ } }
|
|
405
|
+
}
|
|
406
|
+
document.addEventListener('click', onClick);
|
|
407
|
+
document.addEventListener('input', onInput);
|
|
408
|
+
|
|
409
|
+
if (typeof window !== 'undefined') {
|
|
410
|
+
window.C3Replay = { open: openReplay, close: closeReplay,
|
|
411
|
+
/** What the page is doing, for a test and for the entry points: { open, t, playing, speed } */
|
|
412
|
+
state: () => (S ? { open: true, t: S.t, playing: S.play, speed: S.speed, loaded: !!S.data, sound: !!S.audio && !S.soundOff } : { open: false }) };
|
|
413
|
+
}
|
|
@@ -224,6 +224,7 @@ async function loadOpen(inst) {
|
|
|
224
224
|
inst.note = r && r.ok ? r.note : null;
|
|
225
225
|
inst.rec = r && r.ok ? r.recording || null : null;
|
|
226
226
|
inst.shots = r && r.ok && r.screen ? r.screen.frames || [] : [];
|
|
227
|
+
inst.aside = r && r.ok && r.screen ? r.screen.aside || 0 : 0;
|
|
227
228
|
drawRight(inst);
|
|
228
229
|
}
|
|
229
230
|
/** The replay of the note's kept audio: one <audio> per note, kept across redraws so playback goes on. Null when no audio is kept. */
|
|
@@ -282,7 +283,7 @@ function drawRight(inst, force) {
|
|
|
282
283
|
const n = inst.note;
|
|
283
284
|
const live = dock.recordState();
|
|
284
285
|
const held = !!n && live.mode !== 'idle' && live.id === n.id;
|
|
285
|
-
const sig = JSON.stringify([inst.open, n && [n.updated_at, n.title, n.final_notes && n.final_notes.at, (n.transcript || []).length, (n.highlights || []).length, n.agent && n.agent.markdown, iniName(n.initiative), n.state, n.resume_until, n.ended_at, (n.sessions || []).length, n.meeting && n.meeting.context && n.meeting.context.taken_at], inst.rec && [inst.rec.bytes, inst.rec.cleaned], (inst.shots || []).length, live.mode, live.id, !!inst.rows, inst.ctxOpen, inst.info]);
|
|
286
|
+
const sig = JSON.stringify([inst.open, n && [n.updated_at, n.title, n.final_notes && n.final_notes.at, (n.transcript || []).length, (n.highlights || []).length, n.agent && n.agent.markdown, iniName(n.initiative), n.state, n.resume_until, n.ended_at, (n.sessions || []).length, n.meeting && n.meeting.context && n.meeting.context.taken_at], inst.rec && [inst.rec.bytes, inst.rec.cleaned], (inst.shots || []).length, inst.aside || 0, live.mode, live.id, !!inst.rows, inst.ctxOpen, inst.info]);
|
|
286
287
|
// a held note draws again on every render: the clock, the lines and the paused minutes move. Nothing else draws without a change.
|
|
287
288
|
if (!force && !held && sig === inst.rightSig) return;
|
|
288
289
|
// a field being typed in is not redrawn under the cursor, except in the live block, whose fields are kept across redraws
|
|
@@ -417,7 +418,8 @@ function noteParts(inst, n, live) {
|
|
|
417
418
|
el('p', { class: 'nt-hint' }, 'Kept on this machine. Press a time in the discussion to play from there.')));
|
|
418
419
|
// the screenshots of the shared screen, on the same clock as the replay and the transcript (notes-screen-capture)
|
|
419
420
|
const shots = held || n.private ? [] : inst.shots || [];
|
|
420
|
-
|
|
421
|
+
const asideN = held || n.private ? 0 : inst.aside || 0;
|
|
422
|
+
if (shots.length || asideN) out.push(screenStrip({ id: n.id, frames: shots, aside: asideN, seek: replay ? (ms) => seekReplay(inst, ms) : null, replay }));
|
|
421
423
|
|
|
422
424
|
const cf = conflictsOf(n);
|
|
423
425
|
if (cf.length) {
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/* listen-sheet.js — the sheets Listen shows before it asks the browser to share (initiative meeting-material-replay, part 3).
|
|
2
|
+
*
|
|
3
|
+
* askListen({ shots }) → Promise<{ app, picture, shots } | null> "Where is the meeting?": Zoom app, Google Meet in Chrome or Another app,
|
|
4
|
+
* what PMOS listens to and sees, and Start listening or Sound only. null when the PM closes it.
|
|
5
|
+
* `shots: false` means Capture the shared screen is off in Settings: the sheet offers sound only.
|
|
6
|
+
* askWindow(getStream) → Promise<MediaStream | null> the second share: "Pick the meeting window". A browser lets a page share only
|
|
7
|
+
* right after a press, so the button here makes the call itself (getStream). null when skipped.
|
|
8
|
+
*
|
|
9
|
+
* The last choice is remembered in localStorage (listen-sources.js recall / remember), wrapped so a blocked store changes nothing.
|
|
10
|
+
* The words are the mockup's (46-capture.js). Nothing here sends anything: it only draws and answers.
|
|
11
|
+
*/
|
|
12
|
+
import { el } from './dom.js';
|
|
13
|
+
import { button } from './primitives.js';
|
|
14
|
+
import { openSheet, closeSheet } from './c3-sheet.js';
|
|
15
|
+
import { icon, sect, pbtn, plural } from './c3-ini-ui.js';
|
|
16
|
+
import { APPS, planListen, rowsOf, recall, remember } from './listen-sources.js';
|
|
17
|
+
import { ensureListenCss } from './notes-shots.js';
|
|
18
|
+
|
|
19
|
+
const store = () => { try { return window.localStorage; } catch { return null; } };
|
|
20
|
+
|
|
21
|
+
const tag = (t, cls) => el('span', { class: 'tag' + (cls ? ' ' + cls : '') }, t);
|
|
22
|
+
function row(r, act) {
|
|
23
|
+
return el('li', { class: 'nrow', 'data-ls-row': r.icon },
|
|
24
|
+
el('span', { class: 'ki' + (r.ok ? '' : ' hot') }, icon(r.icon)),
|
|
25
|
+
el('div', { class: 'nb' }, el('span', { class: 'tt' }, r.title), el('div', { class: 'chips' }, r.tags.map((t, k) => (k === 0 && r.ok ? el('span', { class: 'tag lsn-ok' }, icon('check', 'sm'), t) : tag(t, !r.ok && k === 0 ? 'lsn-warn' : ''))))),
|
|
26
|
+
act ? el('div', { class: 'acts' }, act) : el('span'));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function askListen({ shots = true } = {}) {
|
|
30
|
+
ensureListenCss();
|
|
31
|
+
return new Promise((resolve) => {
|
|
32
|
+
const c = recall(store());
|
|
33
|
+
if (!shots) c.shots = false;
|
|
34
|
+
let done = false;
|
|
35
|
+
const finish = (v) => { if (done) return; done = true; resolve(v); };
|
|
36
|
+
const body = el('div', { class: 'lsn', 'data-ls': 'listen' });
|
|
37
|
+
openSheet({ title: 'Listen to the meeting', icon: 'mic', wide: true, body, onClose: () => finish(null) });
|
|
38
|
+
const go = (withShots) => {
|
|
39
|
+
const choice = { app: c.app, picture: c.picture, shots: withShots };
|
|
40
|
+
remember(store(), { app: c.app, picture: c.picture, shots: true }); // the place, not the pictures: Sound only is for this meeting
|
|
41
|
+
finish(choice);
|
|
42
|
+
closeSheet();
|
|
43
|
+
};
|
|
44
|
+
const draw = () => {
|
|
45
|
+
const plan = planListen(c);
|
|
46
|
+
const meet = c.app === 'meet';
|
|
47
|
+
const seg = el('div', { class: 'seg', role: 'group', 'aria-label': 'Meeting app' }, APPS.map((a) => button({ label: a.label, variant: 'ghost', size: 'sm', onClick: () => { c.app = a.id; draw(); }, attrs: { 'aria-pressed': String(c.app === a.id), 'data-ls-app': a.id } })));
|
|
48
|
+
const rows = rowsOf(plan).map((r) => {
|
|
49
|
+
if (r.icon === 'eye' && !meet && c.shots) return row(r, pbtn('ghost', 'Change', null, { 'data-ls-pic': 'screen', onclick: () => { c.picture = 'screen'; draw(); } }));
|
|
50
|
+
if (r.icon === 'alert') return row(r, pbtn('outline', 'Use the meeting window', null, { 'data-ls-pic': 'window', onclick: () => { c.picture = 'window'; draw(); } }));
|
|
51
|
+
return row(r);
|
|
52
|
+
});
|
|
53
|
+
const n = plan.shares.length;
|
|
54
|
+
const help = !c.shots ? 'PMOS takes no pictures. It still hears the meeting.'
|
|
55
|
+
: meet ? 'With the PMOS extension in Chrome, Listen starts on the Meet tab in one click, with no sharing window.'
|
|
56
|
+
: c.picture === 'window' ? 'Pick the meeting window in the second sharing window. When you present, the call shows your slides, so the shots keep them too. Your other apps are never seen.'
|
|
57
|
+
: 'Shots of other apps are set aside after the meeting for you to keep or delete. They never reach the note, the replay or the agent.';
|
|
58
|
+
body.replaceChildren(
|
|
59
|
+
el('p', { class: 'dim cal3-note' }, 'The browser asks you to pick what to share' + (n > 1 ? ', once for each line below' : '') + '. Nothing leaves this machine.'),
|
|
60
|
+
sect({ ic: 'cal', title: 'Where is the meeting?', right: seg }),
|
|
61
|
+
sect({ ic: 'eye', title: 'What PMOS listens to and sees', right: el('span', { class: 'dim mono' }, plural(n, 'share')) },
|
|
62
|
+
el('ul', { class: 'list', role: 'list' }, ...rows), el('p', { class: 'dim lsn-help', 'data-ls-help': '' }, help)),
|
|
63
|
+
sect({ ic: 'clock', title: 'While it runs' },
|
|
64
|
+
el('ul', { class: 'list', role: 'list' },
|
|
65
|
+
row({ icon: 'stop', title: 'Pause shots', tags: ['stops the pictures, keeps the sound', 'on the REC chip and in Focus'], ok: false }),
|
|
66
|
+
row({ icon: 'lock', title: 'Private', tags: ['stops sound and pictures until you resume'], ok: false }))),
|
|
67
|
+
el('div', { class: 'iacts' },
|
|
68
|
+
pbtn('primary', 'Start listening', 'mic', { 'data-ls-go': 'start', onclick: () => go(c.shots) }),
|
|
69
|
+
shots ? pbtn('ghost', 'Sound only, no shots', null, { 'data-ls-go': 'sound', onclick: () => go(false) }) : null));
|
|
70
|
+
};
|
|
71
|
+
draw();
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The second share. The button makes the browser call itself, because that call needs a press of its own. */
|
|
76
|
+
export function askWindow(getStream) {
|
|
77
|
+
ensureListenCss();
|
|
78
|
+
return new Promise((resolve) => {
|
|
79
|
+
let done = false;
|
|
80
|
+
const finish = (v) => { if (done) return; done = true; resolve(v); };
|
|
81
|
+
const msg = el('p', { class: 'dim lsn-help', role: 'status', 'data-ls-msg': '' }, 'The sound is on. Now pick the meeting\'s window, so PMOS can take shots of it only. Your other apps are never seen.');
|
|
82
|
+
const pick = pbtn('primary', 'Pick the meeting window', 'eye', { 'data-ls-pick': '', onclick: async () => {
|
|
83
|
+
pick.disabled = true;
|
|
84
|
+
let s = null;
|
|
85
|
+
try { s = await getStream(); } catch { s = null; }
|
|
86
|
+
if (!s) { msg.textContent = 'No window was picked. Pick it again, or listen to the sound only.'; pick.disabled = false; return; }
|
|
87
|
+
finish(s);
|
|
88
|
+
closeSheet();
|
|
89
|
+
} });
|
|
90
|
+
const body = el('div', { class: 'lsn', 'data-ls': 'window' }, msg,
|
|
91
|
+
el('div', { class: 'iacts' }, pick, pbtn('ghost', 'Sound only, no shots', null, { 'data-ls-skip': '', onclick: () => { finish(null); closeSheet(); } })));
|
|
92
|
+
openSheet({ title: 'Pick the meeting window', icon: 'eye', body, onClose: () => finish(null) });
|
|
93
|
+
});
|
|
94
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/* listen-sources.js — where the meeting is, and which browser shares Listen asks for (initiative meeting-material-replay, part 3).
|
|
2
|
+
*
|
|
3
|
+
* APPS the three places a meeting can be: Zoom app, Google Meet in Chrome, Another app
|
|
4
|
+
* normalize(choice) → { app, picture, shots } a choice with every field in its range
|
|
5
|
+
* planListen(choice) → plan the shares to ask for and what each one is used for
|
|
6
|
+
* surfaceOf(track, planned) → 'window' | 'browser' | 'monitor' | null what the picture really is
|
|
7
|
+
* recall(storage) · remember(storage, choice) the last choice, kept on this machine
|
|
8
|
+
* rowsOf(plan) → [{ icon, title, tags, ok }] the lines the Listen sheet shows
|
|
9
|
+
*
|
|
10
|
+
* WHY SEPARATE SHARES. Only the whole screen carries the sound of Zoom, but its picture holds every app the PM switches to. So for Zoom
|
|
11
|
+
* or another app the SOUND comes from one share of the whole screen with system audio (its picture is never used) and the PICTURE from a
|
|
12
|
+
* second share of the meeting's window only, which stays captured while the PM works in other apps. The second share needs its own press,
|
|
13
|
+
* and it can be skipped (sound only). Google Meet is a tab: one share of that tab carries both. The PM can still choose the whole screen
|
|
14
|
+
* as the picture; then the safety net in lib/notes-screen.js sets aside the shots that look like another app.
|
|
15
|
+
*
|
|
16
|
+
* Pure: no DOM and no network, so the unit test (listen-sources.test.js) imports it from node.
|
|
17
|
+
*/
|
|
18
|
+
export const APPS = [
|
|
19
|
+
{ id: 'zoom', label: 'Zoom app' },
|
|
20
|
+
{ id: 'meet', label: 'Google Meet in Chrome' },
|
|
21
|
+
{ id: 'other', label: 'Another app' },
|
|
22
|
+
];
|
|
23
|
+
export const KEY = 'pmos.listen.where';
|
|
24
|
+
const SURFACES = ['window', 'browser', 'monitor'];
|
|
25
|
+
const AUDIO = { echoCancellation: false, noiseSuppression: false, autoGainControl: false };
|
|
26
|
+
|
|
27
|
+
/** The three options of one choice, each in its range. The default is the Zoom app with the picture from its window. */
|
|
28
|
+
export function normalize(c) {
|
|
29
|
+
const o = c && typeof c === 'object' ? c : {};
|
|
30
|
+
return {
|
|
31
|
+
app: APPS.some((a) => a.id === o.app) ? o.app : 'zoom',
|
|
32
|
+
picture: o.picture === 'screen' ? 'screen' : 'window',
|
|
33
|
+
shots: o.shots !== false,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** getDisplayMedia options for one share. Chrome ignores a hint it does not know. */
|
|
38
|
+
export function shareOptions(role, app) {
|
|
39
|
+
if (role === 'picture') return { video: { displaySurface: 'window' }, audio: false, monitorTypeSurfaces: 'exclude', surfaceSwitching: 'exclude', selfBrowserSurface: 'exclude' };
|
|
40
|
+
if (app === 'meet') return { video: { displaySurface: 'browser' }, audio: { ...AUDIO }, monitorTypeSurfaces: 'exclude', surfaceSwitching: 'include', selfBrowserSurface: 'exclude' };
|
|
41
|
+
return { video: { displaySurface: 'monitor' }, audio: { ...AUDIO }, systemAudio: 'include', monitorTypeSurfaces: 'include', surfaceSwitching: 'include', selfBrowserSurface: 'exclude' };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The shares Listen asks for. Each share says its role ('sound' | 'picture' | 'both'), whether its picture and its sound are used,
|
|
45
|
+
* and whether the PM may skip it. `surface` is what the pictures show: the meeting's window, the Meet tab, the whole screen, or null (no pictures). */
|
|
46
|
+
export function planListen(choice) {
|
|
47
|
+
const c = normalize(choice);
|
|
48
|
+
const share = (role, keepVideo, keepAudio, optional = false) => ({ role, options: shareOptions(role, c.app), keepVideo, keepAudio, optional });
|
|
49
|
+
if (c.app === 'meet') return { ...c, shares: [share('both', c.shots, true)], surface: c.shots ? 'browser' : null };
|
|
50
|
+
if (!c.shots) return { ...c, shares: [share('sound', false, true)], surface: null };
|
|
51
|
+
if (c.picture === 'screen') return { ...c, shares: [share('both', true, true)], surface: 'monitor' };
|
|
52
|
+
return { ...c, shares: [share('sound', false, true), share('picture', true, false, true)], surface: 'window' };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** What the picture really is: the browser's own word for the share when it gives one, else what was asked for. Null when neither is known. */
|
|
56
|
+
export function surfaceOf(track, planned) {
|
|
57
|
+
let s = null;
|
|
58
|
+
try { s = track && typeof track.getSettings === 'function' ? track.getSettings().displaySurface : null; } catch { s = null; }
|
|
59
|
+
if (SURFACES.includes(s)) return s;
|
|
60
|
+
return SURFACES.includes(planned) ? planned : null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The last choice, from a Storage (localStorage). Anything wrong with storage gives the default. */
|
|
64
|
+
export function recall(storage) {
|
|
65
|
+
try { const raw = storage && storage.getItem(KEY); return normalize(raw ? JSON.parse(raw) : null); } catch { return normalize(null); }
|
|
66
|
+
}
|
|
67
|
+
export function remember(storage, choice) {
|
|
68
|
+
try { if (!storage) return false; storage.setItem(KEY, JSON.stringify(normalize(choice))); return true; } catch { return false; }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The lines the sheet shows for a choice: what is heard and what is seen. */
|
|
72
|
+
export function rowsOf(plan) {
|
|
73
|
+
const p = plan && plan.shares ? plan : planListen(plan);
|
|
74
|
+
const win = p.app === 'zoom' ? 'Zoom Meeting' : 'meeting app\'s';
|
|
75
|
+
if (p.app === 'meet') {
|
|
76
|
+
return [{ icon: 'mic', title: p.shots ? 'Sound and picture: the Meet tab' : 'Sound: the Meet tab', tags: ['tab audio on', p.shots ? 'only this tab is seen' : 'no pictures'], ok: true }];
|
|
77
|
+
}
|
|
78
|
+
const sound = { icon: 'vol', title: 'Sound: Entire screen, with system audio', tags: ['hears every app, so ' + (p.app === 'zoom' ? 'Zoom' : 'your meeting app') + ' too', p.picture === 'screen' && p.shots ? 'its picture is kept' : 'its picture is not kept'], ok: true };
|
|
79
|
+
if (!p.shots) return [sound, { icon: 'eye', title: 'Picture: none', tags: ['sound only, no shots'], ok: true }];
|
|
80
|
+
if (p.picture === 'screen') return [sound, { icon: 'alert', title: 'Picture: Entire screen', tags: ['every app you switch to is on it', 'PMOS keeps only shots that look like the call'], ok: false }];
|
|
81
|
+
return [sound, { icon: 'eye', title: 'Picture: the ' + win + ' window', tags: ['only this window is seen', 'still captured while you are in other apps'], ok: true }];
|
|
82
|
+
}
|