@homie-rocks/studio 0.12.1 → 0.13.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/README.md +64 -3
- package/bin/homie-studio.mjs +51 -3
- package/lib/build.mjs +2 -0
- package/lib/cloudflare.mjs +3 -3
- package/lib/office.mjs +172 -0
- package/lib/scaffold.mjs +42 -1
- package/lib/stats.mjs +1 -1
- package/lib/template-history.json +25 -0
- package/lib/upgrade.mjs +5 -2
- package/netplay/NETPLAY.md +101 -15
- package/netplay/netplay.ts +49 -3
- package/package.json +1 -1
- package/worker/index.mjs +172 -33
- package/worker/office-page.mjs +366 -0
- package/worker/office-schema.mjs +39 -0
- package/worker/office.mjs +762 -0
- package/worker/pages.mjs +257 -10
- package/worker/room.mjs +301 -8
- package/worker/stats-page.mjs +16 -7
- package/worker/stats.mjs +39 -12
- package/worker/version.mjs +1 -1
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
+
}());`;
|