@openbrf/display 0.0.0-stage → 0.1.1

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.
Files changed (69) hide show
  1. package/LICENSE +661 -0
  2. package/LICENSE-EXCEPTION.md +28 -0
  3. package/README.md +126 -2
  4. package/dist/contract.d.ts +244 -0
  5. package/dist/contract.d.ts.map +1 -0
  6. package/dist/contract.js +54 -0
  7. package/dist/contract.js.map +1 -0
  8. package/dist/fetch-loop.d.ts +87 -0
  9. package/dist/fetch-loop.d.ts.map +1 -0
  10. package/dist/fetch-loop.js +351 -0
  11. package/dist/fetch-loop.js.map +1 -0
  12. package/dist/fit.d.ts +44 -0
  13. package/dist/fit.d.ts.map +1 -0
  14. package/dist/fit.js +257 -0
  15. package/dist/fit.js.map +1 -0
  16. package/dist/index.d.ts +23 -0
  17. package/dist/index.d.ts.map +1 -0
  18. package/dist/index.js +18 -0
  19. package/dist/index.js.map +1 -0
  20. package/dist/parse.d.ts +39 -0
  21. package/dist/parse.d.ts.map +1 -0
  22. package/dist/parse.js +494 -0
  23. package/dist/parse.js.map +1 -0
  24. package/dist/renderer.d.ts +85 -0
  25. package/dist/renderer.d.ts.map +1 -0
  26. package/dist/renderer.js +476 -0
  27. package/dist/renderer.js.map +1 -0
  28. package/dist/rotation.d.ts +129 -0
  29. package/dist/rotation.d.ts.map +1 -0
  30. package/dist/rotation.js +335 -0
  31. package/dist/rotation.js.map +1 -0
  32. package/dist/slides.d.ts +41 -0
  33. package/dist/slides.d.ts.map +1 -0
  34. package/dist/slides.js +341 -0
  35. package/dist/slides.js.map +1 -0
  36. package/dist/styles.d.ts +10 -0
  37. package/dist/styles.d.ts.map +1 -0
  38. package/dist/styles.js +107 -0
  39. package/dist/styles.js.map +1 -0
  40. package/dist/text.d.ts +16 -0
  41. package/dist/text.d.ts.map +1 -0
  42. package/dist/text.js +62 -0
  43. package/dist/text.js.map +1 -0
  44. package/dist/time.d.ts +39 -0
  45. package/dist/time.d.ts.map +1 -0
  46. package/dist/time.js +94 -0
  47. package/dist/time.js.map +1 -0
  48. package/dist/translate.d.ts +18 -0
  49. package/dist/translate.d.ts.map +1 -0
  50. package/dist/translate.js +35 -0
  51. package/dist/translate.js.map +1 -0
  52. package/dist/words.d.ts +41 -0
  53. package/dist/words.d.ts.map +1 -0
  54. package/dist/words.js +73 -0
  55. package/dist/words.js.map +1 -0
  56. package/package.json +58 -3
  57. package/src/contract.ts +279 -0
  58. package/src/fetch-loop.ts +472 -0
  59. package/src/fit.ts +348 -0
  60. package/src/index.ts +77 -0
  61. package/src/parse.ts +605 -0
  62. package/src/renderer.ts +708 -0
  63. package/src/rotation.ts +515 -0
  64. package/src/slides.ts +595 -0
  65. package/src/styles.ts +113 -0
  66. package/src/text.ts +65 -0
  67. package/src/time.ts +147 -0
  68. package/src/translate.ts +56 -0
  69. package/src/words.ts +91 -0
