File size: 7,563 Bytes
2659672
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
// The libretto view (ליברטו) of the developer's prompter: an opera's words one line at a time, Italian above English, on a black
// screen dim enough not to disturb the people sitting nearby. Nothing moves by itself: the reader moves on with the music.
//
// A libretto is a prompter text whose first line is "Libretto" (or "ליברטו"). After it, each line is one of:
//   Act 1 — In the garret            a heading (Act / Atto / Quadro / Scene / Scena): shown on its own, as a cue
//   RODOLFO: Italian words || English words   one cue: who sings (optional), the Italian, and after "||" the English
//   (a stage direction) || (its English)      a cue in parentheses is shown smaller, as a direction
// Empty lines and lines starting with "#" are left out.
//
// Keys: Space / → / Page Down / Enter next · ← / Page Up / Backspace back · Home first line · End last line ·
// ↑ / ↓ brighter / dimmer · + / − text size · Esc close. A tap on the screen's right two thirds is next, on the left third back
// (in this right-to-left app, the start side is right; the Italian text reads left to right, so "back" is the left edge).
// The place in each libretto, the brightness and the size are kept in this browser, so closing by mistake loses nothing.

const KEY = "mathly2.libretto";
const HEADING = /^(act|atto|quadro|scene|scena|parte|part)\b/i;

/** Is this text a libretto? */
export const isLibretto = text => /^\s*(libretto|ליברטו)\s*$/i.test(String(text || "").split("\n").find(l => l.trim()) || "");

/** The cues of a libretto: [{kind: "heading"|"line"|"direction", who, it, en}]. */
export function parse(text) {
  const lines = String(text || "").split("\n").map(l => l.trim()).filter(l => l && !l.startsWith("#"));
  if (lines.length && isLibretto(lines[0])) lines.shift();
  return lines.map(line => {
    const [left, ...rest] = line.split("||");
    const en = rest.join("||").trim();
    let it = left.trim(), who = "";
    if (HEADING.test(it)) return { kind: "heading", who: "", it, en };
    const named = it.match(/^([^:()]{1,80}):\s*(.*)$/);
    if (named && named[1] === named[1].toUpperCase() && /\p{L}/u.test(named[1])) { who = named[1].trim(); it = named[2].trim(); }
    return { kind: /^\(.*\)$/.test(it) ? "direction" : "line", who, it, en };
  }).filter(c => c.it || c.en);
}

const CSS = `
.mxl{position:fixed;inset:0;z-index:2147483000;background:#000;color:#000;cursor:none;-webkit-user-select:none;user-select:none;touch-action:manipulation;
  display:flex;flex-direction:column;justify-content:center;align-items:center;padding:4vh 6vw;font-family:Georgia,"Times New Roman",serif;--b:.6;--s:1}
.mxl-cue{max-width:1100px;width:100%;text-align:center;direction:ltr;opacity:var(--b)}
.mxl-who{color:#a0662a;font:600 calc(15px*var(--s))/1.2 system-ui,sans-serif;letter-spacing:.12em;margin-bottom:.5em;min-height:1.2em}
.mxl-it{color:#e0952f;font-size:calc(34px*var(--s));line-height:1.3;font-weight:600}
.mxl-en{color:#b98040;font-size:calc(25px*var(--s));line-height:1.3;font-style:italic;margin-top:.45em}
.mxl-cue.heading .mxl-it{font-size:calc(24px*var(--s));letter-spacing:.08em;text-transform:uppercase;color:#c48530}
.mxl-cue.heading .mxl-en{font-size:calc(19px*var(--s))}
.mxl-cue.direction .mxl-it{font-size:calc(21px*var(--s));font-style:italic;font-weight:400;color:#b98040}
.mxl-cue.direction .mxl-en{font-size:calc(18px*var(--s))}
.mxl-next{position:absolute;inset-inline:6vw;bottom:3vh;text-align:center;direction:ltr;color:#6a4518;font-size:calc(16px*var(--s));opacity:calc(var(--b)*.6);
  white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.mxl-where{position:absolute;top:2vh;inset-inline-end:3vw;color:#6a4518;font:12px system-ui,sans-serif;direction:ltr;opacity:calc(var(--b)*.5)}
@media (prefers-reduced-motion:no-preference){.mxl-cue{animation:mxl-in .18s ease}}
@keyframes mxl-in{from{opacity:0}to{opacity:var(--b)}}
`;

