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 => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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;
|