| 169 | }); |
| 170 | |
| 171 | function buildPresentPage(projectName: string, islandJson: string): string { |
| 172 | return `<!doctype html> |
| 173 | <html lang="en"> |
| 174 | <head> |
| 175 | <meta charset="UTF-8" /> |
| 176 | <meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
| 177 | <title>${escHtml(projectName)} — Presenter</title> |
| 178 | <style> |
| 179 | * { margin: 0; padding: 0; box-sizing: border-box; } |
| 180 | html, body { height: 100%; background: #0a0a0a; overflow: hidden; } |
| 181 | hyperframes-slideshow { display: block; position: relative; width: 100vw; height: 100vh; } |
| 182 | hyperframes-player { position: absolute; inset: 0; } |
| 183 | #present-btn { |
| 184 | position: fixed; top: 18px; right: 18px; z-index: 99999; |
| 185 | font: 600 14px/1 system-ui, sans-serif; color: #0d1321; |
| 186 | background: #f4b740; border: none; border-radius: 999px; |
| 187 | padding: 11px 18px; cursor: pointer; box-shadow: 0 6px 20px rgba(0,0,0,.45); |
| 188 | } |
| 189 | #present-btn:hover { background: #ffcb5c; } |
| 190 | </style> |
| 191 | <script src="/player.js"></script> |
| 192 | <script src="/slideshow.js"></script> |
| 193 | </head> |
| 194 | <body> |
| 195 | <hyperframes-slideshow tabindex="0" sound> |
| 196 | <hyperframes-player interactive src="/composition/index.html"></hyperframes-player> |
| 197 | <script type="application/hyperframes-slideshow+json"> |
| 198 | ${islandJson} |
| 199 | </script> |
| 200 | </hyperframes-slideshow> |
| 201 | <button id="present-btn" type="button">▶ Present</button> |
| 202 | <script> |
| 203 | (function () { |
| 204 | // The audience window loads this same page with ?mode=audience; it must not |
| 205 | // show the Present button or it would recurse opening windows. |
| 206 | var isAudience = new URLSearchParams(location.search).get("mode") === "audience"; |
| 207 | var btn = document.getElementById("present-btn"); |
| 208 | if (isAudience) { if (btn) btn.remove(); return; } |
| 209 | function present() { |
| 210 | var ss = document.querySelector("hyperframes-slideshow"); |
| 211 | if (ss && typeof ss.present === "function") { |
| 212 | ss.present(); |
| 213 | if (btn) btn.style.display = "none"; |
| 214 | } |
| 215 | } |
| 216 | if (btn) btn.addEventListener("click", present); |
| 217 | // 'P' opens presenter mode (window.open needs a user gesture, so a key/click). |
| 218 | window.addEventListener("keydown", function (e) { |
| 219 | if ((e.key === "p" || e.key === "P") && !e.metaKey && !e.ctrlKey) present(); |
| 220 | }); |
| 221 | })(); |
| 222 | </script> |
| 223 | <script> |
| 224 | // Sound effects play HERE, in the parent document. The composition runs in the |
| 225 | // player's iframe, which is autoplay-blocked without its own user gesture; the |
| 226 | // slideshow posts { type: "hf-sfx", name } to the parent on nav, and we play the |
| 227 | // matching clip from the deck's sfx/ folder (served under /composition/sfx/). |
| 228 | // Runs in both presenter and audience windows. Missing clips fail silently. |