let root = null, cues = [], at = 0, id = "", lock = null, prefs = { b: .6, s: 1, at: {} };

function loadPrefs() { try { prefs = { ...prefs, ...JSON.parse(localStorage.getItem(KEY) || "{}") }; } catch { /* nothing kept */ } }
function savePrefs() { try { localStorage.setItem(KEY, JSON.stringify(prefs)); } catch { /* private window */ } }
function hash(text) { let h = 0; for (let i = 0; i < text.length; i++) h = (h * 31 + text.charCodeAt(i)) | 0; return String(h); }
const esc = s => String(s ?? "").replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));

function draw() {
  const c = cues[at], n = cues[at + 1];
  root.style.setProperty("--b", prefs.b);
  root.style.setProperty("--s", prefs.s);
  root.innerHTML = `<div class="mxl-where">${at + 1} / ${cues.length}</div>
    <div class="mxl-cue ${c.kind}" lang="it"><div class="mxl-who">${esc(c.who)}</div><div class="mxl-it">${esc(c.it)}</div>
      ${c.en ? `<div class="mxl-en" lang="en">${esc(c.en)}</div>` : ""}</div>
    <div class="mxl-next" lang="it">${n ? esc((n.who ? n.who + ": " : "") + n.it) : ""}</div>`;
  prefs.at[id] = at; savePrefs();
}

const go = to => { const next = Math.max(0, Math.min(cues.length - 1, to)); if (next !== at) { at = next; draw(); } };
const brighter = d => { prefs.b = Math.round(Math.max(.1, Math.min(1, prefs.b + d)) * 100) / 100; draw(); };
const bigger = d => { prefs.s = Math.round(Math.max(.6, Math.min(2.5, prefs.s + d)) * 100) / 100; draw(); };

function onKey(e) {
  const k = e.key;
  if ([" ", "ArrowRight", "PageDown", "Enter"].includes(k)) go(at + 1);
  else if (["ArrowLeft", "PageUp", "Backspace"].includes(k)) go(at - 1);
  else if (k === "Home") go(0);
  else if (k === "End") go(cues.length - 1);
  else if (k === "ArrowUp") brighter(.05);
  else if (k === "ArrowDown") brighter(-.05);
  else if (k === "+" || k === "=") bigger(.1);
  else if (k === "-") bigger(-.1);
  else if (k === "Escape") close();
  else return;
  e.preventDefault(); e.stopPropagation();
}

function onTap(e) { go(e.clientX < innerWidth / 3 ? at - 1 : at + 1); }

async function awake() { try { lock = await navigator.wakeLock?.request("screen"); } catch { lock = null; } }
const onVisible = () => { if (root && document.visibilityState === "visible") awake(); };

/** Show a libretto text. Returns false when it has no cues. */
export function open(text) {
  cues = parse(text);
  if (!cues.length) return false;
  close();
  loadPrefs();
  id = hash(text);
  at = Math.min(prefs.at[id] || 0, cues.length - 1);
  if (!document.getElementById("mxl-css")) {
    const el = document.createElement("style"); el.id = "mxl-css"; el.textContent = CSS; document.head.appendChild(el);
  }
  root = document.createElement("div");
  root.className = "mxl"; root.setAttribute("role", "dialog"); root.setAttribute("aria-modal", "true"); root.setAttribute("aria-label", "ליברטו");
  root.tabIndex = -1;
  document.body.appendChild(root);
  draw();
  root.focus();
  document.addEventListener("keydown", onKey, true);
  root.addEventListener("click", onTap);
  document.addEventListener("visibilitychange", onVisible);
  root.requestFullscreen?.().catch(() => { /* not allowed here: the black page still covers the screen */ });
  awake();
  return true;
}

export function close() {
  if (!root) return;
  document.removeEventListener("keydown", onKey, true);
  document.removeEventListener("visibilitychange", onVisible);
  root.remove(); root = null;
  lock?.release?.().catch(() => {}); lock = null;
  if (document.fullscreenElement) document.exitFullscreen?.().catch(() => {});
}

export const isOpen = () => !!root;