oficina / slides Public

Clone
git clone https://amadan.net/oficina/slides

Plain git — no account needed to clone.

Download

Download this file

1// present.js — the live half of presenting: a keyboard, a swipe, and
2// a stream that keeps every screen on the same slide.
3//
4// An ENHANCEMENT and nothing else. Every page that loads it — the
5// fullscreen surface, the presenter view, the phone remote, the
6// audience's follow view — is complete without it: the buttons are
7// forms, the pager is links, the follow view has a meta refresh.
8//
9// It sends exactly what those forms send (op, t, slide, POSTed to
10// /p/{session}/command), so there is one protocol rather than two, and
11// only on a page rendered for whoever holds the remote: the token
12// arrives as data-remote-token, and a page without one cannot send.
13//
14// Every URL it reads comes down as an attribute (codex 5). The
15// audience's follow view is served from a share link and stays there —
16// its stream and its slide fragments are /s/{token} URLs, so revoking
17// the link stops it — and it carries no session sqid at all, which is
18// why nothing in here builds a /p/… address out of one.
19(function () {
20 var root = document.querySelector('[data-session], [data-stream]');
21 if (!root) return;
22 // Empty on a page that holds no session capability of its own.
23 var session = root.getAttribute('data-session') || '';
24 var token = root.getAttribute('data-remote-token') || '';
25 // The presenter view and the remote are always the session's own, so
26 // they may fall back to its address; the surface says which it is.
27 var streamHref = root.getAttribute('data-stream') ||
28 (session ? '/p/' + session + '/stream' : '');
29 // {n} is where the slide number goes. Absent on the pages that have
30 // no stage to swap — they reload instead.
31 var fragmentHref = root.getAttribute('data-fragment') || '';
32 var stage = document.querySelector('[slides-stage]');
33 var note = document.querySelector('[slides-blackout]');
34 if (!streamHref) return;
35
36 function command(op, slide) {
37 if (!token || !session) return;
38 var body = new URLSearchParams({ op: op, t: token });
39 if (slide) body.set('slide', slide);
40 fetch('/p/' + session + '/command', {
41 method: 'POST',
42 body: body,
43 credentials: 'same-origin',
44 headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
45 }).catch(function () {});
46 }
47
48 document.addEventListener('keydown', function (e) {
49 if (e.metaKey || e.ctrlKey || e.altKey) return;
50 var tag = e.target && e.target.tagName;
51 if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
52 switch (e.key) {
53 case 'ArrowRight': case ' ': case 'PageDown':
54 command('next');
55 break;
56 case 'ArrowLeft': case 'PageUp':
57 command('prev');
58 break;
59 case 'b': case 'B':
60 command('blackout');
61 break;
62 case 'n': case 'N':
63 var notes = root.getAttribute('data-notes');
64 if (notes) window.open(notes, 'slides-notes');
65 break;
66 case 'Escape':
67 if (document.fullscreenElement) document.exitFullscreen();
68 return;
69 default: return;
70 }
71 e.preventDefault();
72 });
73
74 var startX = null;
75 root.addEventListener('touchstart', function (e) {
76 startX = e.changedTouches[0].clientX;
77 }, { passive: true });
78 root.addEventListener('touchend', function (e) {
79 if (startX === null) return;
80 var dx = e.changedTouches[0].clientX - startX;
81 startX = null;
82 // Enough to be a swipe rather than a tap that moved.
83 if (Math.abs(dx) < 60) return;
84 command(dx < 0 ? 'next' : 'prev');
85 }, { passive: true });
86
87 // What is on the screen, and what a fetch is trying to put there.
88 // TWO facts, not one (codex 13). They were one — the data-slide
89 // attribute, written the moment an event arrived — and that is a
90 // projector left permanently behind: with the record already saying
91 // 3, a late response for 2 overwrote the stage and the event that
92 // would have corrected it was suppressed as a slide the page was
93 // already on.
94 //
95 // So: displayed only ever moves after an install succeeds, requested
96 // rolls back to displayed when a fetch fails, and every response
97 // carries the id of the request that asked for it — anything but the
98 // newest is dropped on the floor, however late it turns up.
99 var displayed = Number(root.getAttribute('data-slide')) || 0;
100 var requested = displayed;
101 var newest = 0;
102
103 function show(slide) {
104 // Slide numbers on the wire are 1-based; anything else is not an
105 // event this page understands and is left alone rather than fetched.
106 if (!(slide > 0) || slide === requested) return;
107 requested = slide;
108 var mine = ++newest;
109 var back = function () {
110 // Only this request's own failure rolls the record back, and
111 // only while it is still the newest: otherwise the next event
112 // for this slide would be suppressed as one already asked for.
113 if (mine === newest && requested === slide) requested = displayed;
114 };
115 fetch(fragmentHref.replace('{n}', String(slide)), { credentials: 'same-origin' })
116 .then(function (r) { return r.ok ? r.text() : null; })
117 .then(function (html) {
118 if (mine !== newest) return; // superseded; this slide is old news
119 if (html === null) { back(); return; }
120 stage.innerHTML = html;
121 displayed = slide;
122 root.setAttribute('data-slide', String(slide));
123 })
124 .catch(back);
125 }
126
127 // The pages with no stage to swap — the presenter view and the
128 // remote — follow by reloading: they show two slides, the notes and
129 // the blackout state, and re-rendering all that in here would be a
130 // second renderer to keep in step with the Go one. Their root says
131 // what they were rendered with (data-slide, data-blackout), and they
132 // reload only when an event says something else.
133 //
134 // And at most once for any one state. A page that has just reloaded
135 // for slide 3 and still says something else — a root missing its
136 // data-slide, or a session pointing past a deck that has since got
137 // shorter, which the server clamps — would otherwise reload again on
138 // the stream's first event, and again after that, forever: the
139 // presenter view once did exactly that about fifty times a second
140 // (polish audit B3). The record is taken off at load, so it only
141 // ever guards the load straight after the reload that wrote it.
142 var renderedBlackout = root.getAttribute('data-blackout') === 'true';
143 var reloadKey = 'slides-present-reload:' + window.location.pathname;
144 var reloadedFor = null;
145 try {
146 reloadedFor = window.sessionStorage.getItem(reloadKey);
147 window.sessionStorage.removeItem(reloadKey);
148 } catch (err) { /* no storage: see follow */ }
149
150 function follow(state) {
151 var slide = Number(state.slide);
152 if (!(slide > 0)) return;
153 var blackout = !!state.blackout;
154 if (slide === displayed && blackout === renderedBlackout) return;
155 var want = String(slide) + (blackout ? ':blackout' : '');
156 if (want === reloadedFor) return;
157 try {
158 window.sessionStorage.setItem(reloadKey, want);
159 } catch (err) {
160 // Nowhere to remember that this reload happened, so only reload
161 // a page that at least knows which slide it is on.
162 if (!displayed) return;
163 }
164 reloadedFor = want;
165 window.location.reload();
166 }
167
168 // apply is the whole of what an event does. The state is complete —
169 // never a delta — so applying one is exactly right rather than
170 // approximately, and a page that missed some is right again on the
171 // next one.
172 function apply(state) {
173 if (state.ended) { window.location.reload(); return; }
174 if (!stage || !fragmentHref) { follow(state); return; }
175 root.setAttribute('data-blackout', state.blackout ? 'true' : 'false');
176 if (note) note.hidden = !state.blackout;
177 show(Number(state.slide));
178 }
179
180 var events = new EventSource(streamHref);
181 events.addEventListener('slide', function (e) {
182 try { apply(JSON.parse(e.data)); } catch (err) { /* not ours */ }
183 });
184
185 // The clock and elapsed line are server-rendered in UTC — the server
186 // has no idea what time zone the room is in. Here they become local
187 // and tick, in the catalog's own words: the pattern comes down as an
188 // attribute rather than being written in this file.
189 var clock = document.querySelector('[data-clock]');
190 var elapsed = document.querySelector('[data-elapsed-since]');
191 function tick() {
192 if (clock) clock.textContent = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
193 if (!elapsed) return;
194 var mins = Math.floor((Date.now() / 1000 - Number(elapsed.getAttribute('data-elapsed-since'))) / 60);
195 var pattern = elapsed.getAttribute('data-elapsed-template');
196 if (pattern) elapsed.textContent = pattern.replace('{n}', String(mins));
197 }
198 if (clock || elapsed) {
199 tick();
200 setInterval(tick, 15000);
201 }
202})();
203