@@ -0,0 +1,708 @@
1
+ /**
2
+ * The renderer: mounts on an element and shows a screen content document.
3
+ *
4
+ * It takes a document and nothing else, and fetches nothing. A picture comes
5
+ * from a function its caller hands it, so that a device token never has to be
6
+ * in a picture's URL; the caller fetches the bytes with the token in a header
7
+ * and hands back an object URL.
8
+ */
9
+ import type {
10
+ PictureRef,
11
+ ScreenContent,
12
+ ScreenLocale,
13
+ Slide,
14
+ } from "./contract.ts";
15
+ import {
16
+ capitalised,
17
+ element as newElement,
18
+ formatTime,
19
+ inSchedule,
20
+ renderSlide,
21
+ replaceContent,
22
+ } from "./slides.ts";
23
+ import { fitSlide } from "./fit.ts";
24
+ import { DISPLAY_STYLESHEET } from "./styles.ts";
25
+ import { translate, type Phrase } from "./translate.ts";
26
+ import { MINUTE, zonedDayStartAfter } from "./time.ts";
27
+
28
+ /** What the renderer is handed besides the element. */
29
+ export interface RendererOptions {
30
+ /**
31
+ * Hands back a URL the renderer may put in an `<img>`, or null when the
32
+ * picture cannot be had. Called once for each `mediaFileId` and `version` a
33
+ * document refers to, and the result reused while documents go on referring
34
+ * to it; a null or a failure is asked again on a document shown five or more
35
+ * minutes later. The renderer never fetches.
36
+ */
37
+ picture?: (ref: PictureRef) => Promise<string | null>;
38
+ /**
39
+ * Told when a URL `picture` handed back is no longer used: the documents
40
+ * stopped referring to it, the browser could not show it, the renderer
41
+ * showed a message, or it was destroyed. Told once for each URL each call
42
+ * of `picture` handed back, also one that arrived after it was no longer
43
+ * wanted. The place to revoke an object URL.
44
+ */
45
+ releasePicture?: (url: string, ref: PictureRef) => void;
46
+ /**
47
+ * Whether `mountRenderer` adds `DISPLAY_STYLESHEET` to the document as a
48
+ * `<style>` element; true unless set. Set it false on a page whose content
49
+ * security policy has no `'unsafe-inline'` in `style-src`, which refuses that
50
+ * element, and serve the stylesheet as a file of its own, linked as
51
+ * `<link rel="stylesheet" data-obrf-display>`. A page that has such a link
52
+ * (or a `<style data-obrf-display>`) in its head gets no second copy without
53
+ * this option.
54
+ */
55
+ stylesheet?: boolean;
56
+ /** The clock, for tests. Timers are the global `setTimeout` and `clearTimeout`. */
57
+ now?: () => Date;
58
+ }
59
+
60
+ /** How fresh the content being shown is. */
61
+ export interface DisplayStatus {
62
+ /** When the content was last fetched, or confirmed by a 304. */
63
+ fetchedAt: Date;
64
+ /** True while the instance cannot be reached and this is the last good content. */
65
+ stale: boolean;
66
+ }
67
+
68
+ /**
69
+ * A full-screen message shown instead of content, with nothing from the
70
+ * instance in it:
71
+ *
72
+ * - `loading`: no content yet.
73
+ * - `link-invalid`: the instance refused the device token; the display link
74
+ * is no longer valid.
75
+ * - `not-connected`: no content to show for 24 hours.
76
+ * - `link-missing`: the address holds no device token.
77
+ * - `paused`: the route is switched off (a plugin's device route answering 404).
78
+ */
79
+ export type DisplayMessage =
80
+ "loading" | "link-invalid" | "not-connected" | "link-missing" | "paused";
81
+
82
+ /** A mounted renderer. */
83
+ export interface DisplayRenderer {
84
+ /**
85
+ * Shows a document, replacing the previous one whole. The slide on screen
86
+ * stays when the new document still has a slide with its id; otherwise the
87
+ * rotation starts from the first. With `status.stale`, a small note says
88
+ * when the content was fetched.
89
+ *
90
+ * A slide the canvas cannot hold whole, such as eight events or a long news
91
+ * item, is shown a page at a time, each page for the slide's `seconds`, and
92
+ * nothing on it is cut off.
93
+ */
94
+ show(content: ScreenContent, status?: DisplayStatus): void;
95
+ /** Shows a message instead of content, in the given language. */
96
+ message(kind: DisplayMessage, locale: ScreenLocale): void;
97
+ /** Clears every timer and empties the element. */
98
+ destroy(): void;
99
+ }
100
+
101
+ const MESSAGES: Readonly<
102
+ Record<DisplayMessage, { title: Phrase; detail: Phrase | null }>
103
+ > = {
104
+ loading: { title: "loading", detail: null },
105
+ "link-invalid": { title: "linkInvalid", detail: "linkInvalidDetail" },
106
+ "not-connected": { title: "notConnected", detail: "notConnectedDetail" },
107
+ "link-missing": { title: "linkMissing", detail: "linkMissingDetail" },
108
+ paused: { title: "paused", detail: "pausedDetail" },
109
+ };
110
+
111
+ const STYLE_ATTRIBUTE = "data-obrf-display";
112
+ const PICTURE_RETRY = 5 * MINUTE;
113
+
114
+ interface PictureEntry {
115
+ ref: PictureRef;
116
+ state: "pending" | "ready" | "failed";
117
+ url: string | null;
118
+ askedAt: number;
119
+ }
120
+
121
+ /**
122
+ * Mounts a renderer on `element`, replacing what it holds, and adds the
123
+ * stylesheet to its document once, unless `options.stylesheet` is false or the
124
+ * page already links it. Give the element a size, such as the whole
125
+ * viewport: the canvas fills it, turned by the document's orientation.
126
+ */
127
+ export function mountRenderer(
128
+ element: HTMLElement,
129
+ options: RendererOptions = {},
130
+ ): DisplayRenderer {
131
+ const document = element.ownerDocument;
132
+ const clockNow = (): number =>
133
+ (options.now ?? (() => new Date()))().getTime();
134
+ if (options.stylesheet !== false) {
135
+ addStylesheet(document);
136
+ }
137
+ const make = <K extends keyof HTMLElementTagNameMap>(
138
+ tag: K,
139
+ className?: string,
140
+ text?: string,
141
+ ): HTMLElementTagNameMap[K] => newElement(document, tag, className, text);
142
+
143
+ const root = make("div", "obrf-display obrf-display--dark");
144
+ const canvas = make("div", "obrf-display-canvas");
145
+ const clock = make("div", "obrf-display-clock");
146
+ const clockDate = make("span", "obrf-display-clock-date");
147
+ const clockTime = make("span", "obrf-display-clock-time");
148
+ const stage = make("div", "obrf-display-stage");
149
+ const note = make("div", "obrf-display-note");
150
+ const off = make("div", "obrf-display-off");
151
+ clock.append(clockDate, clockTime);
152
+ canvas.append(clock, stage, note);
153
+ root.append(canvas, off);
154
+ clock.hidden = true;
155
+ note.hidden = true;
156
+ off.hidden = true;
157
+ replaceContent(element, root);
158
+
159
+ let content: ScreenContent | null = null;
160
+ let status: DisplayStatus | null = null;
161
+ let current: {
162
+ index: number;
163
+ id: string;
164
+ key: string;
165
+ endsAt: number;
166
+ /** The page of the slide's list on screen, of `pages`. */
167
+ page: number;
168
+ pages: number;
169
+ } | null = null;
170
+ /** A hundredth of the canvas's shorter side, in pixels; 0 before it has a size. */
171
+ let unit = 0;
172
+ let slideTimer: ReturnType<typeof setTimeout> | null = null;
173
+ let minuteTimer: ReturnType<typeof setTimeout> | null = null;
174
+ let destroyed = false;
175
+ const pictures = new Map<string, PictureEntry>();
176
+
177
+ // The canvas is the element's box, turned. At 90 and 270 degrees its width
178
+ // is the element's height, so it is sized from the element in pixels.
179
+ const resizeObserver =
180
+ typeof ResizeObserver === "function"
181
+ ? new ResizeObserver(() => layout())
182
+ : null;
183
+ const view = document.defaultView;
184
+ if (resizeObserver === null) {
185
+ view?.addEventListener("resize", layout);
186
+ } else {
187
+ resizeObserver.observe(element);
188
+ }
189
+ const fonts = (document as { fonts?: FontFaceSet }).fonts;
190
+ fonts?.addEventListener("loadingdone", refit);
191
+
192
+ function layout(): void {
193
+ const orientation = content?.layout.orientation ?? 0;
194
+ const width = element.clientWidth;
195
+ const height = element.clientHeight;
196
+ const turned = orientation === 90 || orientation === 270;
197
+ canvas.style.transform = `translate(-50%, -50%) rotate(${orientation}deg)`;
198
+ if (turned && width > 0 && height > 0) {
199
+ canvas.style.width = `${height}px`;
200
+ canvas.style.height = `${width}px`;
201
+ } else {
202
+ canvas.style.width = "100%";
203
+ canvas.style.height = "100%";
204
+ }
205
+ unit = width > 0 && height > 0 ? Math.min(width, height) / 100 : 0;
206
+ if (unit > 0) {
207
+ root.style.setProperty("--obrf-display-u", `${unit}px`);
208
+ } else {
209
+ root.style.removeProperty("--obrf-display-u");
210
+ }
211
+ const portrait = turned ? width > height : height > width;
212
+ root.classList.toggle("obrf-display--portrait", portrait);
213
+ for (const degrees of [0, 90, 180, 270]) {
214
+ root.classList.toggle(
215
+ `obrf-display--orientation-${degrees}`,
216
+ degrees === orientation,
217
+ );
218
+ }
219
+ refit();
220
+ }
221
+
222
+ /**
223
+ * Fits the slide on screen again: a new size, or a typeface that has just
224
+ * loaded, holds another number of items.
225
+ */
226
+ function refit(): void {
227
+ const slide = stage.firstElementChild as HTMLElement | null;
228
+ if (current !== null && slide?.classList.contains("obrf-display-slide")) {
229
+ const { page, pages } = fitSlide(slide, unit, current.page);
230
+ current = { ...current, page, pages };
231
+ }
232
+ }
233
+
234
+ function setTheme(theme: "light" | "dark"): void {
235
+ root.classList.toggle("obrf-display--light", theme === "light");
236
+ root.classList.toggle("obrf-display--dark", theme === "dark");
237
+ }
238
+
239
+ function clearSlideTimer(): void {
240
+ if (slideTimer !== null) {
241
+ clearTimeout(slideTimer);
242
+ slideTimer = null;
243
+ }
244
+ }
245
+
246
+ function pictureKey(ref: PictureRef): string {
247
+ return `${ref.mediaFileId}\u0000${ref.version}`;
248
+ }
249
+
250
+ function pictureOf(slide: Slide): PictureRef | null {
251
+ const { body } = slide;
252
+ if (body.kind === "news") {
253
+ return body.picture;
254
+ }
255
+ return body.kind === "picture" ? body.picture : null;
256
+ }
257
+
258
+ function pictureUrl(ref: PictureRef): string | null {
259
+ const entry = pictures.get(pictureKey(ref));
260
+ return entry?.state === "ready" ? entry.url : null;
261
+ }
262
+
263
+ function release(entry: PictureEntry): void {
264
+ if (entry.url !== null) {
265
+ options.releasePicture?.(entry.url, entry.ref);
266
+ }
267
+ }
268
+
269
+ function releaseAllPictures(): void {
270
+ for (const entry of pictures.values()) {
271
+ release(entry);
272
+ }
273
+ pictures.clear();
274
+ }
275
+
276
+ /** Asks for every picture the document refers to, and lets go of the rest. */
277
+ function syncPictures(next: ScreenContent, now: number): void {
278
+ const wanted = new Map<string, PictureRef>();
279
+ for (const slide of next.rotation) {
280
+ const ref = pictureOf(slide);
281
+ if (ref !== null) {
282
+ wanted.set(pictureKey(ref), ref);
283
+ }
284
+ }
285
+ for (const [key, entry] of pictures) {
286
+ if (!wanted.has(key)) {
287
+ release(entry);
288
+ pictures.delete(key);
289
+ }
290
+ }
291
+ for (const [key, ref] of wanted) {
292
+ const entry = pictures.get(key);
293
+ if (
294
+ entry === undefined ||
295
+ (entry.state === "failed" && now - entry.askedAt >= PICTURE_RETRY)
296
+ ) {
297
+ ask(key, ref, now);
298
+ }
299
+ }
300
+ }
301
+
302
+ function ask(key: string, ref: PictureRef, now: number): void {
303
+ const entry: PictureEntry = {
304
+ ref,
305
+ state: options.picture === undefined ? "failed" : "pending",
306
+ url: null,
307
+ askedAt: now,
308
+ };
309
+ pictures.set(key, entry);
310
+ if (options.picture === undefined) {
311
+ return;
312
+ }
313
+ let request: Promise<string | null>;
314
+ try {
315
+ request = options.picture(ref);
316
+ } catch {
317
+ entry.state = "failed";
318
+ return;
319
+ }
320
+ request.then(
321
+ (url) => settle(key, entry, url),
322
+ () => settle(key, entry, null),
323
+ );
324
+ }
325
+
326
+ function settle(key: string, entry: PictureEntry, url: string | null): void {
327
+ if (destroyed || pictures.get(key) !== entry) {
328
+ // Nobody wants it any more: hand it straight back.
329
+ if (url !== null) {
330
+ options.releasePicture?.(url, entry.ref);
331
+ }
332
+ return;
333
+ }
334
+ entry.state = url === null ? "failed" : "ready";
335
+ entry.url = url;
336
+ pictureChanged(key);
337
+ }
338
+
339
+ function pictureFailed(ref: PictureRef): void {
340
+ const key = pictureKey(ref);
341
+ const entry = pictures.get(key);
342
+ if (entry === undefined || entry.state !== "ready") {
343
+ return;
344
+ }
345
+ release(entry);
346
+ entry.state = "failed";
347
+ entry.url = null;
348
+ pictureChanged(key);
349
+ }
350
+
351
+ /** A picture arrived or went: redraw the slide that shows it, or start the rotation it unblocks. */
352
+ function pictureChanged(key: string): void {
353
+ if (content === null) {
354
+ return;
355
+ }
356
+ if (current === null) {
357
+ startRotation();
358
+ return;
359
+ }
360
+ const slide = content.rotation[current.index];
361
+ const ref = slide === undefined ? null : pictureOf(slide);
362
+ if (slide !== undefined && ref !== null && pictureKey(ref) === key) {
363
+ if (slide.body.kind === "picture" && pictureUrl(ref) === null) {
364
+ advance();
365
+ } else {
366
+ draw(
367
+ slide,
368
+ current.index,
369
+ false,
370
+ clockNow(),
371
+ current.endsAt,
372
+ current.page,
373
+ );
374
+ }
375
+ }
376
+ }
377
+
378
+ function showable(slide: Slide, now: number): boolean {
379
+ if (slide.until !== null && now >= Date.parse(slide.until)) {
380
+ return false;
381
+ }
382
+ if (slide.body.kind === "picture") {
383
+ return pictureUrl(slide.body.picture) !== null;
384
+ }
385
+ return true;
386
+ }
387
+
388
+ /** What a slide looks like now: redrawn only when this changes. */
389
+ function slideKey(slide: Slide, shown: ScreenContent, now: number): string {
390
+ const ref = pictureOf(slide);
391
+ return JSON.stringify([
392
+ slide,
393
+ shown.locale,
394
+ shown.timeZone,
395
+ ref === null ? null : pictureUrl(ref),
396
+ // Free and busy reads the clock: what has passed drops off each minute.
397
+ slide.body.kind === "availability" ? Math.floor(now / MINUTE) : null,
398
+ // The date on a news slide and the days of events are the zone's.
399
+ zonedDayStartAfter(now, 0, shown.timeZone),
400
+ ]);
401
+ }
402
+
403
+ /** Draws page `page` of a slide, or keeps it when it is already on screen. */
404
+ function draw(
405
+ slide: Slide,
406
+ index: number,
407
+ enter: boolean,
408
+ now: number,
409
+ endsAt: number,
410
+ page = 0,
411
+ ): void {
412
+ if (content === null) {
413
+ return;
414
+ }
415
+ const key = slideKey(slide, content, now);
416
+ let fit = { page, pages: current?.pages ?? 1 };
417
+ if (
418
+ current === null ||
419
+ current.id !== slide.id ||
420
+ current.key !== key ||
421
+ current.page !== page
422
+ ) {
423
+ const context = {
424
+ document,
425
+ locale: content.locale,
426
+ timeZone: content.timeZone,
427
+ now,
428
+ pictureUrl,
429
+ pictureFailed,
430
+ };
431
+ const node = renderSlide(slide, context);
432
+ if (node === null) {
433
+ current = null;
434
+ advance();
435
+ return;
436
+ }
437
+ replaceContent(stage, node);
438
+ const fitted = fitSlide(node, unit, page);
439
+ if (enter) {
440
+ node.classList.add("obrf-display-slide--enter");
441
+ }
442
+ fit = { page: fitted.page, pages: fitted.pages };
443
+ }
444
+ current = { index, id: slide.id, key, endsAt, ...fit };
445
+ clearSlideTimer();
446
+ const until = slide.until === null ? Infinity : Date.parse(slide.until);
447
+ slideTimer = setTimeout(
448
+ advance,
449
+ Math.max(0, Math.min(endsAt, until) - now),
450
+ );
451
+ }
452
+
453
+ /** Shows the first slide that can be shown at or after `from`, looping. */
454
+ function showFrom(from: number, enter: boolean): void {
455
+ if (content === null) {
456
+ return;
457
+ }
458
+ const now = clockNow();
459
+ const { rotation } = content;
460
+ for (let step = 0; step < rotation.length; step += 1) {
461
+ const index = (from + step) % rotation.length;
462
+ const slide = rotation[index];
463
+ if (slide !== undefined && showable(slide, now)) {
464
+ // The slide on screen goes on, unless it comes back to its first page.
465
+ const continuing =
466
+ current !== null && current.id === slide.id && current.page === 0;
467
+ draw(
468
+ slide,
469
+ index,
470
+ enter && !continuing,
471
+ now,
472
+ now + slide.seconds * 1000,
473
+ );
474
+ return;
475
+ }
476
+ }
477
+ // Nothing to show now: said quietly, so a screen just opened on a link
478
+ // does not look broken. The minute tick and an arriving picture try again.
479
+ clearSlideTimer();
480
+ current = null;
481
+ replaceContent(
482
+ stage,
483
+ make(
484
+ "p",
485
+ "obrf-display-empty",
486
+ translate(content.locale, "nothingToShow"),
487
+ ),
488
+ );
489
+ }
490
+
491
+ function startRotation(): void {
492
+ showFrom(0, true);
493
+ }
494
+
495
+ /** The slide's next page, or the next slide after its last. */
496
+ function advance(): void {
497
+ slideTimer = null;
498
+ const slide =
499
+ current === null || content === null
500
+ ? undefined
501
+ : content.rotation[current.index];
502
+ const now = clockNow();
503
+ if (
504
+ current !== null &&
505
+ slide !== undefined &&
506
+ current.page + 1 < current.pages &&
507
+ showable(slide, now)
508
+ ) {
509
+ draw(
510
+ slide,
511
+ current.index,
512
+ true,
513
+ now,
514
+ now + slide.seconds * 1000,
515
+ current.page + 1,
516
+ );
517
+ return;
518
+ }
519
+ showFrom(current === null ? 0 : current.index + 1, true);
520
+ }
521
+
522
+ function updateClock(now: number): void {
523
+ if (content === null || content.layout.header !== "clock") {
524
+ clock.hidden = true;
525
+ return;
526
+ }
527
+ clock.hidden = false;
528
+ clockDate.textContent = capitalised(
529
+ formatTime(now, content.locale, content.timeZone, "weekdayDate"),
530
+ );
531
+ clockTime.textContent = formatTime(
532
+ now,
533
+ content.locale,
534
+ content.timeZone,
535
+ "time",
536
+ );
537
+ }
538
+
539
+ function updateNote(now: number): void {
540
+ if (content === null || status === null || !status.stale) {
541
+ note.hidden = true;
542
+ return;
543
+ }
544
+ const fetchedAt = status.fetchedAt.getTime();
545
+ const { locale, timeZone } = content;
546
+ const sameDay =
547
+ zonedDayStartAfter(fetchedAt, 0, timeZone) ===
548
+ zonedDayStartAfter(now, 0, timeZone);
549
+ const time = formatTime(fetchedAt, locale, timeZone, "time");
550
+ // The date and time in the data face, the words around them not.
551
+ const [before = "", after = ""] = translate(locale, "lastUpdated", {
552
+ time: "\u0000",
553
+ }).split("\u0000");
554
+ replaceContent(
555
+ note,
556
+ before,
557
+ make(
558
+ "span",
559
+ "obrf-display-data",
560
+ sameDay
561
+ ? time
562
+ : `${formatTime(fetchedAt, locale, timeZone, "date")} ${time}`,
563
+ ),
564
+ after,
565
+ );
566
+ note.hidden = false;
567
+ }
568
+
569
+ function updateSchedule(now: number): void {
570
+ off.hidden =
571
+ content === null ||
572
+ inSchedule(content.schedule.on, now, content.timeZone);
573
+ }
574
+
575
+ /** Each minute: the clock, the schedule, the note, and a rotation that had nothing to show. */
576
+ function tick(): void {
577
+ minuteTimer = null;
578
+ if (destroyed) {
579
+ return;
580
+ }
581
+ const now = clockNow();
582
+ updateClock(now);
583
+ updateSchedule(now);
584
+ updateNote(now);
585
+ if (content !== null) {
586
+ if (current === null) {
587
+ startRotation();
588
+ } else {
589
+ const slide = content.rotation[current.index];
590
+ if (
591
+ slide !== undefined &&
592
+ slideKey(slide, content, now) !== current.key
593
+ ) {
594
+ draw(slide, current.index, false, now, current.endsAt, current.page);
595
+ }
596
+ }
597
+ }
598
+ scheduleTick(clockNow());
599
+ }
600
+
601
+ function scheduleTick(now: number): void {
602
+ if (minuteTimer !== null) {
603
+ clearTimeout(minuteTimer);
604
+ }
605
+ minuteTimer = setTimeout(tick, MINUTE - (now % MINUTE));
606
+ }
607
+
608
+ return {
609
+ show(next, nextStatus) {
610
+ if (destroyed) {
611
+ return;
612
+ }
613
+ const now = clockNow();
614
+ const previous = current;
615
+ content = next;
616
+ status = nextStatus ?? null;
617
+ root.lang = next.locale;
618
+ setTheme(next.layout.theme);
619
+ layout();
620
+ syncPictures(next, now);
621
+ updateClock(now);
622
+ updateSchedule(now);
623
+ updateNote(now);
624
+ const kept =
625
+ previous === null
626
+ ? -1
627
+ : next.rotation.findIndex((slide) => slide.id === previous.id);
628
+ const keptSlide = kept === -1 ? undefined : next.rotation[kept];
629
+ if (
630
+ previous !== null &&
631
+ keptSlide !== undefined &&
632
+ showable(keptSlide, now)
633
+ ) {
634
+ draw(keptSlide, kept, false, now, previous.endsAt, previous.page);
635
+ } else {
636
+ current = null;
637
+ clearSlideTimer();
638
+ startRotation();
639
+ }
640
+ scheduleTick(now);
641
+ },
642
+
643
+ message(kind, locale) {
644
+ if (destroyed) {
645
+ return;
646
+ }
647
+ content = null;
648
+ status = null;
649
+ current = null;
650
+ clearSlideTimer();
651
+ releaseAllPictures();
652
+ root.lang = locale;
653
+ clock.hidden = true;
654
+ note.hidden = true;
655
+ off.hidden = true;
656
+ layout();
657
+ const words = MESSAGES[kind];
658
+ const box = make("div", "obrf-display-message");
659
+ box.setAttribute("role", "status");
660
+ box.append(
661
+ make("p", "obrf-display-message-title", translate(locale, words.title)),
662
+ );
663
+ if (words.detail !== null) {
664
+ box.append(
665
+ make(
666
+ "p",
667
+ "obrf-display-message-detail",
668
+ translate(locale, words.detail),
669
+ ),
670
+ );
671
+ }
672
+ replaceContent(stage, box);
673
+ },
674
+
675
+ destroy() {
676
+ if (destroyed) {
677
+ return;
678
+ }
679
+ destroyed = true;
680
+ clearSlideTimer();
681
+ if (minuteTimer !== null) {
682
+ clearTimeout(minuteTimer);
683
+ minuteTimer = null;
684
+ }
685
+ resizeObserver?.disconnect();
686
+ view?.removeEventListener("resize", layout);
687
+ fonts?.removeEventListener("loadingdone", refit);
688
+ releaseAllPictures();
689
+ content = null;
690
+ current = null;
691
+ replaceContent(element);
692
+ },
693
+ };
694
+ }
695
+
696
+ function addStylesheet(document: Document): void {
697
+ if (
698
+ document.head.querySelector(
699
+ `style[${STYLE_ATTRIBUTE}], link[${STYLE_ATTRIBUTE}]`,
700
+ ) !== null
701
+ ) {
702
+ return;
703
+ }
704
+ const style = document.createElement("style");
705
+ style.setAttribute(STYLE_ATTRIBUTE, "");
706
+ style.textContent = DISPLAY_STYLESHEET;
707
+ document.head.append(style);
708
+ }