@homie-rocks/studio 0.12.1 → 0.14.0

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.
package/worker/pages.mjs CHANGED
@@ -71,7 +71,7 @@ export function sharePlaces(value) {
71
71
  * the edge (game.json `screen.share`, per device: sharePlaces) opens Invite, Big screen and the room code; nothing
72
72
  * covers the middle of the screen or a thumb.
73
73
  */
74
- export function playPage(cat, g, { screen = false, joinUrl = null, qr = null, room = null } = {}) {
74
+ export function playPage(cat, g, { screen = false, joinUrl = null, qr = null, room = null, ticket = null, owner = false, launch = 'public' } = {}) {
75
75
  const accent = cat?.studio?.theme?.accent ?? '#ffcf5a';
76
76
  const corner = (name, fallback) => (['top-left', 'top-right', 'bottom-left', 'bottom-right'].includes(g.screen?.[name]) ? g.screen[name] : fallback);
77
77
  const places = sharePlaces(g.screen?.share);
@@ -122,9 +122,25 @@ iframe.game { position: fixed; inset: 0; width: 100%; height: 100%; border: 0; d
122
122
  .sheet .foot a, .sheet .foot button { color: #aab3c7; background: none; border: 0; padding: 6px 2px; font: inherit; text-decoration: none; cursor: pointer; }
123
123
  .sheet .foot a:hover, .sheet .foot button:hover { color: #fff; }
124
124
  .toast { position: fixed; left: 50%; top: max(12px, env(safe-area-inset-top)); z-index: 8; transform: translateX(-50%); padding: 8px 14px; border-radius: 999px; background: rgba(8,12,22,.9); color: #fff; font: 600 13px/1.2 ui-sans-serif, system-ui, sans-serif; pointer-events: none; }
125
+ /* The studio's announcement (section 15): a line across the top, small, never over the middle; it goes by itself. */
126
+ .banner { box-sizing: border-box; position: fixed; left: 50%; top: calc(max(8px, env(safe-area-inset-top)) + 44px); z-index: 9; transform: translateX(-50%); display: flex; align-items: center; gap: 10px; width: max-content; max-width: min(560px, calc(100vw - 24px)); padding: 9px 8px 9px 14px; border-radius: 14px; background: rgba(8,12,22,.9); border: 1px solid var(--hot); color: #fff; font: 600 14px/1.35 ui-sans-serif, system-ui, -apple-system, sans-serif; box-shadow: 0 10px 30px rgba(0,0,0,.45); backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); touch-action: manipulation; animation: drop .35s ease-out; }
127
+ .banner b { color: var(--hot); font-weight: 800; white-space: nowrap; }
128
+ .banner span { overflow-wrap: anywhere; }
129
+ .banner button { flex: none; width: 30px; height: 30px; border-radius: 50%; border: 0; background: rgba(255,255,255,.08); color: #fff; font: 600 16px/1 ui-sans-serif, system-ui, sans-serif; cursor: pointer; }
130
+ @keyframes drop { from { opacity: 0; transform: translate(-50%, -8px); } }
131
+ /* A kick or a closed room: the game stops, and the page says so plainly. */
132
+ .notice { box-sizing: border-box; position: fixed; inset: 0; z-index: 20; display: grid; place-items: center; padding: 20px; background: rgba(4,6,12,.86); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); color: #eef1f8; font: 15px/1.45 ui-sans-serif, system-ui, -apple-system, sans-serif; -webkit-user-select: text; user-select: text; touch-action: manipulation; }
133
+ .notice .box { box-sizing: border-box; width: min(420px, 100%); padding: 22px; border-radius: 20px; background: #0d111c; border: 1px solid rgba(255,255,255,.14); box-shadow: 0 24px 70px rgba(0,0,0,.55); }
134
+ .notice h1 { margin: 0 0 8px; font-size: 22px; letter-spacing: -.01em; }
135
+ .notice p { margin: 0 0 10px; color: #c3cad9; }
136
+ .notice .when { color: var(--hot); font-weight: 700; }
137
+ .notice .acts { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 16px; }
138
+ .notice .acts a { display: inline-flex; align-items: center; min-height: 44px; padding: 0 16px; border-radius: 12px; text-decoration: none; font-weight: 700; color: #eef1f8; border: 1px solid rgba(255,255,255,.18); }
139
+ .notice .acts a.primary { background: var(--hot); color: #0b0b10; border-color: transparent; }
125
140
  [hidden] { display: none !important; }${g.saves && !screen ? SAVES_SHELL_CSS : ''}`;
126
141
  // The room button's place on each device (sharePlaces); the shell moves it to this browser's once it knows the device.
127
- const boot = { game: g.id, name: g.name, screen: Boolean(screen), share: places, ...(room ? { room } : {}) };
142
+ // A ticket (a game that is not public) and the owner's overlay ride along only for the browser they are for.
143
+ const boot = { game: g.id, name: g.name, screen: Boolean(screen), share: places, ...(room ? { room } : {}), ...(ticket ? { t: ticket } : {}), ...(owner ? { owner: true, launch } : {}) };
128
144
  // game.json "screen": { "join": "top-left" | "top-right" | "bottom-left" | "bottom-right" } keeps the card off the game's own HUD.
129
145
  const joinCorner = corner('join', 'bottom-right');
130
146
  const first = places.desk;
@@ -151,7 +167,7 @@ iframe.game { position: fixed; inset: 0; width: 100%; height: 100%; border: 0; d
151
167
  ${joinCard}${share}
152
168
  <script>window.__HOMIE_PLAY=${JSON.stringify(boot).replace(/</g, '\\u003c')};</script>
153
169
  <script>${SHELL_JS}</script>${g.saves && !screen ? `
154
- <script>${SAVES_SHELL_JS}</script>` : ''}`, css, frameAncestors(cat));
170
+ <script>${SAVES_SHELL_JS}</script>` : ''}${owner ? `<style>${OWNER_CSS}</style><script>${OWNER_JS}</script>` : ''}`, css, frameAncestors(cat));
155
171
  }
156
172
 
157
173
  /** The play page's own document (no site chrome: the game owns the whole screen). */
@@ -185,8 +201,16 @@ const SHELL_JS = String.raw`(function () {
185
201
  var statusEl = document.querySelector('[data-status]');
186
202
  var results = document.querySelector('[data-results]');
187
203
  var screenCard = document.querySelector('[data-screen]');
188
- var state = { game: boot.game, room: null, device: device, want: want, attached: false, stats: null, round: null, roster: null, facts: null, seat: null, results: [], closed: null, link: null };
204
+ var state = { game: boot.game, room: null, device: device, want: want, attached: false, stats: null, round: null, roster: null, facts: null, seat: null, results: [], closed: null, link: null, notice: null, banner: null, muted: null };
189
205
  window.__shell = state;
206
+ // This browser's room key: random, kept in this site's own storage, and sent only to this site's rooms. It is what
207
+ // an owner's kick holds out of a room for a while (section 15); it says nothing about who the player is.
208
+ function rnd() {
209
+ try { var a = new Uint8Array(16); crypto.getRandomValues(a); var s = ''; for (var i = 0; i < a.length; i++) s += String.fromCharCode(a[i]); return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); }
210
+ catch (e) { var r = ''; while (r.length < 22) r += Math.random().toString(36).slice(2); return r.slice(0, 22); }
211
+ }
212
+ var roomKey = null;
213
+ try { roomKey = localStorage.getItem('homie-b'); if (!/^[A-Za-z0-9_-]{16,43}$/.test(roomKey || '')) { roomKey = rnd(); localStorage.setItem('homie-b', roomKey); } } catch (e) { roomKey = rnd(); }
190
214
 
191
215
  // The chip says who is here, then gets out of the game's way (games draw their own HUD at the top). It never takes a touch.
192
216
  var chip = document.querySelector('[data-chip]');
@@ -257,6 +281,7 @@ const SHELL_JS = String.raw`(function () {
257
281
  try {
258
282
  if (params.get('room') !== room) {
259
283
  params.set('room', room);
284
+ params.delete('not');
260
285
  history.replaceState(history.state, '', location.pathname + '?' + params.toString() + location.hash);
261
286
  }
262
287
  } catch (e) {}
@@ -265,6 +290,8 @@ const SHELL_JS = String.raw`(function () {
265
290
  try { token = sessionStorage.getItem(KEY); } catch (e) {}
266
291
  var q = new URLSearchParams({ room: room, device: device, want: want });
267
292
  if (token) q.set('k', token);
293
+ q.set('b', roomKey);
294
+ if (boot.t) q.set('t', boot.t);
268
295
  if (params.get('name')) q.set('name', params.get('name'));
269
296
  else {
270
297
  // A player with an account (or a named guest) on this studio plays under their own name in every room.
@@ -275,7 +302,7 @@ const SHELL_JS = String.raw`(function () {
275
302
  ['touchdebug', 'cam', 'view'].forEach(function (k) { var v = params.get(k); if (v && /^[A-Za-z0-9_-]{1,16}$/.test(v)) q.set(k, v); });
276
303
  frame.src = '/' + boot.game + '/__game/?' + q.toString();
277
304
  frame.addEventListener('load', function () { try { frame.focus(); frame.contentWindow.focus(); } catch (e) {} });
278
- window.addEventListener('pointerdown', function (e) { if (ui && ui.contains(e.target)) return; try { frame.focus(); } catch (e2) {} }, { passive: true });
305
+ window.addEventListener('pointerdown', function (e) { if ((ui && ui.contains(e.target)) || (e.target.closest && e.target.closest('[data-keep-focus]'))) return; try { frame.focus(); } catch (e2) {} }, { passive: true });
279
306
  window.addEventListener('message', function (ev) {
280
307
  if (ev.source !== frame.contentWindow) return;
281
308
  var m = ev.data;
@@ -285,7 +312,9 @@ const SHELL_JS = String.raw`(function () {
285
312
  if (m.what === 'stats') state.stats = m.stats;
286
313
  if (m.what === 'round') onRound(m.round);
287
314
  if (m.what === 'roster') state.roster = m.slots;
288
- if (m.what === 'closed') state.closed = m.why;
315
+ if (m.what === 'closed') { state.closed = m.why; if (m.why === 'kicked' || m.why === 'room-closed') notice(m.why === 'kicked' ? 'kicked' : 'closed', m); }
316
+ // A game's net.pickPlayer(seat): only an owner's page listens (its overlay opens that player's card).
317
+ if (m.what === 'pick' && (m.seat === null || typeof m.seat === 'number')) { try { window.dispatchEvent(new CustomEvent('homie-pick', { detail: { seat: m.seat } })); } catch (e) {} }
289
318
  paint();
290
319
  });
291
320
  watch(room);
@@ -317,15 +346,81 @@ const SHELL_JS = String.raw`(function () {
317
346
  }
318
347
 
319
348
  function watch(room) {
349
+ if (state.notice) return;
320
350
  var ws;
321
- try { ws = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/' + boot.game + '/__watch?room=' + encodeURIComponent(room)); }
351
+ var extra = '&b=' + encodeURIComponent(roomKey) + (boot.t ? '&t=' + encodeURIComponent(boot.t) : '');
352
+ try { ws = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/' + boot.game + '/__watch?room=' + encodeURIComponent(room) + extra); }
322
353
  catch (e) { setTimeout(function () { watch(room); }, 2000); return; }
354
+ state.watchSocket = ws;
323
355
  ws.onmessage = function (ev) {
324
- try { state.facts = JSON.parse(ev.data); } catch (e) { return; }
356
+ var m; try { m = JSON.parse(ev.data); } catch (e) { return; }
357
+ if (!m || typeof m !== 'object') return;
358
+ if (m.t === 'kicked') { notice('kicked', m); return; }
359
+ if (m.t === 'closed') { notice('closed', m); return; }
360
+ if (m.t === 'announce') { banner(m); return; }
361
+ if (m.t === 'muted') { mutedNote(m); return; }
362
+ if (m.t !== 'net') return;
363
+ state.facts = m;
364
+ if (m.announce && m.announce.text) banner(m.announce, true);
325
365
  if (state.facts && state.facts.round) onRound(state.facts.round);
326
366
  paint();
327
367
  };
328
- ws.onclose = function () { setTimeout(function () { watch(room); }, 1500); };
368
+ ws.onclose = function () { if (!state.notice) setTimeout(function () { watch(room); }, 1500); };
369
+ }
370
+
371
+ // The studio's announcement: one line across the top until it ends (its own clock) or the player closes it.
372
+ var shownBanners = {};
373
+ function banner(a, quiet) {
374
+ if (!a || typeof a !== 'object') return;
375
+ var old = document.querySelector('[data-banner]');
376
+ if (!a.text) { if (old && (!a.id || old.getAttribute('data-banner') === a.id)) { old.remove(); state.banner = null; } return; }
377
+ if (quiet && shownBanners[a.id]) return;
378
+ shownBanners[a.id] = true;
379
+ var left = Math.max(3000, Math.min(3600000, (Number(a.until) || 0) - (Number(a.at) || 0) || 30000));
380
+ if (state.facts && state.facts.st && a.until) left = Math.max(1000, Math.min(left, a.until - state.facts.st));
381
+ if (old) old.remove();
382
+ var el = document.createElement('div'); el.className = 'banner'; el.setAttribute('role', 'status'); el.setAttribute('data-banner', String(a.id || '')); el.setAttribute('data-keep-focus', '');
383
+ var b = document.createElement('b'); b.textContent = 'Studio';
384
+ var span = document.createElement('span'); span.textContent = String(a.text).slice(0, 280);
385
+ var x = document.createElement('button'); x.type = 'button'; x.setAttribute('aria-label', 'Close'); x.textContent = '×';
386
+ x.onclick = function () { el.remove(); state.banner = null; };
387
+ el.append(b, span, x);
388
+ document.body.appendChild(el);
389
+ state.banner = { id: a.id, text: String(a.text) };
390
+ setTimeout(function () { if (el.parentNode) { el.remove(); if (state.banner && state.banner.id === a.id) state.banner = null; } }, left);
391
+ }
392
+
393
+ function mutedNote(m) {
394
+ var mins = m.until ? Math.max(1, Math.round((m.until - (state.facts && state.facts.st ? state.facts.st : Date.now())) / 60000)) : 0;
395
+ state.muted = m.until ? { until: m.until } : null;
396
+ flash(m.until ? 'The studio muted you for ' + mins + ' min: your chat and emotes reach nobody.' : 'The studio unmuted you.');
397
+ }
398
+
399
+ // A kick or a closed room ends play here: the game frame stops (an older game would otherwise knock again and
400
+ // again), and the page says what happened, when they can come back, and where to play now.
401
+ function notice(kind, m) {
402
+ if (state.notice) return;
403
+ state.notice = { kind: kind, until: m && m.until ? m.until : null, message: m && m.message ? String(m.message) : '' };
404
+ try { frame.src = 'about:blank'; } catch (e) {}
405
+ if (state.watchSocket) { try { state.watchSocket.close(); } catch (e) {} }
406
+ if (ui) ui.hidden = true;
407
+ var old = document.querySelector('[data-banner]'); if (old) old.remove();
408
+ var nowAt = state.facts && state.facts.st ? state.facts.st : Date.now();
409
+ var mins = state.notice.until ? Math.max(1, Math.ceil((state.notice.until - nowAt) / 60000)) : 0;
410
+ var label = labelOf(state.room || '');
411
+ var box = document.createElement('div'); box.className = 'notice'; box.setAttribute('data-notice', kind); box.setAttribute('role', 'alertdialog'); box.setAttribute('data-keep-focus', '');
412
+ var inner = document.createElement('div'); inner.className = 'box';
413
+ var h = document.createElement('h1'); h.textContent = kind === 'kicked' ? 'You were removed from this room' : 'This room is closed';
414
+ var p = document.createElement('p'); p.textContent = state.notice.message || (kind === 'kicked' ? 'The studio removed you from this room.' : 'The studio closed this room. Thanks for playing!');
415
+ var w = document.createElement('p'); w.className = 'when';
416
+ w.textContent = mins ? (kind === 'kicked' ? 'You can come back to ' + label + ' in ' + mins + ' min.' : label + ' opens again in ' + mins + ' min.') : '';
417
+ var acts = document.createElement('div'); acts.className = 'acts';
418
+ var other = document.createElement('a'); other.className = 'primary'; other.href = '/' + boot.game + '/play?not=' + encodeURIComponent(state.room || ''); other.textContent = 'Play in another room';
419
+ var back = document.createElement('a'); back.href = '/' + boot.game + '/'; back.textContent = 'Back to ' + boot.name;
420
+ acts.append(other, back);
421
+ inner.append(h, p, w, acts); box.appendChild(inner);
422
+ document.body.appendChild(box);
423
+ say(kind === 'kicked' ? 'removed from this room' : 'room closed');
329
424
  }
330
425
 
331
426
  function paint() {
@@ -346,8 +441,160 @@ const SHELL_JS = String.raw`(function () {
346
441
  if (boot.room) start(boot.room);
347
442
  else if (askedRoom !== null && /^[A-Za-z0-9_-]{1,32}$/.test(askedRoom)) start(askedRoom);
348
443
  else if (askedRoom !== null) { say('that room link does not work'); }
349
- else fetch('/' + boot.game + '/api/lobby', { method: 'POST' })
444
+ else fetch('/' + boot.game + '/api/lobby' + (/^[A-Za-z0-9_,-]{1,140}$/.test(params.get('not') || '') ? '?not=' + encodeURIComponent(params.get('not')) : ''), { method: 'POST' })
350
445
  .then(function (r) { return r.json(); })
351
446
  .then(function (j) { start(j.room || 'main'); })
352
447
  .catch(function () { start('main'); });
353
448
  }());`;
449
+
450
+ /*
451
+ * THE OWNER IN THEIR OWN GAME (0.13.0). Only a page served to the studio's signed-in owner carries this (the
452
+ * Worker checks the owner's session before it adds it; nobody else's page has a byte of it): a small Owner button
453
+ * in a corner the room button does not use, and a sheet with everyone in this room. A tap on a player shows who they
454
+ * are, with Mute and Kick; Announce reaches this room or every room of the game. The controls go to the studio's
455
+ * own /_studio/api with the owner's session (an HttpOnly cookie; the game's sandboxed frame can neither read it nor
456
+ * send it). A game can open a player's card itself: `net.pickPlayer(seat)` (a click on that player's body).
457
+ */
458
+ const OWNER_CSS = `.owner { position: fixed; z-index: 10; top: max(8px, env(safe-area-inset-top)); left: max(8px, env(safe-area-inset-left)); font: 600 13px/1.25 ui-sans-serif, system-ui, -apple-system, sans-serif; color: #eef1f8; display: flex; flex-direction: column; gap: 8px; align-items: flex-start; }
459
+ .owner.at-right { align-items: flex-end; }
460
+ .owner.up { flex-direction: column-reverse; }
461
+ .owner .opill { display: inline-flex; align-items: center; gap: 6px; height: 34px; padding: 0 12px 0 9px; border-radius: 999px; border: 1px solid #c9b8ff; background: rgba(12,8,24,.7); color: #e6dcff; font: inherit; cursor: pointer; backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); touch-action: manipulation; }
462
+ .owner .opill i { width: 8px; height: 8px; border-radius: 50%; background: #c9b8ff; }
463
+ .osheet { box-sizing: border-box; width: min(340px, calc(100vw - 16px)); max-height: min(560px, calc(100vh - 70px)); overflow: auto; padding: 12px; border-radius: 16px; background: rgba(10,8,20,.96); border: 1px solid rgba(201,184,255,.35); box-shadow: 0 18px 50px rgba(0,0,0,.55); -webkit-user-select: text; user-select: text; touch-action: manipulation; }
464
+ .osheet h2 { margin: 2px 2px 8px; font-size: 13px; letter-spacing: .08em; text-transform: uppercase; color: #c9b8ff; display: flex; justify-content: space-between; gap: 8px; }
465
+ .osheet h2 a { color: #aab3c7; text-transform: none; letter-spacing: 0; font-weight: 600; text-decoration: none; }
466
+ .osheet .row { display: flex; align-items: center; justify-content: space-between; gap: 8px; width: 100%; padding: 8px 10px; margin: 0 0 6px; border-radius: 11px; border: 1px solid rgba(255,255,255,.10); background: rgba(255,255,255,.03); color: inherit; font: inherit; text-align: left; cursor: pointer; }
467
+ .osheet .row:hover { border-color: rgba(201,184,255,.5); }
468
+ .osheet .row small { color: #9aa3b7; font-weight: 500; }
469
+ .osheet .tag { font-size: 11px; padding: 1px 7px; border-radius: 999px; border: 1px solid rgba(255,255,255,.2); color: #aab3c7; margin-left: 6px; }
470
+ .osheet .tag.you { color: #c9b8ff; border-color: #c9b8ff; }
471
+ .osheet .tag.muted { color: #ff8a9a; border-color: rgba(255,107,125,.6); }
472
+ .osheet .bot { color: #6c7489; font-weight: 500; padding: 2px 10px 8px; }
473
+ .osheet .det { padding: 4px 2px; }
474
+ .osheet .det b { font-size: 18px; display: block; margin-bottom: 4px; overflow-wrap: anywhere; }
475
+ .osheet .det p { margin: 0 0 4px; color: #aab3c7; font-weight: 500; }
476
+ .osheet .acts { display: flex; gap: 8px; margin: 12px 0 4px; flex-wrap: wrap; }
477
+ .osheet .acts button, .osheet form button { min-height: 40px; padding: 0 14px; border-radius: 11px; font: inherit; font-weight: 800; cursor: pointer; border: 1px solid rgba(255,255,255,.18); background: transparent; color: inherit; }
478
+ .osheet .acts .kick { color: #ff8a9a; border-color: rgba(255,107,125,.55); }
479
+ .osheet .acts .kick.armed { background: #ff6b7d; color: #16080a; }
480
+ .osheet .acts .mute { color: #ffc27a; border-color: rgba(255,179,92,.55); }
481
+ .osheet .back { background: none; border: 0; color: #aab3c7; font: inherit; padding: 4px 0; cursor: pointer; }
482
+ .osheet form { margin-top: 10px; padding-top: 10px; border-top: 1px solid rgba(255,255,255,.10); display: grid; gap: 8px; }
483
+ .osheet textarea, .osheet select { box-sizing: border-box; width: 100%; font: 500 14px/1.35 ui-sans-serif, system-ui, sans-serif; color: inherit; background: rgba(255,255,255,.04); border: 1px solid rgba(255,255,255,.16); border-radius: 10px; padding: 8px 10px; }
484
+ .osheet textarea { min-height: 58px; resize: vertical; }
485
+ .osheet form button { background: #c9b8ff; color: #120a24; border-color: transparent; }
486
+ .osheet .msg { color: #aab3c7; font-weight: 500; min-height: 1.2em; margin: 6px 2px 0; }`;
487
+
488
+ const OWNER_JS = String.raw`(function () {
489
+ 'use strict';
490
+ var boot = window.__HOMIE_PLAY; var state = window.__shell;
491
+ if (!boot || !boot.owner || !state || boot.screen) return;
492
+ var HOLD = 10;
493
+ function el(tag, cls, text) { var e = document.createElement(tag); if (cls) e.className = cls; if (text !== undefined && text !== null) e.textContent = String(text); return e; }
494
+ // Beside the room button, on its inner side: the game already keeps that edge clear of its own HUD
495
+ // (game.json screen.share), so the owner's button never sits on a scoreboard or a stick.
496
+ var root = el('div', 'owner'); root.setAttribute('data-owner', ''); root.setAttribute('data-keep-focus', '');
497
+ var pill = el('button', 'opill'); pill.type = 'button'; pill.setAttribute('aria-expanded', 'false'); pill.appendChild(el('i')); pill.appendChild(el('span', '', 'Owner'));
498
+ var sheet = el('div', 'osheet'); sheet.hidden = true; sheet.setAttribute('role', 'dialog'); sheet.setAttribute('aria-label', 'Owner');
499
+ root.append(pill, sheet); document.body.appendChild(root);
500
+ var toggleEl = document.querySelector('[data-share-toggle]');
501
+ function placeOwner() {
502
+ var r = toggleEl ? toggleEl.getBoundingClientRect() : null;
503
+ if (!r || !r.width) return;
504
+ var right = r.left + r.width / 2 > innerWidth / 2;
505
+ var low = r.top > innerHeight / 2;
506
+ root.classList.toggle('at-right', right); root.classList.toggle('up', low);
507
+ root.style.top = low ? '' : Math.round(r.top) + 'px';
508
+ root.style.bottom = low ? Math.round(innerHeight - r.bottom) + 'px' : '';
509
+ root.style.left = right ? '' : Math.round(r.right + 8) + 'px';
510
+ root.style.right = right ? Math.round(innerWidth - r.left + 8) + 'px' : '';
511
+ }
512
+ placeOwner();
513
+ addEventListener('resize', placeOwner);
514
+ try { new ResizeObserver(placeOwner).observe(toggleEl); } catch (e) { setInterval(placeOwner, 500); }
515
+ var view = { pick: null, msg: '', armed: null, armedAt: 0 };
516
+ function post(path, body) {
517
+ return fetch(path, { method: 'POST', credentials: 'same-origin', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
518
+ .then(function (r) { return r.json().catch(function () { return { ok: false, message: 'HTTP ' + r.status }; }); })
519
+ .catch(function () { return { ok: false, message: 'The studio did not answer.' }; });
520
+ }
521
+ function clients() { var f = state.facts; return (f && Array.isArray(f.clients) ? f.clients : []).filter(function (c) { return c.seat !== null || c.waiting; }); }
522
+ function bots() { var f = state.facts; return (f && Array.isArray(f.roster) ? f.roster : []).filter(function (s) { return s && s.bot; }); }
523
+ function mins(ms) { var m = Math.max(0, Math.round(ms / 60000)); return m < 1 ? 'under a minute' : m + ' min'; }
524
+ var drawn = '';
525
+ function render(force) {
526
+ if (sheet.hidden) return;
527
+ var active = document.activeElement; if (active && sheet.contains(active) && /TEXTAREA|SELECT/.test(active.tagName)) return;
528
+ // Redraw only when something it shows changed (a button under a finger is never swapped mid-tap).
529
+ var armedOn = view.armed !== null && Date.now() - view.armedAt < 4000 ? view.armed : null;
530
+ var sig = JSON.stringify([state.room, state.seat, view.pick, view.msg, armedOn, clients().map(function (c) { return [c.id, c.name, c.seat, c.muted, c.role, c.device]; }), bots().map(function (b) { return b.name; })]);
531
+ if (!force && sig === drawn) return;
532
+ drawn = sig;
533
+ sheet.textContent = '';
534
+ var h = el('h2'); h.appendChild(el('span', '', 'Owner · ' + (state.room ? (/^pub-(\d+)$/.test(state.room) ? 'Room ' + state.room.slice(4) : state.room) : 'this room')));
535
+ var office = el('a', '', 'Office ↗'); office.href = '/_studio/office'; office.target = '_blank'; office.rel = 'noopener'; h.appendChild(office);
536
+ sheet.appendChild(h);
537
+ var list = clients();
538
+ var picked = view.pick === null ? null : list.filter(function (c) { return c.id === view.pick || (typeof view.pick === 'number' && c.seat === view.pick); })[0];
539
+ if (picked) {
540
+ var me = picked.seat !== null && picked.seat === state.seat;
541
+ var back = el('button', 'back', '← Everyone here'); back.type = 'button'; back.onclick = function () { view.pick = null; view.msg = ''; render(); };
542
+ var det = el('div', 'det');
543
+ det.appendChild(el('b', '', picked.name || 'Someone'));
544
+ det.appendChild(el('p', '', (picked.seat !== null ? 'Seat ' + (picked.seat + 1) : 'Watching') + ' · ' + (picked.device === 'phone' ? 'on a phone' : picked.device === 'tv' ? 'a big screen' : 'on a computer') + (picked.role === 'host' ? ' · hosting the room' : '')));
545
+ if (me) det.appendChild(el('p', '', 'This is you.'));
546
+ if (picked.muted) det.appendChild(el('p', '', 'Muted: their chat and emotes reach nobody.'));
547
+ sheet.append(back, det);
548
+ if (!me) {
549
+ var acts = el('div', 'acts');
550
+ var mute = el('button', 'mute', picked.muted ? 'Unmute' : 'Mute ' + HOLD + ' min'); mute.type = 'button';
551
+ mute.onclick = function () { mute.disabled = true; post('/_studio/api/mute', { game: boot.game, room: state.room, id: picked.id, minutes: HOLD, off: Boolean(picked.muted) }).then(function (r) { view.msg = r.ok ? (picked.muted ? 'Unmuted.' : 'Muted for ' + HOLD + ' min.') : (r.message || 'That did not work.'); render(); }); };
552
+ // A kick takes a second tap within 4 s (the sheet redraws meanwhile, so the arming is remembered here).
553
+ var armedNow = view.armed === picked.id && Date.now() - view.armedAt < 4000;
554
+ var kick = el('button', 'kick' + (armedNow ? ' armed' : ''), armedNow ? 'Sure? Kick' : 'Kick ' + HOLD + ' min'); kick.type = 'button';
555
+ kick.onclick = function () {
556
+ if (!(view.armed === picked.id && Date.now() - view.armedAt < 4000)) { view.armed = picked.id; view.armedAt = Date.now(); render(); setTimeout(render, 4050); return; }
557
+ view.armed = null;
558
+ kick.disabled = true;
559
+ post('/_studio/api/kick', { game: boot.game, room: state.room, id: picked.id, minutes: HOLD }).then(function (r) { view.msg = r.ok ? (picked.name || 'They') + ' was removed for ' + HOLD + ' min.' : (r.message || 'That did not work.'); if (r.ok) view.pick = null; render(); });
560
+ };
561
+ acts.append(mute, kick); sheet.appendChild(acts);
562
+ }
563
+ } else {
564
+ if (!list.length) sheet.appendChild(el('p', 'msg', 'Nobody else is here yet.'));
565
+ list.forEach(function (c) {
566
+ var row = el('button', 'row'); row.type = 'button';
567
+ var left = el('span'); left.appendChild(el('span', '', c.name || 'Someone'));
568
+ if (c.seat !== null && c.seat === state.seat) left.appendChild(el('span', 'tag you', 'you'));
569
+ if (c.muted) left.appendChild(el('span', 'tag muted', 'muted'));
570
+ if (c.role === 'host') left.appendChild(el('span', 'tag', 'host'));
571
+ row.append(left, el('small', '', c.seat !== null ? 'seat ' + (c.seat + 1) + ' · ' + (c.device === 'phone' ? 'phone' : c.device === 'tv' ? 'screen' : 'computer') : 'waiting'));
572
+ row.onclick = function () { view.pick = c.id; view.msg = ''; render(); };
573
+ sheet.appendChild(row);
574
+ });
575
+ var b = bots(); if (b.length) sheet.appendChild(el('div', 'bot', 'Bots: ' + b.map(function (s) { return s.name; }).join(', ')));
576
+ }
577
+ var form = el('form'); form.setAttribute('data-announce', '');
578
+ var ta = el('textarea'); ta.maxLength = 280; ta.placeholder = 'Announce something to the players'; ta.setAttribute('aria-label', 'Announcement');
579
+ var scope = el('select'); var o1 = el('option', '', 'This room'); o1.value = 'room'; var o2 = el('option', '', 'Every room of ' + boot.name); o2.value = 'game'; scope.append(o1, o2);
580
+ var send = el('button', '', 'Announce'); send.type = 'submit';
581
+ form.append(ta, scope, send);
582
+ form.onsubmit = function (e) {
583
+ e.preventDefault(); if (!ta.value.trim()) return; send.disabled = true;
584
+ post('/_studio/api/announce', scope.value === 'room' ? { game: boot.game, room: state.room, text: ta.value } : { game: boot.game, text: ta.value }).then(function (r) {
585
+ view.msg = r.ok ? 'Announced to ' + (r.people || 0) + (r.people === 1 ? ' person' : ' people') + '.' : (r.message || 'That did not work.');
586
+ if (r.ok) ta.value = ''; send.disabled = false; ta.blur(); render();
587
+ });
588
+ };
589
+ sheet.appendChild(form);
590
+ sheet.appendChild(el('p', 'msg', view.msg));
591
+ }
592
+ function open(on) { sheet.hidden = !on; pill.setAttribute('aria-expanded', on ? 'true' : 'false'); if (on) render(true); }
593
+ pill.onclick = function (e) { e.stopPropagation(); open(sheet.hidden); };
594
+ document.addEventListener('keydown', function (e) { if (e.key === 'Escape' && !sheet.hidden) open(false); });
595
+ // A game that knows its players' bodies opens one's card (net.pickPlayer(seat), posted by its frame).
596
+ window.addEventListener('homie-pick', function (e) { var seat = e.detail && e.detail.seat; if (seat === null || typeof seat === 'number') { view.pick = seat; view.msg = ''; open(true); } });
597
+ window.__ownerPick = function (seat) { view.pick = seat; view.msg = ''; open(true); };
598
+ setInterval(render, 1500);
599
+ window.__owner = { open: open, render: render };
600
+ }());`;