PageSourceSearch

https://app.replicants.ai/js/space-live-room.js?v=20261006gm1

js replicants.ai collected 2026-10-05 12:31:02 UTC 135,682 bytes, 2,110 lines download raw bytes

1// space-live-room.js — a space's Live tab and the host's live room (coach app).
2//
3// Approved design: docs/projects/dau-50/spaces-design/project/HostLive.dc.html
4// (artboard 6) and the "Next live session" card on SpaceOverview.dc.html.
5// Spec: docs/projects/dau-50/SPACES-SPEC.md §3 (live, raised hands, breakouts,
6// background) and §4a (the free allowance).
7//
8// WHAT IT BORROWS, NAMED (CODING-STANDARDS §4):
9//   POST /community-hosting/streams/:id/go-live, /end   the room's lifecycle (stream-service)
10//   GET  /community-hosting/streams/:id/chat           the ONE poll: chat, Q&A, hands, attendance,
11//                                                      breakouts, clock — and the coach heartbeat
12//   POST /community-hosting/streams/:id/hands/:cid     invite / remove / mute a raised hand (P5)
13//   /community-hosting/streams/:id/breakouts…          breakout rooms (stream-breakout-service)
14//   /vendor/livekit/livekit-client-2.21.0.umd.js       the room (the platform's one provider)
15//   /vendor/livekit/track-processors-0.8.1.umd.js      background blur / a chosen picture
16//   CBSheet                                            every question asked (never a native dialog)
17//   App.PlanCheckout.open('platform')                  the Studio upgrade, as the sidebar opens it
18//
19// Mounted by community-spaces.js: window.SpaceLive.pane(el, ctx), .nextCard(el, ctx),
20// .goLive(ctx), .tryRoom(ctx). ctx = { space, cohorts, api, toast, reload }.
21//
22// TRYING THE ROOM (round 3, Zoom's test meeting): a stream row is single-use, so
23// going live on a scheduled session to "have a look" used that session up (the
24// review lost Week 1 on 4 Nov). Trying the room opens the platform's rehearsal
25// room instead (POST /community-hosting/streams {test:true}, §CALL-TEST.1): its
26// own row, in no space, seen by no member. Ending it ends that row and nothing
27// else, so a scheduled session can never be spent or ended by a test.
28
29(function () {
30  'use strict';
31
32  var LK_SRC = '/vendor/livekit/livekit-client-2.21.0.umd.js';
33  var TP_SRC = '/vendor/livekit/track-processors-0.8.1.umd.js';
34  // The one gallery layout and Pin every live room shares (window.LiveGallery);
35  // the member's card carries the same file (live-pin-build.source()).
36  var GALLERY_SRC = '/js/live-gallery.js?v=20261003g6r10';
37  var POLL_MS = 2500;
38
39  function _esc(v) {
40    return String(v == null ? '' : v).replace(/[&<>"']/g, function (c) {
41      return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
42    });
43  }
44  function _load(src, global) {
45    return new Promise(function (resolve, reject) {
46      if (window[global]) return resolve(window[global]);
47      var s = document.createElement('script');
48      s.src = src;
49      s.onload = function () { window[global] ? resolve(window[global]) : reject(new Error(global + ' did not load')); };
50      s.onerror = function () { reject(new Error(src + ' failed to load')); };
51      document.head.appendChild(s);
52    });
53  }
54  function _mmss(ms) {
55    var s = Math.max(0, Math.round(ms / 1000));
56    var m = Math.floor(s / 60); var r = s % 60;
57    return m + ':' + (r < 10 ? '0' : '') + r;
58  }
59  function _when(iso) {
60    if (!iso) return '';
61    var d = new Date(iso);
62    if (isNaN(d.getTime())) return '';
63    return window.App && window.App.Dates ? window.App.Dates.weekdayShortTime(d) : '';
64  }
65  function _inWords(iso) {
66    var d = new Date(iso); if (isNaN(d.getTime())) return '';
67    var days = Math.round((new Date(d.toDateString()) - new Date(new Date().toDateString())) / 86400000);
68    if (days === 0) return 'Today';
69    if (days === 1) return 'Tomorrow';
70    if (days > 1) return 'In ' + days + ' days';
71    return 'Past';
72  }
73
74  var ICON = {
75    live: '<circle cx="12" cy="12" r="3"/><path d="M16.2 7.8a6 6 0 0 1 0 8.4M7.8 16.2a6 6 0 0 1 0-8.4M19 5a10 10 0 0 1 0 14M5 19A10 10 0 0 1 5 5"/>',
76    mic: '<rect x="9" y="3" width="6" height="11" rx="3"/><path d="M5 11a7 7 0 0 0 14 0M12 18v3"/>',
77    cam: '<rect x="3" y="6" width="13" height="12" rx="2"/><path d="M16 10l5-3v10l-5-3z"/>',
78    bg: '<circle cx="12" cy="9" r="3.5"/><path d="M5 20c1-4 4-6 7-6s6 2 7 6"/><rect x="2" y="2" width="20" height="20" rx="4"/>',
79    screen: '<rect x="3" y="4" width="18" height="12" rx="2"/><path d="M8 20h8M12 16v4"/>',
80    rooms: '<rect x="3" y="3" width="8" height="8" rx="2"/><rect x="13" y="3" width="8" height="8" rx="2"/><rect x="3" y="13" width="8" height="8" rx="2"/><rect x="13" y="13" width="8" height="8" rx="2"/>',
81    hand: '<path d="M8 13V5a1.5 1.5 0 0 1 3 0v6M11 11V4a1.5 1.5 0 0 1 3 0v7M14 11V6a1.5 1.5 0 0 1 3 0v8a7 7 0 0 1-7 7h-.5A6.5 6.5 0 0 1 4 15l-1-3a1.5 1.5 0 0 1 2.7-1.2L8 13"/>',
82    clock: '<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>',
83    people: '<circle cx="9" cy="8" r="3"/><path d="M3 20c.8-3.5 3.2-5 6-5s5.2 1.5 6 5"/><circle cx="17" cy="9" r="2.5"/><path d="M16 15c2.6 0 4.4 1.4 5 4"/>',
84    help: '<circle cx="12" cy="12" r="9"/><path d="M9.5 9a2.5 2.5 0 1 1 3.5 2.3c-.6.3-1 .8-1 1.5V14M12 17.5v.5"/>',
85    // Review 3, UX3-4: a meetup is a place, a call on the coach's own link is a link, a course's session a date.
86    pin: '<path d="M12 21s-7-6.2-7-11.5A7 7 0 0 1 19 9.5C19 14.8 12 21 12 21z"/><circle cx="12" cy="9.5" r="2.5"/>',
87    link: '<path d="M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1 1"/><path d="M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1-1"/>',
88    cal: '<rect x="3.5" y="5" width="17" height="15" rx="2"/><path d="M3.5 10h17M8 3v4M16 3v4"/>',
89  };
90  function _ic(n, s) {
91    s = s || 18;
92    return '<svg width="' + s + '" height="' + s + '" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' + (ICON[n] || '') + '</svg>';
93  }
94
95  // ── The free allowance, in the coach's words (SPACES-SPEC §4a) ──────────
96
97  function allowanceLine(a) {
98    if (!a || a.mode === 'studio' || a.mode === 'open') return '';
99    var st = window.LiveAllowanceWords && window.LiveAllowanceWords.studioWords ? window.LiveAllowanceWords.studioWords(a) : { tail: '' };
100    if (a.mode === 'off') {
101      return '<span class="slr-free">Live is part of Studio' + _esc(st.tail.replace(/,$/, '')) + '. <a href="#" data-slr-studio>See Studio</a></span>';
102    }
103    /* Alex, 2 Oct 2026: no minute or price talk up front. The free plan's
104       monthly minutes are said in the live room itself while broadcasting
105       (the banner, _poolBanner) and where 1:1 video calls are set up. */
106    return '';
107  }
108  function _overLine() { return ''; }
109  function _overShort() { return ''; }
110  /* TK-15 (tyre-kicker, 2 Oct): "Upgrade for more" opened the general trial
111     panel, which says nothing about live calls. From a live-minutes place
112     (the room's banner and warnings, the Booking tab, the end screen) it first
113     says what Studio changes for live calls and its price, from the one rule
114     (LiveAllowanceWords.studioWords, the plan catalogue, US dollars), then
115     opens the same Studio upgrade as everywhere else. No new pricing page. */
116  async function openStudio(opts) {
117    var o = opts || {};
118    if (o.live && window.CBSheet && window.CBSheet.confirm) {
119      var a = o.allowance || _state.allowance || null;
120      var W = window.LiveAllowanceWords;
121      var st = (W && W.studioWords) ? W.studioWords(a) : { tail: ', our paid plan,' };
122      var mins = (a && a.monthly_minutes) ? a.monthly_minutes + ' free minutes a month' : 'free monthly minutes';
123      var go = await window.CBSheet.confirm({
124        title: 'More live time with Studio',
125        message: 'On the free plan your live sessions and 1:1 video calls share ' + mins + ', with up to 10 people in a call. ' +
126          'Studio' + st.tail + ' takes the monthly limit away: your calls run as long as you need, and more people can join. ' +
127          'Recordings are kept on every plan.',
128        confirmLabel: 'See Studio', cancelLabel: 'Not now',
129      });
130      if (!go) return;
131    }
132    if (window.App && window.App.PlanCheckout && typeof window.App.PlanCheckout.open === 'function') {
133      window.App.PlanCheckout.open('platform');
134      return;
135    }
136    // Loud, never silent (CODING-STANDARDS §7b): the upgrade is the one thing this link does.
137    console.error('[space-live] App.PlanCheckout is not loaded: the Studio upgrade cannot open');
138    if (typeof window.showToast === 'function') window.showToast('Studio could not open. Please refresh the page and try again.', 'error');
139  }
140
141  // ── The Live tab ────────────────────────────────────────────────────────
142
143  var _state = { streams: [], allowance: null };
144
145  async function _fetchLive(ctx) {
146    var r = await ctx.api('/community-hosting/spaces/' + ctx.space.id + '/live');
147    _state.streams = (r && r.streams) || [];
148    _state.allowance = (r && r.allowance) || null;
149    _state.events = (r && r.events) || [];   // a course's group sessions moved into its space (861)
150    return _state;
151  }
152
153  function _next(streams) {
154    return streams.filter(function (s) { return s.state === 'live'; })[0]
155      || streams.filter(function (s) { return s.state === 'idle'; })
156        .sort(function (a, b) { return String(a.scheduled_at || '9').localeCompare(String(b.scheduled_at || '9')); })[0]
157      || null;
158  }
159
160  /* ── The space's other events (calendar events) ──────────────────────
161     Made here with "Add an event" (a meetup, a call on the coach's own link) or
162     a course's group sessions moved in. Review 3, UX3-4: they belong under
163     "Coming up" with the live-room sessions, each with its own icon, so the
164     coach is never told "Nothing scheduled" about the meetup they just added. */
165  function _evIcon(ev) {
166    if (ev.kind === 'in_person') return 'pin';
167    if (ev.join_url) return 'link';
168    if (ev.stream_id) return 'live';
169    return 'cal';
170  }
171  function _evWhere(ev) {
172    if (ev.kind === 'in_person') return [ev.venue_name, ev.address].filter(
172Boolean).join(', ') || ev.location || '';
173    return ev.location || '';
174  }
175  function _evMeta(ev) {
176    var where = _evWhere(ev);
177    return _esc(_when(ev.starts_at)) + (where ? ' · ' + _esc(where) : '') +
178      (ev.join_url ? ' · On your own link' : (ev.stream_id ? ' · In the live room' : '')) +
179      (ev.course_id ? ' · From the course' : '') + _meetLine(ev);
180  }
181  /* 949: a meetup in the coach's Coming up list shows what members see on its
182     card, the map and the photos (js/meetup-visual.js, the member site's own
183     file), with "Photos" beside "Who's here" to add or remove them. */
184  function _meetVisHtml(ev) {
185    var MV = window.MeetupVisual;
186    if (!MV || ev.kind !== 'in_person') return '';
187    var inner = MV.mapHtml(ev, { height: 200 }) + MV.photosHtml(ev);
188    return inner ? '<div class="slr-meetvis">' + inner + '</div>' : '';
189  }
190  function _evRowHtml(ev, withActions) {
191    return '<div class="cpg-item" data-slr-ev="' + ev.id + '"><div class="csp-ico" style="width:30px;height:30px;flex-basis:30px">' + _ic(_evIcon(ev), 15) + '</div>' +
192      '<div class="cpg-item-body"><div class="cpg-item-title">' + _esc(ev.title) + '</div>' +
193      '<div class="cpg-item-meta">' + _evMeta(ev) + '</div></div>' +
194      (withActions
195        /* Meetups (910, js/space-meetups.js): "Who's here" ticks people in on the day. */
196        ? (ev.kind === 'in_person' && window.SpaceMeetups && window.SpaceMeetups.photos ? '<button type="button" class="btn btn-secondary cpg-btn-tight" data-slr-photos="' + ev.id + '">Photos</button>' : '') +
197          (ev.kind === 'in_person' && window.SpaceMeetups ? '<button type="button" class="btn btn-secondary cpg-btn-tight" data-slr-door="' + ev.id + '" data-title="' + _esc(ev.title) + '">Who\u2019s here</button>' : '') +
198          (ev.course_id ? '' : '<button type="button" class="btn btn-secondary cpg-btn-tight" data-slr-evdel="' + ev.id + '" data-title="' + _esc(ev.title) + '">Remove</button>')
199        : '') +
200      '</div>' + (withActions ? _meetVisHtml(ev) : '');
201  }
202
203  function _nextCardHtml(s, a, evs, otherEvents) {
204    evs = evs || [];
205    if (!s && evs.length) {
206      /* No live-room session, but a meetup or a call on their own link is coming. */
207      return '<div class="card slr-next"><div class="card-head"><h2 class="card-title">Coming up</h2>' +
208          (evs[0].starts_at ? '<span class="pill p-acc">' + _esc(_inWords(evs[0].starts_at)) + '</span>' : '') + '</div>' +
209        '<div class="slr-next-body"><div class="csp-steps">' + evs.slice(0, 3).map(function (ev) { return _evRowHtml(ev, false); }).join('') + '</div>' +
210        '<div class="cpg-item-meta">No live-room session booked.</div>' +
211        '<div class="slr-row">' +
212          '<button type="button" class="btn btn-secondary csp-btn" data-slr-schedule>Add an event</button>' +
213          '<button type="button" class="btn btn-primary csp-btn" data-slr-golive-now>' + _ic('live', 16) + ' Go live now</button>' +
214          _tryBtn() +
215        '</div>' + allowanceLine(a) + '</div></div>';
216    }
217    if (!s) {
218      return '<div class="card slr-next"><div class="card-head"><h2 class="card-title">Next live session</h2></div>' +
219        '<div class="slr-next-body"><div class="cpg-item-meta">' + (otherEvents ? 'No live-room session booked. Your other events are under Coming up.' : 'Nothing scheduled yet.') + '</div>' +
220        '<div class="slr-row">' +
221          '<button type="button" class="btn btn-secondary csp-btn" data-slr-schedule>Add an event</button>' +
222          '<button type="button" class="btn btn-primary csp-btn" data-slr-golive-now>' + _ic('live', 16) + ' Go live now</button>' +
223          _tryBtn() +
224        '</div>' + allowanceLine(a) + '</div></div>';
225    }
226    var live = s.state === 'live';
227    return '<div class="card slr-next"><div class="card-head"><h2 class="card-title">' + (live ? 'Live now' : 'Next live session') + '</h2>' +
228        (live ? '<span class="pill p-warn">Live</span>' : (s.scheduled_at ? '<span class="pill p-acc">' + _esc(_inWords(s.scheduled_at)) + '</span>' : '')) + '</div>' +
229      '<div class="slr-next-body"><div class="slr-next-title">' + _esc(s.title) + '</div>' +
230        '<div class="cpg-item-meta">' + (s.scheduled_at ? _esc(_when(s.scheduled_at)) : 'Not scheduled: go live whenever you are ready') +
231          (s.planned_minutes ? ' · ' + _esc(minutesWords(s.planned_minutes)) : '') +
232          (s.space_cohort_name ? ' · ' + _esc(s.space_cohort_name) : ' · Everyone in the space') + '</div>' + _overLine(s, a) +
233        '<div class="slr-chips"><span class="pill p-mut">' + _ic('hand', 12) + ' Raised hands</span>' +
234          '<span class="pill p-mut">' + _ic('rooms', 12) + ' Breakout rooms</span>' +
235          '<span class="pill p-mut">Replay posted to the space</span></div>' +
236        '<div class="slr-row"><button type="button" class="btn btn-primary csp-btn" data-slr-golive="' + s.id + '">' + _ic('live', 16) + ' ' + (live ? 'Rejoin' : 'Go live') + '</button>' +
237          (live ? '' : _tryBtn()) + allowanceLine(a) + '</div>' +
238        (evs.length ? '<div class="slr-also"><div class="csp-lbl">Also coming up</div><div class="csp-steps">' +
239          evs.slice(0, 3).map(function (ev) { return _evRowHtml(ev, false); }).join('') + '</div></div>' : '') +
240        '</div></div>';
241  }
242
243  function _tryBtn() {
244    return '<button type="button" class="btn btn-secondary csp-btn" data-slr-try>' + _ic('cam', 16) + ' Try the room</button>';
245  }
246
247  async function nextCard(el, ctx) {
248    if (!el) return;
249    try { await _fetchLive(ctx); } catch (e) { el.innerHTML = ''; return; }
250    el.innerHTML = _nextCardHtml(_next(_state.streams), _state.allowance, _state.events || []);
251    _wire(el, ctx);
252  }
253
254  async function pane(el, ctx) {
255    if (!el) return;
256    el.innerHTML = '<div class="empty-block" style="opacity:0.6">Loading…</div>';
257    try { await _fetchLive(ctx); } catch (e) { el.innerHTML = '<div class="csp-none">Live sessions could not load.</div>'; return; }
258    var st = _state.streams;
259    var upcoming = st.filter(function (s) { return s.state !== 'ended'; });
260    var past = st.filter(function (s) { return s.state === 'ended'; });
261    function endLine(s) {
262      if (s.end_reason === 'limit') return 'Ended when this month\u2019s free live minutes ran out. The recording is kept.';
263      if (s.end_reason === 'idle') return 'Ended automatically: the live page was closed.';
264      if (s.end_reason === 'cap') return 'Ended automatically at the 3-hour limit.';
265      if (s.end_reason === 'killed') return 'Ended by the platform: the host left the room.';
266      return '';
267    }
268    function replayPill(s) {
269      // NEW-2: the replay job tried for a day and could not post it; say where the recording is.
270      if (s.replay_post_failed && !s.replay_post_id) return '<span class="pill p-warn" title="We could not post this replay to the feed. The recording is in your Media library, so you can post it from there.">Replay not posted</span>';
271      if (s.replay && s.replay.status === 'ready') return s.replay_post_id ? '<span class="pill p-good">Replay in the feed</span>' : '<span class="pill p-good">Replay ready</span>';
272      if (s.replay_video_id || s.replay) return '<span class="pill p-mut">Replay processing</span>';
273      return '<span class="pill p-mut">No replay</span>';
274    }
275    /* Review 3, UX3-4: one "Coming up" list, soonest first: the live-room
276       sessions and the space's other events (a meetup, a call on the coach's
277       own link, a course's sessions), each with its own icon. */
278    var evs = _state.events || [];
279    var coming = upcoming.map(function (x) { return { at: x.scheduled_at, s: x }; })
280      .concat(evs.map(function (ev) { return { at: ev.starts_at, ev: ev }; }))
281      .sort(function (p, q) {
282        var a = p.s && p.s.state === 'live' ? -1 : (p.at ? Date.parse(p.at) : 8.64e15);
283        var b = q.s && q.s.state === 'live' ? -1 : (q.at ? Date.parse(q.at) : 8.64e15);
284        return a - b;
285      });
286    function streamRow(s) {
287      var live = s.state === 'live';
288      return '<div class="cpg-item"><div class="csp-ico" style="width:30px;height:30px;flex-basis:30px">' + _ic('live', 15) + '</div>' +
289        '<div class="cpg-item-body"><div class="cpg-item-title">' + _esc(s.title) + (live ? ' <span class="pill p-warn">Live</span>' : '') + '</div>' +
290        '<div class="cpg-item-meta">' + (s.scheduled_at ? _esc(_when(s.scheduled_at)) : 'Not scheduled') + (s.planned_minutes ? ' · ' + _esc(minutesWords(s.planned_minutes)) : '') + (s.space_cohort_name ? ' · ' + _esc(s.space_cohort_name) : '') + ' · In the live room' + _overShort(s, _state.allowance) + '</div></div>' +
291        (live ? '' : '<button type="button" class="btn btn-secondary cpg-btn-tight" data-slr-cancel="' + s.id + '" data-title="' + _esc(s.title) + '">Cancel</button>') +
292        '<button type="button" class="btn btn-primary cpg-btn-tight" data-slr-golive="' + s.id + '">' + (live ? 'Rejoin' : 'Go live') + '</button></div>';
293    }
294    el.innerHTML = _nextCardHtml(_next(st), _state.allowance, [], evs.length > 0) +
295      '<div class="card"><div class="card-head"><h2 class="card-title">Coming up <span class="count">' + coming.length + '</span></h2>' +
296        '<button type="button" class="btn btn-secondary cpg-btn-tight" data-slr-schedule>Add an event</button></div>' +
297      '<div class="csp-steps">' + (coming.length ? coming.map(function (c) { return c.s ? streamRow(c.s) : _evRowHtml(c.ev, true); }).join('')
298        : '<div class="csp-none">Nothing scheduled. Add an event and members see it in the space and in their calendar, or go live straight away.</div>') + '</div></div>' +
299      /* Reminders before each call, by email and (paid plan) by text, with a
300         link to each message's words in Automated emails (community-messages.js). */
301      '<div data-slr-reminders></div>' +
302      '<div class="card"><div class="card-head"><h2 class="card-title">Past sessions <span class="count">' + past.length + '</span></h2></div>' +
303      '<div class="csp-steps">' + (past.length ? past.map(function (s) {
304        var line = endLine(s);
305        if (s.replay_post_failed && !s.replay_post_id) line = (line ? line + ' ' : '') + 'We could not post the replay to the feed. The recording is in your Media library, so you can post it from there.';
306        return '<div class="cpg-item"><div class="cpg-item-body"><div class="cpg-item-title">' + _esc(s.title) + '</div>' +
307          '<div class="cpg-item-meta">' + _esc(_when(s.started_at || s.scheduled_at)) + ' · ' + (s.attended || 0) + ' came' + (line ? ' · ' + _esc(line) : '') + '</div></div>' +
308          replayPill(s) + '</div>';
309      }).join('') : '<div class="csp-none">Past sessions and their replays appear here. Each replay is posted to this space’s feed.</div>') + '</div></div>';
310    _wire(el, ctx);
311    if (window.CommunityMessages && window.CommunityMessages.reminderCard) {
312      window.CommunityMessages.reminderCard(el.querySelector('[data-slr-reminders]'), { toast: ctx.toast });
313    }
314  }
315
316  function _wire(el, ctx) {
317    if (el._slrWired) { el._slrCtx = ctx; return; }
318    el._slrWired = true; el._slrCtx = ctx;
319    el.addEventListener('click', function (e) {
320      var c = el._slrCtx;
321      var t = e.target.closest('[data-slr-schedule],[data-slr-golive],[data-slr-golive-now],[data-slr-cancel],[data-slr-studio],[data-slr-try],[data-slr-evdel],[data-slr-door],[data-slr-photos]');
322      if (!t) return;
323      e.preventDefault(); e.stopPropagation();
324      if (t.hasAttribute('data-slr-studio')) return openStudio({ live: true });
325      /* "Add an event" (SPACES-SPEC.md §8): one button, then what kind
326         (js/space-events.js). Without that module it is the live call sheet. */
327      if (t.hasAttribute('data-slr-schedule')) {
328        return (window.SpaceEvents && typeof window.SpaceEvents.open === 'function') ? window.SpaceEvents.open(c) : _schedule(c);
329      }
330      if (t.hasAttribute('data-slr-evdel')) return _deleteEvent(c, t.getAttribute('data-slr-evdel'), t.getAttribute('data-title'));
331      if (t.hasAttribute('data-slr-door')) return window.SpaceMeetups && window.SpaceMeetups.door(c, t.getAttribute('data-slr-door'), t.getAttribute('data-title'));
332      if (t.hasAttribute('data-slr-photos')) {
333        var pev = (_state.events || []).filter(function (x) { return String(x.id) === t.getAttribute('data-slr-photos'); })[0];
334        return pev && window.SpaceMeetups && window.SpaceMeetups.photos(c, pev);
335      }
336      if (t.hasAttribute('data-slr-golive-now')) return goLive(c);
337      if (t.hasAttribute('data-slr-try')) return tryRoom(c);
338      if (t.hasAttribute('data-slr-golive')) {
339        var gid = parseInt(t.getAttribute('data-slr-golive'), 10);
340        return _startOrAsk(c, (_state.streams || []).filter(function (x) { return x.id === gid; })[0] || { id: gid });
341      }
342      if (t.hasAttribute('data-slr-cancel')) return _cancel(c, t.getAttribute('data-slr-cancel'), t.getAttribute('data-title'));
343    });
344  }
345
346  /* ⭐ R4-22 (dau-50 round 4, Brian): "How many weeks" said 6 for his four-week
347     November group, and two calls landed after it ended. The weeks now follow
348     the group: the one chosen under "Who it is for", or the space's one group
349     still to run when there is only one. Typed by the coach, the number is
350     theirs and stays. Past the group's end is asked, never done quietly. */
351  function _ymd(dt) {
352    return dt.getFullYear() + '-' + ('0' + (dt.getMonth() + 1)).slice(-2) + '-' + ('0' + dt.getDate()).slice(-2);
353  }
354  function _groupFor(ctx, cohortId) {
355    var list = (ctx.cohorts || []).filter(function (c) { return c.state !== 'finished'; });
356    if (cohortId) return (ctx.cohorts || []).filter(function (c) { return String(c.id) === String(cohortId); })[0] || null;
357    return list.length === 1 ? list[0] : null;
358  }
359  function _weeksTo(day, end) {
360    var a = /^(\d{4})-(\d{2})-(\d{2})/.exec(day || ''), b = /^(\d{4})-(\d{2})-(\d{2})/.exec(end || '');
361    if (!a || !b) return null;
362    var days = Math.round((Date.UTC(+b[1], +b[2] - 1, +b[3]) - Date.UTC(+a[1], +a[2] - 1, +a[3])) / 86400000);
363    return days < 0 ? 0 : Math.floor(days / 7) + 1;
364  }
365  function _dayWords(ymd) {
366    var m = /^(\d{4})-(\d{2})-(\d{2})/.exec(ymd || ''); if (!m) return ymd || '';
367    var dt = new Date(+m[1], +m[2] - 1, +m[3]);
368    return (window.App && window.App.Dates && window.App.Dates.short) ? window.App.Dates.short(dt, { year: false })
369      : (window.CBDateFormat ? window.CBDateFormat.format(dt, 'short', { year: false }) : m[0]);
370  }
371
372  async function _schedule(ctx) {
373    if (!window.CBSheet) return;
374    var today = new Date(); today.setDate(today.getDate() + 1);
375    var d = _ymd(today);
376    /* A group that has not started yet: its first day is the natural first call. */
377    var g0 = _groupFor(ctx, null);
378    if (g0 && g0.start_date && g0.start_date > d) d = g0.start_date.slice(0, 10);
379    var w0 = g0 && g0.end_date ? _weeksTo(d, g0.end_date) : null;
380    if (!_state.allowance) { try { await _fetchLive(ctx); } catch (_) {} }
381    /* Round 3 (F14, F15): how long it runs and "every week", as Zoom asks when
382       a meeting is scheduled (its recurring meeting simplified to weekly, a
383       number of times). The free limit is said WHILE the length is picked,
384       before Schedule it, never after. */
385    var lengths = [15, 30, 45, 60, 75, 90, 120].map(function (n) { return { value: String(n), label: minutesWords(n) }; });
386    var fields = [
387      { name: 'title', label: 'What is it called', value: '', placeholder: 'e.g. Week 2: your first 5 invitations', required: true },
388      { name: 'date', label: 'Day', type: 'date', value: d },
389      { name: 'time', label: 'Starts at', type: 'time', value: '20:00' },
390      { name: 'minutes', label: 'How long', type: 'select', value: _defaultLength(_state.allowance), options: lengths },
391      { name: 'repeat', label: 'Repeat', type: 'select', value: 'none',
392        options: [{ value: 'none', label: 'Just this once' }, { value: 'week', label: 'Every week' }] },
393      { name: 'weeks', label: 'How many weeks', type: 'number', min: 2, max: 26, value: (w0 && w0 >= 2) ? Math.min(26, w0) : 6,
394        hint: 'One session each week, at the same time on your clock.' },
395    ];
396    if (ctx.cohorts && ctx.cohorts.length) {
397      fields.push({ name: 'cohort_id', label: 'Who it is for', type: 'select', value: '',
398        options: [{ value: '', label: 'Everyone in the space' }].concat(ctx.cohorts.map(function (c) { return { value: String(c.id), label: c.name }; })) });
399    }
400    var asked = window.CBSheet.form({
401      title: 'Schedule a live session',
402      message: 'Members of ' + ctx.space.name + ' see it in the space. You press Go live when it is time; the replay is posted to the space afterwards.',
403      fields: fields, confirmLabel: 'Schedule it', cancelLabel: 'Cancel',
404    });
405    _wireScheduleSheet(_state.allowance, ctx);
406    var v = await asked;
407    if (!v) return;
408    if (!String(v.title || '').trim()) { ctx.toast('Give the session a name.', true); return; }
409    var when = null;
410    if (v.date) {
411      var dt = new Date(v.date + 'T' + (v.time || '20:00'));
412      if (!isNaN(dt.getTime())) when = dt.toISOString();
413    }
414    try {
415      var weekly = v.repeat === 'week' && when;
416      var n = weekly ? Math.max(2, Math.min(26, parseInt(v.weeks, 10) || 2)) : 1;
417      /* Never past the group's end without saying so (R4-22). */
418      var g = _groupFor(ctx, v.cohort_id || null);
419      var fit = (g && g.end_date && v.date) ? _weeksTo(v.date, g.end_date) : null;
420      if (fit !== null && n > fit) {
421        var late = n - fit;
422        var pick = await window.CBSheet.choose({
423          title: late === 1 ? 'One call falls after ' + g.name + ' ends' : late + ' calls fall after ' + g.name + ' ends',
424          message: g.name + ' ends on ' + _dayWords(g.end_date) + '. ' + (fit
425            ? fit + (fit === 1 ? ' call fits' : ' calls fit') + ' before then.' : 'None of these calls is before then.'),
426          items: [].concat(fit ? [{ value: 'fit', label: fit === 1 ? 'Just the one before it ends' : 'Stop at the end: ' + fit + ' calls' }] : [],
427            [{ value: 'all', label: 'Keep all ' + n, note: 'The last ' + (late === 1 ? 'one runs' : late + ' run') + ' after the group has finished.' }]),
428          confirmLabel: 'Schedule them', cancelLabel: 'Go back',
429        });
430        if (!pick) return;
431        if (pick === 'fit') n = fit;
432      }
433      await ctx.api('/community-hosting/spaces/' + ctx.space.id + '/live', { method: 'POST', body: {
434        title: String(v.title).trim(), scheduled_at: when, cohort_id: v.cohort_id || null,
435        minutes: parseInt(v.minutes, 10) || null, repeat_weeks: n, timezone: _zoneName(),
436      } });
437      ctx.toast(n > 1 ? n + ' weekly sessions scheduled.' : 'Scheduled.');
438      if (ctx.reload) ctx.reload();
439    } catch (e) { ctx.toast('That did not save. Please try again.', true); }
440  }
441
442  /** 45 → "45 minutes", 60 → "1 hour", 90 → "1 hour 30 minutes", 120 → "2 hours". */
443  function minutesWords(n) {
444    n = parseInt(n, 10) || 0;
445    if (n < 60) return n + ' minutes';
446    var h = Math.floor(n / 60), m = n % 60;
447    return h + (h === 1 ? ' hour' : ' hours') + (m ? ' ' + m + ' minutes' : '');
448  }
449  /* "2026-11-01" → "1 November": when the free minutes start again. */
450  function _resetWords(iso) {
451    var d = iso ? new Date(iso + 'T12:00:00Z') : null;
452    if (!d || isNaN(d.getTime())) { var n = new Date(); d = new Date(Date.UTC(n.getUTCFullYear(), n.getUTCMonth() + 1, 1, 12)); }
453    return d.getUTCDate() + ' ' + ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'][d.getUTCMonth()];
454  }
455  /* TK-11 (tyre-kicker, 2 Oct): a free coach's session length starts at what
456     the free monthly minutes can run (30 of 30: "30 minutes"), not an hour that
457     would end early. Said nowhere up front (Alex, 2 Oct): it is only the
458     starting choice, and every length stays open to pick. Studio: 1 hour. */
459  function _defaultLength(a) {
460    if (!a || a.mode !== 'limited' || !a.monthly_minutes) return '60';
461    var fit = [15, 30, 45, 60].filter(function (n) { return n <= Number(a.monthly_minutes); });
462    return String(fit.length ? fit[fit.length - 1] : 15);
463  }
464  function _zoneName() { try { return Intl.DateTimeFormat().resolvedOptions().timeZone || ''; } catch (_) { return ''; } }
465
466  /* The free limit, said where the length is picked (F14). The words are the
467     server's own (LiveAllowanceWords, the same rule Maya and the tools use). */
468  function lengthWarnHtml(allowance, minutes) {
469    var W = window.LiveAllowanceWords;
470    if (!W || !W.poolWords || !allowance || allowance.mode !== 'limited') return '';
471    /* The 1:1 call sheet (Alex, 2 Oct 2026): the calls use the free monthly
472       minutes, how many are left, how to get more, and whether the length
473       picked is longer than what is left. */
474    var w = W.poolWords(allowance);
475    var n = parseInt(minutes, 10) || 0;
476    return '1:1 calls use your free live minutes, the same ones as your live sessions. ' + w.summary +
477      (n && n > w.left ? ' A ' + n + '-minute call booked now would end after ' + w.left + (w.left === 1 ? ' minute.' : ' minutes.') : '') +
478      ' To get more, upgrade to Studio, our paid plan.';
479  }
480  /* The warning under a length picker in the open sheet. CBSheet.form draws
481     the sheet before it resolves, so the field is on the page now. Used by the
482     1:1 call sheet (space-booking.js); amber only when the length is longer than what is left. */
483  function _wireLengthWarn(allowance, field) {
484    var sheet = document.getElementById('cb-sheet'); if (!sheet) return null;
485    var mins = sheet.querySelector('[data-cbs-field="' + field + '"]');
486    if (!mins || !mins.closest('label')) return null;
487    var note = document.createElement('div');
488    note.className = 'slr-limit-warn'; note.setAttribute('role', 'status'); note.setAttribute('aria-live', 'polite');
489    note.style.cssText = 'margin:-4px 0 4px;padding:10px 12px;border-radius:10px;background:var(--warn-soft);color:var(--warn);font-size:13.5px;line-height:1.45';
490    mins.closest('label').insertAdjacentElement('afterend', note);
491    function draw() {
492      var n = parseInt(mins.value, 10) || 0;
493      var t = lengthWarnHtml(allowance, n || null); note.textContent = t; note.style.display = t ? '' : 'none';
494      var short = allowance && allowance.minutes_left != null && n > Number(allowance.minutes_left);
495      note.style.background = short ? 'var(--warn-soft)' : 'var(--accent-soft)';
496      note.style.color = short ? 'var(--warn)' : 'var(--text)';
497    }
498    mins.addEventListener('change', draw);
499    draw();
500    return note;
501  }
502  /* The schedule sheet: the length warning, and "How many weeks" only when it repeats. */
503  function _wireScheduleSheet(allowance, ctx) {
504    /* No free-minutes warning while a session is scheduled (Alex, 2 Oct 2026):
505       the monthly minutes are said in the room, while broadcasting. */
506    var sheet = document.getElementById('cb-sheet'); if (!sheet) return;
507    var rep = sheet.querySelector('[data-cbs-field="repeat"]');
508    var weeks = sheet.querySelector('[data-cbs-field="weeks"]');
509    var day = sheet.querySelector('[data-cbs-field="date"]');
510    var who = sheet.querySelector('[data-cbs-field="cohort_id"]');
511    var typed = false;
512    var note = null;
513    if (weeks && weeks.closest('label') && ctx) {
514      note = document.createElement('div');
515      note.className = 'slr-weeks-note';
516      note.style.cssText = 'margin:-4px 0 4px;font-size:13.5px;line-height:1.45;color:var(--text-2,#4d5168)';
517      weeks.closest('label').insertAdjacentElement('afterend', note);
518      weeks.addEventListener('input', function () { typed = true; });
519    }
520    /* The group the calls are for, and its weeks from the chosen day. */
521    function follow() {
522      if (!ctx || !weeks) return;
523      var g = _groupFor(ctx, who && who.value);
524      var fit = g && g.end_date && day && day.value ? _weeksTo(day.value, g.end_date) : null;
525      if (fit && fit >= 2 && !typed) weeks.value = String(Math.min(26, fit));
526      if (note) note.textContent = g && g.end_date
527        ? g.name + ' runs ' + _dayWords(g.start_date) + ' to ' + _dayWords(g.end_date) + (fit ? ': ' + fit + (fit === 1 ? ' week' : ' weeks') + ' from this day.' : '.')
528        : '';
529    }
530    function draw() {
531      var on = rep && rep.value === 'week';
532      if (weeks && weeks.closest('label')) weeks.closest('label').style.display = on ? '' : 'none';
533      if (note) note.style.display = on && note.textContent ? '' : 'none';
534    }
535    if (rep) rep.addEventListener('change', function () { follow(); draw(); });
536    if (day) day.addEventListener('change', function () { follow(); draw(); });
537    if (who) who.addEventListener('change', function () { follow(); draw(); });
538    follow();
539    draw();
540  }
541
542  async function _cancel(ctx, id, title) {
543    var ok = window.CBSheet && await window.CBSheet.confirm({
544      title: 'Cancel ' + (title || 'this session') + '?',
545      message: 'It disappears from the space. Nobody is told.',
546      confirmLabel: 'Cancel the session', cancelLabel: 'Keep it', danger: true,
547    });
548    if (!ok) return;
549    try {
550      await ctx.api('/community-hosting/spaces/' + ctx.space.id + '/live/' + id, { method: 'DELETE' });
551      ctx.toast('Cancelled.');
552      if (ctx.reload) ctx.reload();
553    } catch (e) { ctx.toast('That did not cancel. Please try again.', true); }
554  }
555
556  /* A meetup's numbers in its row: "9 of 12 going · 2 waiting · Only the September group". */
557  function _meetLine(ev) {
558    if (!ev || ev.kind !== 'in_person') return '';
559    var g = Number(ev.going_count) || 0;
560    return ' · ' + g + (ev.cap != null ? ' of ' + ev.cap : '') + ' going' +
561      (ev.waiting_count ? ' · ' + ev.waiting_count + ' waiting' : '') +
562      (ev.here_count ? ' · ' + ev.here_count + ' here' : '') +
563      (ev.space_cohort_name ? ' · Only the ' + _esc(ev.space_cohort_name) : '') +
564      (ev.open_to_community ? ' · Open to your whole community' : '');
565  }
566
567  /* Remove an event made with "Add an event" (the existing calendar delete). */
568  /* Removing an event made with "Add an event" (the existing calendar delete).
569     Review 3, UX3-5, then Alex, 3 Oct 2026: *"if they cancel a meetup and
570     there's more than one person there, it gives them the offer to send them an
571     email that's actually editable… They can decide whether it goes out or
572     not."* A meetup with anyone going (one or more) opens the remove sheet with
573     that email, prefilled for this meetup (Automated emails: "Meetup
574     cancelled"), editable with the app's usual tools: the speak-it pill
575     (VoiceInput) on each box and "Ask Maya to change it" with the running list
576     of notes (createRefineLoop). Send or Don't send: the meetup is removed
577     either way (meetup-cancel.js). With nobody going it is the plain confirm. */
578  async function _deleteEvent(ctx, id, title) {
579    var ev = (_state.events || []).filter(function (x) { return String(x.id) === String(id); })[0] || null;
580    var going = ev ? (Number(ev.going_count) || 0) : 0;
581    if (ev && ev.kind === 'in_person' && going >= 1 && window.CBSheet && window.CBSheet.panel) return _cancelSheet(ctx, ev);
582    var ok = window.CBSheet && await window.CBSheet.confirm({
583      title: 'Remove ' + (title || 'this event') + '?',
584      message: 'It disappears from the space and from members\u2019 calendars. Nobody is told.',
585      confirmLabel: 'Remove the event', cancelLabel: 'Keep it', danger: true,
586    });
587    if (!ok) return;
588    try {
589      await ctx.api('/community-hosting/events/' + id, { method: 'DELETE' });
590      ctx.toast('Removed.');
591      if (ctx.reload) ctx.reload();
592    } catch (e) { ctx.toast('That did not remove. Please try again.', true); }
593  }
594
595  function _people(n) { return n === 1 ? '1 person' : n + ' people'; }
596  async function _cancelSheet(ctx, ev) {
597    var base = '/community-hosting/spaces/' + ctx.space.id + '/events/' + ev.id;
598    var d = null, loop = null, busy = false;
599    var sheet = window.CBSheet.panel({
600      title: 'Remove ' + (ev.title || 'this meetup') + '?',
601      message: 'It disappears from the space and from members\u2019 calendars.',
602      width: 640,
603      actions: [
604        { id: 'keep', label: 'Keep the meetup', onClick: function (close) { close(); } },
605        { id: 'nosend', label: 'Don\u2019t send', onClick: function (close) { go(close, false); } },
606        { id: 'send', label: 'Send', primary: true, onClick: function (close) { go(close, true); } },
607      ],
608      onMount: function (body) {
609        body.innerHTML = '<div class="empty-block" style="opacity:0.6">Loading\u2026</div>';
610        ctx.api(base + '/cancel-email').then(function (r) { d = r; draw(body); }, function () {
611          body.innerHTML = '<div class="csp-none">The email could not load. You can still remove the meetup without sending anything.</div>';
612          d = { count: 0 }; setSendLabel();
613        });
614      },
615    });
616    function setSendLabel() {
617      var b = document.querySelector('#cb-sheet [data-cbs-action="2"]');
618      if (!b) return;
619      if (d && d.count) b.textContent = 'Send to ' + _people(d.count); else b.style.display = 'none';
620    }
621    function draw(body) {
622      var who = _people(d.going) + ' said they\u2019re going' + (d.waiting ? ', and ' + _people(d.waiting) + (d.waiting === 1 ? ' is' : ' are') + ' on the waiting list' : '') + '.';
623      body.innerHTML =
624        '<p class="slr-cx-lead">' + _esc(who) + ' Would you like us to send this email to everyone who said they\u2019re going' + (d.waiting ? ' and everyone on the waiting list' : '') + '?</p>' +
625        '<label class="slr-cx-lbl" for="slr-cx-subj">Subject</label>' +
626        '<input id="slr-cx-subj" class="input slr-cx-in" data-cx-subj value="' + _esc(d.subject) + '">' +
627        '<label class="slr-cx-lbl" for="slr-cx-body">The email</label>' +
628        '<textarea id="slr-cx-body" class="textarea slr-cx-in" data-cx-body rows="9">' + _esc(d.body) + '</textarea>' +
629        '<p class="slr-cx-hint">{first_name} becomes each person\u2019s own first name. A link back to your community is added at the end.</p>' +
630        '<div class="slr-cx-maya"><label class="slr-cx-lbl" for="slr-cx-fb">\u2728 Ask Maya to change it</label>' +
631          '<textarea id="slr-cx-fb" class="textarea slr-cx-in" data-cx-fb rows="2" placeholder="For example: warmer, shorter, say we will meet again next month"></textarea>' +
632          '<button type="button" class="btn btn-secondary csp-btn" data-cx-refine>Rewrite it</button>' +
633          '<div data-cx-log style="display:none;margin-top:10px"></div></div>' +
634        '<p class="slr-cx-hint">Don\u2019t send and Send both remove the meetup. Nothing is sent unless you press Send.</p>';
635      var VI = window.VoiceInput;
636      if (VI && VI.attach) { VI.attach(body.querySelector('[data-cx-body]')); VI.attach(body.querySelector('[data-cx-fb]')); }
637      loop = typeof window.createRefineLoop === 'function' ? window.createRefineLoop({ listMount: body.querySelector('[data-cx-log]') }) : null;
638      body.querySelector('[data-cx-refine]').onclick = refine;
639      setSendLabel();
640    }
641    async function refine() {
642      var fb = document.querySelector('#cb-sheet [data-cx-fb]'), btn = document.querySelector('#cb-sheet [data-cx-refine]');
643      var note = String(fb && fb.value || '').trim();
644      if (!note) { sheet.note('Say what you would like changed first.'); return; }
645      var log = loop ? loop.push(note) : [note];
646      btn.disabled = true; btn.textContent = 'Maya is rewriting\u2026';
647      try {
648        var r = await ctx.api(base + '/cancel-email/refine', { method: 'POST', body: {
649          subject: document.querySelector('#cb-sheet [data-cx-subj]').value,
650          body: document.querySelector('#cb-sheet [data-cx-body]').value, feedback_log: log } });
651        document.querySelector('#cb-sheet [data-cx-subj]').value = r.subject;
652        document.querySelector('#cb-sheet [data-cx-body]').value = r.body;
653        fb.value = ''; sheet.note('Rewritten. Read it over before you send.');
654      } catch (e) { sheet.note((e && e.body && e.body.message) || 'Maya could not rewrite it just now. Please try again.'); }
655      btn.disabled = false; btn.textContent = 'Rewrite it';
656    }
657    async function go(close, send) {
658      if (busy) return;
659      var notify = null;
660      if (send) {
661        if (!d || !d.count) return;
662        notify = { subject: document.querySelector('#cb-sheet [data-cx-subj]').value, body: document.querySelector('#cb-sheet [data-cx-body]').value };
663      }
664      busy = true; sheet.note(send ? 'Removing it and sending\u2026' : 'Removing it\u2026');
665      try {
666        var r = await ctx.api(base + '/cancel', { method: 'POST', body: { notify: notify } });
667        close();
668        ctx.toast(r && r.sent ? 'Removed. The email is on its way to ' + _people(r.sent) + '.' : 'Removed. Nobody was emailed.');
669        if (ctx.reload) ctx.reload();
670      } catch (e) {
671        busy = false;
672        sheet.note((e && e.body && e.body.message) || 'That did not work. Please try again.');
673      }
674    }
675  }
676
677  /** The header's Go live: the live session, else the next scheduled one, else a new one now. */
678  async function goLive(ctx) {
679    try { await _fetchLive(ctx); } catch (_) {}
680    var s = _next(_state.streams);
681    if (s) return _startOrAsk(ctx, s);
682    var r;
683    try {
684      r = await ctx.api('/community-hosting/spaces/' + ctx.space.id + '/live', { method: 'POST', body: { title: ctx.space.name } });
685    } catch (e) { ctx.toast('The session could not be made. Please try again.', true); return; }
686    s = r && r.stream;
687    if (!s) { ctx.toast('The session could not be made. Please try again.', true); return; }
688    /* TK-26: this session exists only because the coach pressed Go live. If
689       the room is refused (no free minutes left, live switched off, three rooms
690       already live), it goes again, so a session they never scheduled is not
691       left waiting as the "Next live session". */
692    var started = await _startRoom(ctx, s.id);
693    if (!started) {
694      try { await ctx.api('/community-hosting/spaces/' + ctx.space.id + '/live/' + s.id, { method: 'DELETE' }); } catch (_) {}
695      if (ctx.reload) ctx.reload();
696    }
697    return started;
698  }
699
700  /* A session set for later is the real thing: going live on it now tells
701     members it is live, and ending it spends it. So when its time is more than
702     SOON_MS away (Zoom lets a host start any time; this is the point where a
703     test is likelier than the session), the coach is asked, with trying the
704     room on their own offered first. Live, due or unscheduled: straight in. */
705  var SOON_MS = 30 * 60000;
706  async function _startOrAsk(ctx, s) {
707    var at = s && s.scheduled_at ? new Date(s.scheduled_at).getTime() : NaN;
708    if (!s || s.state === 'live' || isNaN(at) || at - Date.now() <= SOON_MS || !window.CBSheet || !window.CBSheet.choose) {
709      return _startRoom(ctx, s.id);
710    }
711    var pick = await window.CBSheet.choose({
712      title: s.title + ' is ' + _when(s.scheduled_at),
713      message: 'Going live now starts it for real: members are told it is live, and it is used up when you end it.',
714      items: [
715        { value: 'try', label: 'Try the room on my own', note: 'Check your camera, sound and controls. Members cannot see it, and ' + s.title + ' stays booked.' },
716        { value: 'live', label: 'Start ' + s.title + ' now', note: 'Members can join straight away.' },
717      ],
718      confirmLabel: 'Open the room', cancelLabel: 'Not now',
719    });
720    if (pick === 'try') return tryRoom(ctx);
721    if (pick === 'live') return _startRoom(ctx, s.id);
722  }
723
724  /** Zoom's test meeting: the rehearsal room, never one of the space's sessions. */
725  async function tryRoom(ctx) {
726    var r;
727    try {
728      r = await ctx.api('/community-hosting/streams', { method: 'POST', body: { title: 'Test room: just you', test: true } });
729    } catch (e) { ctx.toast('The test room could not open. Please try again.', true); return; }
730    if (!r || !r.stream || !r.stream.id) { ctx.toast('The test room could not open. Please try again.', true); return; }
731    return _startRoom(ctx, r.stream.id, { test: true });
732  }
733
734  // ── The host's room ─────────────────────────────────────────────────────
735
736  var R = null;
737
738  /* Resolves true once the room is open, false when the start was refused. */
739  async function _startRoom(ctx, streamId, opts) {
740    if (R) { _show(); return true; }
741    var out;
742    try {
743      out = await ctx.api('/community-hosting/streams/' + streamId + '/go-live', { method: 'POST', body: {} });
744    } catch (e) {
745      var code = e && (e.error || e.message);
746      if (code === 'live_needs_studio') {
747        var go = window.CBSheet && await window.CBSheet.confirm({
748          title: 'Live is part of Studio',
749          message: 'Running live sessions, breakout rooms and 1:1 video calls comes with Studio. Everything else in your spaces stays free.',
750          confirmLabel: 'See Studio', cancelLabel: 'Not now',
751        });
752        if (go) openStudio();
753        return;
754      }
755      /* Alex on dev9 with live switched off saw "try again in a few minutes":
756         when the site has live calls switched off, waiting will not help, so
757         say what is true (the route sends switched_off). */
758      if (code === 'streaming_unavailable') {
759        ctx.toast(e && e.body && e.body.switched_off
760          ? 'Live calls are switched off on this site, so a call can\'t start here.'
761          : 'Live is not available right now. Please try again in a few minutes.', true);
762        return;
763      }
764      if (code === 'live_minutes_used') {
765        var b = (e && e.body) || {};
766        var up = window.CBSheet && await window.CBSheet.confirm({
767          title: 'This month\u2019s free minutes are used up',
768          message: 'You\u2019ve used all ' + (b.monthly_minutes || 'your') + ' free live minutes for this month, so a call can\u2019t start now. ' +
769            'They start again on ' + _resetWords(b.resets_on) + '. Upgrade to Studio, our paid plan, for more and you can go live straight away.',
770          confirmLabel: 'Upgrade for more', cancelLabel: 'Not now',
771        });
772        if (up) openStudio();
773        return;
774      }
775      if (code === 'too_many_live') { ctx.toast('Three rooms are already live in your community. End one first.', true); return; }
776      if (e && e.body && e.body.message) { ctx.toast(e.body.message, true); return; }   // R9-LiveServer, LIVE-12: another free call holds the minutes (live_minutes_held) or is starting (live_busy): the server's plain sentence
777      ctx.toast('You could not go live. Please try again.', true); return;
778    }
779    R = {
780      ctx: ctx, id: streamId, stream: out.stream,
781      title: opts && opts.call ? '1:1 with ' + opts.call.name : ((out.stream && out.stream.title) || ctx.space.name),
782      startedAt: out.stream && out.stream.started_at ? new Date(out.stream.started_at).getTime() : Date.now(),
783      tab: 'chat', after: 0, messages: [], env: {}, cam: true, mic: true, screen: false, bg: 'off',
784      split: 'random', size: '2', minutes: '10', share: 'yes', choose: {}, ended: false,
785      test: !!(opts && opts.test),
786      // R12-SLOTS: a 1:1 call after a session, { sessionId, slotId, name }: its end goes back to the queue.
787      call: (opts && opts.call) || null,
788      private: !!(out.stream && out.stream.is_private),   // a 1:1 or booked call: never posted
789      // R4-16: 'gallery' (Zoom Meetings: everyone can be seen) or 'stage' (Zoom Webinar).
790      layout: (out.stream && out.stream.layout) || 'stage',
791      galleryMax: (out.stream && out.stream.gallery_max) || 25,
792    };
793    _renderRoom();
794    _poll();   // TK-43: the room's limits arrive now, not after the camera and the media connection
795    _load(GALLERY_SRC, 'LiveGallery').then(_galleryCount, function () { /* the stage's own grid stays */ });
796    try {
797      var LK = await _load(LK_SRC, 'LivekitClient');
798      R.LK = LK;
799      R.room = new LK.Room({ adaptiveStream: true, dynacast: true });
800      R.room.on(LK.RoomEvent.TrackSubscribed, function (track, pub, p) { _guestTrack(track, p, true); });
801      R.room.on(LK.RoomEvent.TrackUnsubscribed, function (track, pub, p) { _guestTrack(track, p, false); });
802      R.room.on(LK.RoomEvent.ParticipantDisconnected, function (p) { _guestGone(p); });
803      /* R4-16: in a gallery every person in the room has a tile, camera on or
804         not (their name and initial when it is off), as Zoom's gallery does. */
805      R.room.on(LK.RoomEvent.ParticipantConnected, function (p) { if (R && R.layout === 'gallery') _ensureTile(p); });
806      R.room.on(LK.RoomEvent.TrackUnpublished, function () { if (R && R.layout !== 'gallery') _galleryTiles(); });
807      R.room.on(LK.RoomEvent.TrackMuted, function (pub, p) { if (pub && pub.kind === 'video') _tileVideo(p, false); });
808      R.room.on(LK.RoomEvent.TrackUnmuted, function (pub, p) { if (pub && pub.kind === 'video') _tileVideo(p, true); });
809      await R.room.connect(out.token.url, out.token.token);
810      _galleryTiles();
811      await R.room.localParticipant.enableCameraAndMicrophone();
812      _attachSelf();
813    } catch (err) {
814      console.error('[space-live] joining the room failed —', err && err.message);
815      _banner('Your camera or microphone did not start. Check the browser’s permission and press Camera to try again.', 'warn');
816    }
817    // The Studio sheet's price (TK-15) for a room opened from somewhere the Live tab was not read.
818    if (!_state.allowance) { ctx.api('/community-hosting/live-allowance').then(function (r) { if (r && r.allowance) _state.allowance = r.allowance; }, function () {}); }
819    _poll();
820    R.timer = setInterval(_poll, POLL_MS);
821    R.tick = setInterval(_tickClock, 1000);
822    return true;
823  }
824
825  function _show() { var el = document.getElementById('slr-room'); if (el) el.hidden = false; }
826
827  function _renderRoom() {
828    var old = document.getElementById('slr-room'); if (old) old.remove();
829    var el = document.createElement('div');
830    el.id = 'slr-room';
831    el.className = 'slr-room';
832    el.setAttribute('role', 'dialog');
833    // A modal said in markup: support-widget.js sees [aria-modal="true"] and sets
834    // cb-modal-open, which drops Help and the account-manager rail under the room.
835    el.setAttribute('aria-modal', 'true');
836    el.setAttribute('aria-label', 'Live room');
837    el.innerHTML =
838      '<div class="slr-main">' +
839        '<div class="slr-top"><span class="slr-livepill" id="slr-clock">' + (R.test ? 'TEST' : 'LIVE') + ' · 0:00</span>' +
840          '<div class="slr-title">' + _esc(R.test ? 'Trying the room' : R.title) + '</div>' +
841          '<span class="slr-sub" id="slr-sub">' + (R.test ? 'Only you. Members cannot see or join this room, and your sessions stay as they are.' : _esc(R.ctx.space.name)) + '</span>' +
842          '<span class="slr-limit" id="slr-limit" hidden></span></div>' +
843        '<div class="slr-pool" id="slr-pool" role="status" hidden><span id="slr-pool-t"></span>' +
844          '<button type="button" class="slr-pool-btn" data-slr-studio>Upgrade for more</button></div>' +
845        '<div class="slr-warn" id="slr-warn" hidden></div>' +
846        '<div class="slr-stage' + (R.layout === 'gallery' ? ' slr-gallery' : '') + '" id="slr-stage" data-n="1">' +
847          '<div class="slr-tile slr-host" id="slr-self"><video id="slr-self-video" autoplay playsinline muted></video>' +
848            '<span class="slr-name" id="slr-self-name" data-lg-nm>You</span>' +
849            '<div class="slr-bo-banner" id="slr-bo-banner" hidden></div></div>' +
850          '<div class="slr-guests" id="slr-guests"></div>' +
851          '<div class="slr-inroom" id="slr-inroom" hidden></div>' +
852          '<div class="slr-calls-pill" id="slr-calls-pill" role="status" hidden></div>' +
853        '</div>' +
854        '<div class="slr-calls-late" id="slr-calls-late" role="status" hidden></div>' +
855        '<div class="slr-hands" id="slr-hands" hidden></div>' +
856        '<div class="slr-controls">' +
857          '<button type="button" class="slr-ctl" data-slr-ctl="mic">' + _ic('mic') + ' <span>Mute</span></button>' +
858          '<button type="button" class="slr-ctl" data-slr-ctl="cam">' + _ic('cam') + ' <span>Camera</span></button>' +
859          '<button type="button" class="slr-ctl" data-slr-ctl="bg">' + _ic('bg') + ' <span>Background</span></button>' +
860          '<button type="button" class="slr-ctl" data-slr-ctl="screen">' + _ic('screen') + ' <span>Share screen</span></button>' +
861          (R.test ? '' : '<button type="button" class="slr-ctl" data-slr-ctl="layout" id="slr-layout-btn">' + _ic('people') + ' <span>' + _layoutLabel() + '</span></button>') +
862          '<button type="button" class="slr-ctl slr-pri" data-slr-ctl="breakouts">' + _ic('rooms') + ' <span>Breakouts</span></button>' +
863          (R.test || R.call ? '' : '<button type="button" class="slr-ctl" data-slr-ctl="slots">' + _ic('clock') + ' <span>1:1 calls</span></button>') +
864          '<button type="button" class="slr-ctl slr-end" data-slr-ctl="end">End</button>' +
865        '</div>' +
866      '</div>' +
867      '<aside class="slr-panel" aria-label="Host panel">' +
868        '<div class="slr-tabs" role="tablist" id="slr-tabs"></div>' +
869        '<div class="slr-pane" id="slr-pane"></div>' +
870      '</aside>';
871    document.body.appendChild(el);
872    document.body.classList.add('slr-open');
873    el.addEventListener('click', _onClick);
874    el.addEventListener('keydown', function (e) {
875      if (e.key === 'Enter' && e.target && e.target.id === 'slr-chat-in') { e.preventDefault(); _sendChat(); }
876      if (e.key === 'Enter' && e.target && e.target.id === 'slr-bo-msg') { e.preventDefault(); _boMessage(); }
877    });
878    el.addEventListener('change', function (e) {
879      var t = e.target;
880      if (t.id === 'slr-bo-min') R.minutes = t.value;
881      if (t.id === 'slr-bo-share') R.share = t.value;
882      if (t.id === 'slr-bo-size') { R.size = t.value; if (R.split === 'choose') _renderChoose(); }
883      if (t.hasAttribute && t.hasAttribute('data-slr-pick')) { R.choose[t.getAttribute('data-slr-pick')] = t.value; _renderChoose(); }
884      if (/^slr-sl-/.test(t.id || '')) { _keepSlotsForm(); _renderPane(); }
885    });
886    _renderTabs(); _renderPane();
887  }
888
889  /* The room the host's own camera and microphone are in right now: a breakout
890     they are visiting, else the main room (LIVE-3). R.mic and R.cam are the
891     host's real state THERE, and the Mute and Camera buttons say so. */
892  function _hostRoom() { return (R && R.inRoom && R.inRoom.room) || (R && R.room) || null; }
893  function _attachSelf() {
894    var v = document.getElementById('slr-self-video'); var room = _hostRoom(); if (!v || !room) return;
895    var pub = room.localParticipant.getTrackPublication(R.LK.Track.Source.Camera);
896    if (pub && pub.track) { try { pub.track.attach(v); } catch (_) {} }
897    _selfName();
898  }
899  /* The Mute and Camera buttons drawn from R.mic and R.cam, never the other way round. */
900  function _ctlSync() {
901    var el = document.getElementById('slr-room'); if (!el || !R) return;
902    var mic = el.querySelector('[data-slr-ctl="mic"]'), cam = el.querySelector('[data-slr-ctl="cam"]');
903    if (mic) { mic.classList.toggle('off', !R.mic); mic.querySelector('span').textContent = R.mic ? 'Mute' : 'Unmute'; }
904    if (cam) { cam.classList.toggle('off', !R.cam); cam.querySelector('span').textContent = R.cam ? 'Camera' : 'Camera off'; }
905    _selfName();
906  }
907  function _selfName() {
908    var n = document.getElementById('slr-self-name'); if (!n) return;
909    var bits = ['You'];
910    if (!R.mic) bits.push('muted');
911    if (R.bg === 'blur') bits.push('background blurred');
912    if (R.bg === 'picture') bits.push('your background');
913    if (R.screen) bits.push('sharing screen');
914    n.textContent = bits.join(' · ');
915  }
916
917  /* A guest on stage is named as the host's hands list names them (member-name.js
918     forHost: their name, else their email), matched on the stage identity the
919     hand row keeps. The token's own name is what other members see. */
920  function _guestName(identity, fallback) {
921    var h = ((R && R.env && R.env.hands) || []).filter(function (x) { return identity && x.participant_id === identity; })[0];
922    return (h && h.name) || fallback || 'Guest';
923  }
924  function _nameTiles() {
925    Array.prototype.forEach.call(document.querySelectorAll('#slr-guests .slr-tile[data-identity]'), function (t) {
926      var n = t.querySelector('.slr-name'); if (!n) return;
927      var name = _guestName(t.getAttribute('data-identity'), n.textContent);
928      if (n.textContent !== name) n.textContent = name;
929    });
930  }
931
932  function _tileId(p) { return 'slr-g-' + String((p && p.identity) || 'x').replace(/[^a-z0-9-]/gi, ''); }
933  /* One tile per person: their name, their initial while the camera is off. */
934  function _ensureTile(p) {
935    var box = document.getElementById('slr-guests'); if (!box || !p) return null;
936    if (/^EG_/.test(String(p.identity || ''))) return null;   // the recorder is not a person
937    var tile = document.getElementById(_tileId(p));
938    if (!tile) {
939      var name = _guestName(p.identity, p.name);
940      tile = document.createElement('div'); tile.id = _tileId(p); tile.className = 'slr-tile';
941      tile.setAttribute('data-identity', String(p.identity || ''));
942      tile.innerHTML = '<span class="slr-ini" data-lg-ini aria-hidden="true">' + _esc(String(name || '?').trim().charAt(0).toUpperCase() || '?') + '</span>' +
943        '<span class="slr-name" data-lg-nm>' + _esc(name) + '</span>';
944      box.appendChild(tile);
945    }
946    box.classList.toggle('has', box.children.length > 0);
947    _galleryCount();
948    return tile;
949  }
950  function _tileVideo(p, on) {
951    var tile = document.getElementById(_tileId(p)); if (tile) tile.classList.toggle('has-video', !!on);
952  }
953  function _guestTrack(track, p, add) {
954    var box = document.getElementById('slr-guests'); if (!box) return;
955    var tile = document.getElementById(_tileId(p));
956    if (add) {
957      tile = tile || _ensureTile(p);
958      if (!tile) return;
959      if (track.kind === 'video') {
960        var old = tile.querySelector('video'); if (old) old.remove();
961        var v = document.createElement('video'); v.autoplay = true; v.playsInline = true; v.muted = true;
962        tile.insertBefore(v, tile.firstChild);
963        try { track.attach(v); } catch (_) {}
964        tile.classList.toggle('has-video', !track.isMuted);
965      } else if (track.kind === 'audio') {
966        try { var a = track.attach(); a.setAttribute('data-slr-audio', '1'); tile.appendChild(a); } catch (_) {}
967      }
968    } else if (tile) {
969      try { track.detach().forEach(function (x) { x.remove(); }); } catch (_) {}
970      if (track.kind === 'video') tile.classList.remove('has-video');
971      // In the stage room a tile is a person on stage; with nothing left to show, it goes.
972      if (R && R.layout !== 'gallery' && !tile.querySelector('video,audio')) tile.remove();
973    }
974    box.classList.toggle('has', box.children.length > 0);
975    _galleryCount();
976  }
977  function _guestGone(p) {
978    var el = document.getElementById(_tileId(p));
979    if (el) el.remove();
980    _galleryCount();
981  }
982  /* The gallery's grid follows how many are in it (Zoom: 2 side by side, 4 in a
983     square, then rows). */
984  function _galleryCount() {
985    var st = document.getElementById('slr-stage'); var box = document.getElementById('slr-guests');
986    if (!st || !box) return;
987    var n = box.children.length + 1;
988    st.setAttribute('data-n', String(n > 9 ? 'many' : n));
989    _galleryLayout(st);
990  }
991  /* Zoom's gallery and Pin, from the one shared layout (live-gallery.js): every
992     tile the same 16:9 size, a short last row centred, and a click or tap on
993     anybody (you too) makes them the main picture until "Back to gallery".
994     Only on the host's own screen. */
995  function _galleryLayout(st) {
996    if (!st || !window.LiveGallery) return;
997    window.LiveGallery.attach(st, {
998      tiles: function () {
999        var self = document.getElementById('slr-self');
1000        return (self ? [self] : []).concat(Array.prototype.slice.call(document.querySelectorAll('#slr-guests > .slr-tile')));
1001      },
1002      idOf: function (el) { return el.id; },
1003      nameOf: function (el) { return el.id === 'slr-self' ? 'yourself' : ((el.querySelector('.slr-name') || {}).textContent || ''); },
1004      label: function () { return false; },   // every host tile carries its own name
1005      gap: 8
1006    });
1007  }
1008  /* Every remote person gets a tile in a gallery; in a stage room only those with
1009     something on screen keep one. Called on connect and when the layout changes. */
1010  function _galleryTiles() {
1011    if (!R || !R.room) return;
1012    var st = document.getElementById('slr-stage');
1013    if (st) st.classList.toggle('slr-gallery', R.layout === 'gallery');
1014    var people = [];
1015    try { R.room.remoteParticipants.forEach(function (p) { people.push(p); }); } catch (_) {}
1016    people.forEach(function (p) {
1017      if (R.layout === 'gallery') _ensureTile(p);
1018      else {
1019        var t = document.getElementById(_tileId(p));
1020        var pubs = 0; try { pubs = p.trackPublications ? p.trackPublications.size : 0; } catch (_) {}
1021        if (t && (!pubs || !t.querySelector('video,audio'))) t.remove();
1022      }
1023    });
1024    var box = document.getElementById('slr-guests'); if (box) box.classList.toggle('has', box.children.length > 0);
1025    _galleryCount();
1026  }
1027  function _layoutLabel() { return R && R.layout === 'gallery' ? 'Everyone on screen' : 'Only you on screen'; }
1028  /* R4-16 — Zoom Meetings or Zoom Webinar, the host's choice, while live. */
1029  async function _chooseLayout() {
1030    if (!window.CBSheet || !window.CBSheet.choose) return;
1031    var pick = await window.CBSheet.choose({
1032      title: 'Who is on screen?',
1033      message: 'You can change this at any time. Nobody has to leave or join again.',
1034      selected: R.layout,
1035      items: [
1036        { value: 'gallery', label: 'Everyone, like a Zoom meeting', note: 'Members can turn on their own camera and microphone (both start off), and you see everyone. Best for small groups, up to ' + R.galleryMax + ' people.' },
1037        { value: 'stage', label: 'Only you, like a talk', note: 'Members watch and listen. They raise a hand to speak, and you choose who comes on screen.' },
1038      ],
1039      confirmLabel: 'Use this', cancelLabel: 'Keep it as it is',
1040    });
1041    if (!pick || !R || pick === R.layout) return;
1042    try {
1043      var r = await R.ctx.api('/community-hosting/streams/' + R.id + '/layout', { method: 'POST', body: { layout: pick } });
1044      R.layout = (r && r.stream && r.stream.layout) || pick;
1045      _galleryTiles();
1046      // LiveKit unpublishes a member's camera a moment after the permission changes; tidy again then.
1047      setTimeout(_galleryTiles, 2500);
1048      var b = document.getElementById('slr-layout-btn'); if (b) { var sp = b.querySelector('span'); if (sp) sp.textContent = _layoutLabel(); }
1049      R.ctx.toast(R.layout === 'gallery' ? 'Everyone can now turn on their camera and microphone.' : 'Only you are on screen now. Members can raise a hand.');
1050    } catch (e) { R.ctx.toast('That did not change. Please try again.', true); }
1051  }
1052
1053  function _banner(text, kind) {
1054    var w = document.getElementById('slr-warn'); if (!w) return;
1055    if (!text) { w.hidden = true; w.textContent = ''; return; }
1056    w.hidden = false; w.className = 'slr-warn ' + (kind || '');
1057    w.innerHTML = text;
1058  }
1059
1060  // ── The poll: one request carries everything ───────────────────────────
1061
1062  async function _poll() {
1063    if (!R) return;
1064    // A poll asked for while one is in flight (Send, an answer, a hand) runs
1065    // straight after it, so what the coach just did shows without waiting 2.5 s.
1066    if (R.polling) { R.pollAgain = true; return; }
1067    R.polling = true; R.pollAgain = false;
1068    try {
1069      var env = await R.ctx.api('/community-hosting/streams/' + R.id + '/chat?after=' + R.after);
1070      if (!R) return;
1071      R.env = env || {};
1072      if (env && env.clock) R.lastClock = env.clock;   // an ended room has none; the end screen reads the last
1073      var msgs = (env && env.messages) || [];
1074      if (msgs.length) { R.messages = R.messages.concat(msgs).slice(-200); R.after = msgs[msgs.length - 1].id; }
1075      if (env && env.state === 'ended') return _ended(env.end_reason || null);
1076      // LIVE-3: the host's breakout is no longer open (its timer ran out): back in the main room.
1077      if (R.inRoom && !((env && env.breakouts && env.breakouts.rooms) || []).some(function (r) { return String(r.id) === String(R.inRoom.id); })) _boLeave();
1078      _renderHandsBar(); _renderBreakoutBanner(); _renderTabs(); _nameTiles(); _renderCallsBits();
1079      // A setup form (breakouts or slots not running yet) is left alone by the
1080      // poll, so a coach's choices are never reset under their finger; a running
1081      // list refreshes unless they are typing in it.
1082      var pn = document.getElementById('slr-pane');
1083      var editing = !!(pn && document.activeElement && pn.contains(document.activeElement));
1084      var cs = _calls();
1085      var slotsOpen = !!(cs && cs.offer && (cs.offer.open || cs.booked));
1086      var setupForm = (R.tab === 'breakouts' && !R.env.breakouts) || (R.tab === 'slots' && !slotsOpen);
1087      // Chat always redraws (TK-1): its renderer redraws only the message list and
1088      // leaves the box being typed in alone, so messages appear while the coach types.
1089      if (R.tab === 'chat' || (!editing && !(setupForm && R.formShown === R.tab))) _renderPane();
1090      R.formShown = setupForm ? R.tab : null;
1091      var sub = document.getElementById('slr-sub');
1092      if (sub && env.attendance && !R.test) {   // a test room keeps saying it is only you
1093        sub.textContent = R.ctx.space.name + ' · ' + env.attendance.here + ' here' +
1094          (env.attendance.limit_participants ? ' of ' + env.attendance.limit_participants : '');
1095      }
1096    } catch (e) {
1097      if (/stream_not_found/.test(String(e && (e.error || e.message)))) return _ended();
1098    } finally {
1099      if (R) { R.polling = false; if (R.pollAgain) { R.pollAgain = false; _poll(); } }
1100    }
1101  }
1102
1103  function _tickClock() {
1104    if (!R) return;
1105    var c = document.getElementById('slr-clock');
1106    if (c) c.textContent = (R.test ? 'TEST · ' : 'LIVE · ') + _mmss(Date.now() - R.startedAt);
1107    var clock = R.env && R.env.clock;
1108    var lim = document.getElementById('slr-limit');
1109    var poolEl = document.getElementById('slr-pool');
1110    if (clock && clock.limited && clock.ends_at) {
1111      var left = new Date(clock.ends_at).getTime() - Date.now();
1112      /* TK-30: a free room says its minutes in ONE place, the pool line, which
1113         turns amber and changes its words as the end comes. The "Ends in"
1114         chip is for a room with a limit and no pool (a rehearsal). */
1115      if (lim) { lim.hidden = !!clock.pool_minutes; lim.textContent = 'Ends in ' + _mmss(left); }
1116      if (clock.pool_minutes) {
1117        /* Alex, 2 Oct 2026: the free plan's monthly minutes, on screen the whole
1118           time a free coach broadcasts, falling as the call runs. The room's end
1119           IS the pool running out (stream-service.goLive). */
1120        var W = window.LiveAllowanceWords;
1121        var leftMin = Math.max(0, Math.ceil(left / 60000));
1122        var stage = left <= 0 ? 'zero' : (left <= 60000 ? 'one' : (left <= 300000 ? 'five' : 'calm'));
1123        var t;
1124        if (stage === 'calm') {
1125          t = W && W.poolWords ? W.poolWords({ mode: 'limited', monthly_minutes: clock.pool_minutes }, { left: leftMin }).summary
1126            : (leftMin + ' free minutes left this month.');
1127        } else if (stage === 'five') {
1128          /* The real count: a call that starts with less than 5 minutes left says so (2, not 5). */
1129          t = (leftMin === 1 ? '1 minute' : leftMin + ' minutes') + ' of this month\u2019s free live time left. The call ends when it runs out, and the recording is kept.';
1130        } else if (stage === 'one') {
1131          t = 'The call ends in ' + _mmss(left) + ' because this month\u2019s free minutes run out. Say goodbye to everyone now.';
1132        } else {
1133          t = 'This month\u2019s free live minutes have run out, so the call is ending now. The recording is kept.';
1134        }
1135        if (poolEl) {
1136          poolEl.hidden = false;
1137          poolEl.classList.toggle('slr-pool-warn', stage !== 'calm');
1138          /* A countdown read aloud every second is noise: the line is announced
1139             when its meaning changes, not when its seconds do. */
1140          if (R.poolStage !== stage) { R.poolStage = stage; poolEl.setAttribute('aria-live', 'polite'); }
1141          else poolEl.setAttribute('aria-live', 'off');
1142          var pt = document.getElementById('slr-pool-t'); if (pt && pt.textContent !== t) pt.textContent = t;
1143          var pb = poolEl.querySelector('.slr-pool-btn'); if (pb) pb.hidden = stage === 'zero';
1144        }
1145        /* TK-30: the call ends when the minutes do, not up to a minute later
1146           when the server's sweep comes round. The server decides the reason
1147           (endStream: a free room past its clock ends as 'limit'), so the end
1148           screen still says the minutes ran out. */
1149        if (stage === 'zero' && !R.endingAtZero && !R.test) {
1150          R.endingAtZero = true;
1151          R.ctx.api('/community-hosting/streams/' + R.id + '/end', { method: 'POST', body: {} })
1152            .then(function () { _poll(); }, function () { /* the sweep ends it within the minute */ });
1153        }
1154      } else if (left <= 120000 && left > 0 && !R.warned) {
1155        R.warned = true;   // a rehearsal's own short limit
1156        _banner('Two minutes left in this practice room. It closes by itself, and you can open it again any time.', 'w
1156arn');
1157      }
1158    } else if (lim) { lim.hidden = true; }
1159    if (poolEl && !(clock && clock.pool_minutes)) poolEl.hidden = true;
1160    var bo = R.env && R.env.breakouts;
1161    var bt = document.getElementById('slr-bo-left');
1162    if (bo && bt && bo.ends_at) bt.textContent = _mmss(new Date(bo.ends_at).getTime() - Date.now()) + ' left';
1163  }
1164
1165  function _renderTabs() {
1166    var el = document.getElementById('slr-tabs'); if (!el || !R) return;
1167    var hands = ((R.env && R.env.hands) || []).filter(function (h) { return h.state === 'raised'; }).length;
1168    var qs = ((R.env && R.env.questions) || []).filter(function (q) { return !q.answered; }).length;
1169    var help = (R.env && R.env.breakouts && R.env.breakouts.help && R.env.breakouts.help.length) || 0;
1170    var tabs = [
1171      { id: 'chat', label: 'Chat' },
1172      { id: 'qa', label: 'Q&A', n: qs },
1173      { id: 'hands', label: 'Hands', n: hands },
1174      { id: 'breakouts', label: 'Breakouts', n: help },
1175      // A test room offers no real calls; a 1:1 call's own room offers none either.
1176      R.test || R.call ? null : { id: 'slots', label: '1:1 calls', n: (_calls() && _calls().offer) ? _calls().booked : 0 },
1177    ].filter(Boolean);
1178    el.innerHTML = tabs.map(function (t) {
1179      return '<button type="button" role="tab" class="slr-tab' + (t.id === R.tab ? ' on' : '') + '" aria-selected="' + (t.id === R.tab) + '" data-slr-tab="' + t.id + '">' +
1180        _esc(t.label) + (t.n ? ' <span class="n">' + t.n + '</span>' : '') + '</button>';
1181    }).join('');
1182  }
1183
1184  function _renderPane() {
1185    var el = document.getElementById('slr-pane'); if (!el || !R) return;
1186    var typed = document.getElementById('slr-chat-in');   // half-written chat survives a look at another tab
1187    if (typed && R.tab !== 'chat' && el.contains(typed)) R.chatDraft = typed.value;
1188    if (R.tab === 'chat') {
1189      var msgsHtml = R.messages.length ? R.messages.map(function (m) {
1190        return '<div class="slr-msg"><b class="' + (m.author_is_coach ? 'slr-me' : '') + '">' + _esc(m.author_name || 'Member') + '</b> ' + _esc(m.body) + '</div>';
1191      }).join('') : '<div class="slr-none">Messages from the room appear here.</div>';
1192      var list = document.getElementById('slr-chat');
1193      // TK-1: when the chat is already on screen only the list is redrawn, so the
1194      // box (what is typed, the cursor, the focus) is never touched by the poll.
1195      if (list && el.contains(list) && document.getElementById('slr-chat-in')) {
1196        var atEnd = list.scrollHeight - list.scrollTop - list.clientHeight < 40;
1197        if (list.innerHTML !== msgsHtml) { list.innerHTML = msgsHtml; if (atEnd || R.chatJumped) list.scrollTop = list.scrollHeight; }
1198        R.chatJumped = false;
1199        return;
1200      }
1201      el.innerHTML = '<div class="slr-chat" id="slr-chat">' + msgsHtml + '</div>' +
1202      '<div class="slr-compose"><input id="slr-chat-in" type="text" maxlength="500" placeholder="Write to everyone" aria-label="Write to everyone">' +
1203        '<button type="button" class="btn btn-primary" data-slr-send>Send</button></div>';
1204      var inp = document.getElementById('slr-chat-in');
1205      if (inp && R.chatDraft) { inp.value = R.chatDraft; R.chatDraft = ''; }
1206      list = document.getElementById('slr-chat'); if (list) list.scrollTop = list.scrollHeight;
1207      return;
1208    }
1209    if (R.tab === 'qa') {
1210      var qs = (R.env && R.env.questions) || [];
1211      el.innerHTML = qs.length ? qs.map(function (q) {
1212        return '<div class="slr-row2"><div class="slr-grow"><div class="slr-rt">' + _esc(q.body) + '</div>' +
1213          '<div class="slr-rm">' + _esc(q.author_name) + ' · ' + (q.upvotes || 0) + ' up</div></div>' +
1214          (q.answered ? '<span class="pill p-good">Answered</span>' : '<button type="button" class="btn btn-secondary cpg-btn-tight" data-slr-answer="' + q.id + '">Answered</button>') + '</div>';
1215      }).join('') : '<div class="slr-none">Questions members ask appear here, most wanted first.</div>';
1216      return;
1217    }
1218    if (R.tab === 'hands') {
1219      var hs = (R.env && R.env.hands) || [];
1220      var guests = ((R.env && R.env.publishers) || 1) - 1;
1221      /* TK-43: no count until the room's own limit has arrived (it read "0 of 11",
1222         the default, then "0 of 10"); the server still refuses a full stage. */
1223      var max = R.env && R.env.max_publishers > 0 ? R.env.max_publishers - 1 : null;
1224      el.innerHTML = (max != null ? '<div class="slr-rm" style="margin-bottom:8px">On stage: ' + guests + ' of ' + max + '</div>' : '') + (hs.length ? hs.map(function (h) {
1225        var act = '';
1226        if (h.state === 'raised') act = max != null && guests >= max ? '<span class="pill p-mut">Stage full</span>' : '<button type="button" class="btn btn-primary cpg-btn-tight" data-slr-hand="invite" data-cid="' + h.contact_id + '">Invite to speak</button>';
1227        else if (h.state === 'invited') act = '<span class="pill p-acc">Invited</span><button type="button" class="btn btn-secondary cpg-btn-tight" data-slr-hand="remove" data-cid="' + h.contact_id + '">Cancel</button>';
1228        else if (h.state === 'on_stage') act = '<button type="button" class="btn btn-secondary cpg-btn-tight" data-slr-hand="mute" data-cid="' + h.contact_id + '">Mute</button>' +
1229          '<button type="button" class="btn btn-secondary cpg-btn-tight" data-slr-hand="remove" data-cid="' + h.contact_id + '">Remove</button>';
1230        return '<div class="slr-row2"><div class="slr-grow"><div class="slr-rt">' + _esc(h.name) + '</div>' +
1231          '<div class="slr-rm">' + (h.state === 'raised' ? 'Hand up' : (h.state === 'invited' ? 'Deciding' : 'On stage')) + '</div></div>' + act + '</div>';
1232      }).join('') : '<div class="slr-none">When a member raises a hand, it shows here. Invite them and they choose mic on, and camera on or off.</div>');
1233      return;
1234    }
1235    if (R.tab === 'breakouts') return _paneBreakouts(el);
1236    if (R.tab === 'slots') return _paneSlots(el);
1237  }
1238
1239  // ── 1:1 calls straight after the session (R12-SLOTS) ────────────────────
1240  // Approved mockup: scratchpad slots-ux/mockup.html (Alex, 3 Oct 2026), steps 1
1241  // to 5 and the edge states. booking-slots-service decides everything (when the
1242  // calls are, who may book, who is here); this draws it. The poll's `calls` is
1243  // the queue: { offer, calls[{ slot_id, starts_at, room_id, room_state,
1244  // people[{ booking_id, name, presence }] }], booked, first_call_at, planned }.
1245  function _calls() { var c = (R && R.env && R.env.calls) || null; if (c && c.offer) _lastQueue = c; return c; }
1246  var _lastQueue = null;   // the last queue seen (the session's poll, or the queue screen's), for a call's End question
1247  function _hm(iso) {
1248    var d = new Date(iso); if (isNaN(d.getTime())) return '';
1249    return d.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }).replace(/\s?([AP])\.?M\.?$/i, function (_, x) { return x.toLowerCase() + 'm'; });
1250  }
1251  function _ord(n) { var s = ['th', 'st', 'nd', 'rd'], v = n % 100; return n + (s[(v - 20) % 10] || s[v] || s[0]); }
1252  function _first(n) { return String(n || '').trim().split(/\s+/)[0] || 'them'; }
1253  function _the(topic) { return /^the\s/i.test(topic) ? topic : 'the ' + topic; }
1254  /* The line the coach says to the room, written around what the calls are for. */
1255  function _sayLine(topic, stayed) {
1256    var tail = stayed ? ' Stay to the end, then tap Book on your screen.' : ' Tap Book on your screen.';
1257    return (topic
1258      ? 'Anyone want to talk through ' + _the(topic) + ' in detail afterwards? I’ve opened a few calls for straight after this.'
1259      : 'Anyone want to talk through what we covered today, one to one? I’ve opened a few calls for straight after this.') + tail;
1260  }
1261  function _presencePill(p) {
1262    if (p === 'here') return '<span class="pill p-good">Here</span>';
1263    if (p === 'left') return '<span class="pill p-mut">Left the room</span>';
1264    return '<span class="pill p-warn">Not here yet</span>';
1265  }
1266  function _ini(name) { return '<span class="slr-av" aria-hidden="true">' + _esc((String(name || '?').trim()[0] || '?').toUpperCase()) + '</span>'; }
1267  function _places(c) { return (c.offer.slots || 0) * (c.offer.capacity || 1); }
1268  /* Calls still to come (their room has not ended), with somebody on them. */
1269  function _toCome(c) {
1270    return ((c && c.calls) || []).filter(function (x) { return x.people.length && x.room_state !== 'ended'; });
1271  }
1272
1273  function _paneSlots(el) {
1274    var c = _calls();
1275    if (c && c.offer && (c.offer.open || c.booked)) return _paneCallsOpen(el, c);
1276    R.slots = R.slots || { count: '6', minutes: '10', capacity: '1', stayed: false, more: false };
1277    if (!R.products) {
1278      R.products = 'loading';
1279      R.ctx.api('/products').then(function (r) { R.products = (r && r.products) || []; if (R && R.tab === 'slots') _renderPane(); },
1280        function () { R.products = []; if (R && R.tab === 'slots') _renderPane(); });
1281    }
1282    var prods = Array.isArray(R.products) ? R.products.slice(0, 4) : [];
1283    if (R.slots.topic === undefined && prods.length) R.slots.topic = prods[0].name;
1284    el.innerHTML = _callsFormHtml(c, R.slots, R.products);
1285  }
1286  /* The "1:1 calls" tab before anything is offered (mockup step 1). Pure: (queue, the coach's choices, her products). */
1287  function _callsFormHtml(c, form, products) {
1288    var prods = Array.isArray(products) ? products.slice(0, 4) : [];
1289    var topic = form.topic || '';
1290    var chip = function (label, value) {
1291      return '<button type="button" class="slr-chip' + (value === topic ? ' on' : '') + '" data-slr-topic="' + _esc(value) + '" aria-pressed="' + (value === topic) + '">' + _esc(label) + '</button>';
1292    };
1293    var n = +form.count, cap = +form.capacity;
1294    var when = c && c.planned && c.first_call_at ? 'from <b>' + _esc(_hm(c.first_call_at)) + '</b> when this session ends'
1295      : 'starting the moment this session ends (each call gets its time when you end the session)';
1296    var sel = function (id, list, cur, unit) {
1297      return '<select id="' + id + '">' + list.map(function (v) { return '<option value="' + v + '"' + (String(v) === String(cur) ? ' selected' : '') + '>' + v + (unit ? ' ' + unit : '') + '</option>'; }).join('') + '</select>';
1298    };
1299    return '<div class="slr-calls">' +
1300      '<p class="slr-ct slr-ct-big">Turn this energy into 1:1 calls</p>' +
1301      '<p class="slr-big">People book a short call with you for straight after this session, without leaving the room.</p>' +
1302      '<div><span class="slr-lbl">What the calls are about</span><div class="slr-chips2">' +
1303        prods.map(function (p) { return chip(p.name, p.name); }).join('') + chip('Just a chat', '') + '</div>' +
1304        (Array.isArray(products) && !products.length ? '<a class="slr-link" href="/?page=products" target="_blank" rel="noopener">Add your programme as a product</a>' : '') + '</div>' +
1305      '<div class="slr-sum" data-slr-sum><b>' + n + ' calls, ' + form.minutes + ' minutes each</b>, ' + (cap === 1 ? 'one person each' : 'up to ' + cap + ' people each') + ', ' + when + '.' +
1306        (form.stayed ? ' Only people still here at the end can book.' : '') +
1307        '<br><a href="#" class="slr-link" data-slr-more>' + (form.more ? 'Done' : 'Change') + '</a></div>' +
1308      (form.more ? '<div class="slr-grid3"><label><span class="slr-lbl">Calls</span>' + sel('slr-sl-count', [2, 3, 4, 5, 6, 8, 10, 12], form.count) + '</label>' +
1309          '<label><span class="slr-lbl">Each</span>' + sel('slr-sl-min', [5, 10, 15, 20, 30], form.minutes, 'min') + '</label>' +
1310          '<label><span class="slr-lbl">People</span>' + sel('slr-sl-cap', [1, 2, 3, 4, 5], form.capacity) + '</label></div>' +
1311        /* Alex, 3 Oct: kept, off unless chosen: the coach's way to keep people to the end. */
1312        '<label class="slr-check"><input type="checkbox" id="slr-sl-stay"' + (form.stayed ? ' checked' : '') + '> Only people still here at the end can book</label>' +
1313        '<div class="slr-rm">Use this to keep people to the end. Everyone sees the offer now, with “Stay to the end to book”, and booking opens when you end the session.</div>' +
1314        '<div class="slr-rm" data-slr-whenfull><b>When it’s full:</b> Show it as full. A call shows as full when every place is booked.</div>' : '') +
1315      '<button type="button" class="btn btn-primary slr-wide slr-big-btn" data-slr-slots="open">Offer 1:1 calls after this session</button>' +
1316      '<p class="slr-rm">You can do this at any time: at the start, halfway or near the end. Times you are busy in your calendar are skipped.</p></div>';
1317  }
1318
1319  function _paneCallsOpen(el, c) { el.innerHTML = _callsOpenHtml(c, Date.now()); }
1320  /* The tab once the calls are offered (mockup step 2): the line to say, and the bookings arr
1320iving. Pure. */
1321  function _callsOpenHtml(c, now) {
1322    var o = c.offer;
1323    var nudge = o.open && !c.booked && o.opened_at && now - new Date(o.opened_at).getTime() > 3 * 60000;
1324    var rows = (c.calls || []).map(function (x, i) {
1325      var who = x.people.map(function (p) { return p.name; });
1326      /* Review M1: with no planned end the calls are held as "straight after the session"; they are named by their place until End gives them a time. */
1327      return '<div class="slr-crow"><span class="slr-ct-t">' + _esc(o.times_held ? _ord(i + 1) : _hm(x.starts_at)) + '</span>' +
1328        (who.length ? _ini(who[0]) + '<span class="slr-grow">' + _esc(who.join(', ')) + '</span>' : '<span class="slr-grow slr-free-t">Free</span>') +
1329        (o.capacity > 1 ? '<span class="slr-rm">' + who.length + ' of ' + o.capacity + '</span>' : '') + '</div>';
1330    }).join('');
1331    return '<div class="slr-calls">' +
1332      (o.open ? (nudge ? '<p class="slr-big">Nobody has booked yet. You could say the line again, or tell people what the call is for.</p>' : '') +
1333        '<div class="slr-say"><span class="slr-lbl">Say this to the room</span><p class="slr-say-q">“' + _esc(_sayLine(o.topic, o.stayed_only)) + '”</p></div>' : '') +
1334      '<div class="slr-head"><p class="slr-ct">' + c.booked + ' of ' + _places(c) + ' booked</p>' +
1335        (o.open ? '<span class="pill p-good">Open</span>' : '<span class="pill p-mut">Closed</span>') + '</div>' +
1336      (o.stayed_only && o.open ? '<p class="slr-rm">Only people still here at the end can book. Until then they see “Stay to the end to book”.</p>' : '') +
1337      rows +
1338      (o.open ? '<button type="button" class="btn btn-secondary slr-wide" data-slr-slots="close" data-id="' + o.id + '">Stop taking bookings</button>'
1339        : '<p class="slr-rm">You stopped taking bookings. The people booked keep their calls, straight after the session.</p>') +
1340      '</div>';
1341  }
1342
1343  function _keepSlotsForm() {
1344    if (!R || !R.slots || !R.slots.more) return;
1345    var g = function (id) { var x = document.getElementById(id); return x ? x.value : null; };
1346    var stay = document.getElementById('slr-sl-stay');
1347    R.slots.count = g('slr-sl-count') || R.slots.count; R.slots.minutes = g('slr-sl-min') || R.slots.minutes;
1348    R.slots.capacity = g('slr-sl-cap') || R.slots.capacity; if (stay) R.slots.stayed = !!stay.checked;
1349  }
1350  async function _slotsOpen(btn) {
1351    var g = function (id) { var x = document.getElementById(id); return x ? x.value : null; };
1352    if (R.slots.more) {
1353      var stay = document.getElementById('slr-sl-stay');
1354      R.slots.count = g('slr-sl-count') || R.slots.count; R.slots.minutes = g('slr-sl-min') || R.slots.minutes;
1355      R.slots.capacity = g('slr-sl-cap') || R.slots.capacity; R.slots.stayed = !!(stay && stay.checked);
1356    }
1357    btn.disabled = true;
1358    try {
1359      var zone = 'Europe/London';
1360      try { zone = Intl.DateTimeFormat().resolvedOptions().timeZone || zone; } catch (_) {}
1361      await R.ctx.api('/community-hosting/spaces/' + R.ctx.space.id + '/quick-slots', { method: 'POST', body: {
1362        stream_id: R.id, count: +R.slots.count, minutes: +R.slots.minutes, capacity: +R.slots.capacity,
1363        stayed_only: !!R.slots.stayed, topic: R.slots.topic || null, timezone: zone,
1364      } });
1365      R.ctx.toast('Your 1:1 calls are offered. Everyone in the room sees them now.');
1366      R.formShown = null;
1367      _poll();
1368    } catch (e) {
1369      var code = String(e && (e.error || e.message));
1370      R.ctx.toast(/no_free_time/.test(code) ? 'Your calendar is busy straight after this session, so no calls could be offered.'
1371        : (/already_open/.test(code) ? 'Your calls are already offered.' : 'The calls were not offered. Please try again.'), true);
1372      btn.disabled = false;
1373    }
1374  }
1375  async function _slotsClose(id) {
1376    var c = _calls();
1377    var names = _toCome(c).map(function (x) { return _first(x.people[0].name); });
1378    var ok = window.CBSheet && await window.CBSheet.confirm({
1379      title: 'Stop taking bookings?',
1380      message: 'The offer disappears for everyone in the room.' + (names.length ? ' ' + _list(names) + ' keep' + (names.length === 1 ? 's' : '') + ' ' + (names.length === 1 ? 'their call' : 'their calls') + '.' : ''),
1381      confirmLabel: 'Stop taking bookings', cancelLabel: 'Keep them open',
1382    });
1383    if (!ok) return;
1384    try { await R.ctx.api('/community-hosting/spaces/' + R.ctx.space.id + '/quick-slots/' + id, { method: 'DELETE' }); R.ctx.toast('Bookings stopped. People already booked keep their call.'); _poll(); }
1385    catch (_) { R.ctx.toast('That did not work. Please try again.', true); }
1386  }
1387  function _list(names) {
1388    if (names.length <= 1) return names.join('');
1389    return names.slice(0, -1).join(', ') + ' and ' + names[names.length - 1];
1390  }
1391
1392  /* On the stage: "1:1 calls open · 2 of 6 booked"; a booking arriving is a toast;
1393     a session running over says who is waiting for their call. */
1394  function _renderCallsBits() {
1395    var c = _calls();
1396    var pill = document.getElementById('slr-calls-pill');
1397    var o = c && c.offer;
1398    if (pill) {
1399      var bo = R.env && R.env.breakouts;
1400      pill.hidden = !(o && o.open) || !!bo;
1401      if (o && o.open) pill.innerHTML = '<span class="pill p-good">1:1 calls open</span> ' + c.booked + ' of ' + _places(c) + ' booked';
1402    }
1403    var seen = R.seenBookings;
1404    var now = {};
1405    ((c && c.calls) || []).forEach(function (x) { x.people.forEach(function (p) { now[p.booking_id] = { name: p.name, at: x.starts_at }; }); });
1406    if (seen) Object.keys(now).forEach(function (id) { if (!seen[id]) R.ctx.toast(_first(now[id].name) + ' booked ' + _hm(now[id].at)); });
1407    R.seenBookings = now;
1408    var late = document.getElementById('slr-calls-late');
1409    var next = _toCome(c)[0];
1410    if (late) {
1411      var over = next && new Date(next.starts_at).getTime() < Date.now() && next.people[0].presence === 'here';
1412      late.hidden = !over;
1413      if (over) late.textContent = _first(next.people[0].name) + ' is waiting for the ' + _hm(next.starts_at) + ' call.';
1414    }
1415  }
1416
1417  function _renderHandsBar() {
1418    var el = document.getElementById('slr-hands'); if (!el) return;
1419    var hs = ((R.env && R.env.hands) || []).filter(function (h) { return h.state === 'raised'; });
1420    var guests = ((R.env && R.env.publishers) || 1) - 1;
1421    var max = R.env && R.env.max_publishers > 0 ? R.env.max_publishers - 1 : null;   // TK-43
1422    if (!hs.length) { el.hidden = true; return; }
1423    el.hidden = false;
1424    el.innerHTML = _ic('hand', 16) + '<b>' + hs.length + ' hand' + (hs.length === 1 ? '' : 's') + ' up</b>' +
1425      hs.slice(0, 2).map(function (h, i) {
1426        return '<span class="slr-dim">' + _esc(h.name) + '</span>' +
1427          '<button type="button" class="slr-mini' + (i === 0 ? ' pri' : '') + '" data-slr-hand="invite" data-cid="' + h.contact_id + '">' +
1428          (i === 0 ? 'Invite ' + _esc(String(h.name).split(' ')[0]) + ' to speak' : 'Invite') + '</button>';
1429      }).join('') +
1430      (max != null ? '<span class="slr-dim slr-right">On stage: ' + guests + ' of ' + max + '</span>' : '');
1431  }
1432
1433  function _renderBreakoutBanner() {
1434    var el = document.getElementById('slr-bo-banner'); if (!el) return;
1435    var bo = R.env && R.env.breakouts;
1436    if (!bo) { el.hidden = true; return; }
1437    var people = bo.rooms.reduce(function (a, r) { return a + r.people.length; }, 0);
1438    el.hidden = false;
1439    el.innerHTML = _ic('rooms', 16) + ' ' + bo.rooms.length + ' room' + (bo.rooms.length === 1 ? '' : 's') + ' · ' + people + ' people · <span id="slr-bo-left"></span> ' +
1440      '<a href="#" data-slr-bo="close">Bring everyone back</a>';
1441  }
1442
1443  function _paneBreakouts(el) {
1444    var bo = R.env && R.env.breakouts;
1445    if (bo) {
1446      el.innerHTML =
1447        '<div class="slr-head"><div class="slr-ct">Breakout rooms</div><span class="pill p-good">Running</span></div>' +
1448        '<div class="slr-compose"><input id="slr-bo-msg" type="text" maxlength="280" placeholder="Message to all rooms, e.g. 2 minutes left" aria-label="Message to all rooms">' +
1449          '<button type="button" class="btn btn-secondary" data-slr-bo="message">Send</button></div>' +
1450        (bo.message ? '<div class="slr-rm">Last message: “' + _esc(bo.message) + '”</div>' : '') +
1451        (R.inRoom ? '<div class="slr-inroom-note">You are in ' + _esc(R.inRoom.label) + '’s room. <a href="#" data-slr-bo="leave">Back to the main room</a></div>' : '') +
1452        bo.rooms.map(function (r) {
1453          var inRoom = r.people.filter(function (p) { return !p.left; }).length;
1454          var status = r.help ? '<span class="pill p-warn">' + _ic('help', 12) + ' Asking for help</span>'
1455            : '<span class="slr-rm">' + inRoom + ' in the room' + (inRoom < r.people.length ? ', ' + (r.people.length - inRoom) + ' stepped out' : '') + '</span>';
1456          // Who is in it, as the host names them (member-name.js forHost): the room's label is what members see.
1457          var who = r.people.map(function (p) { return p.name; }).join(', ');
1458          return '<div class="slr-row2"><div class="slr-grow"><div class="slr-rt">' + _esc(r.label) + '</div>' +
1459            (who ? '<div class="slr-rm">' + _esc(who) + '</div>' : '') + '</div>' + status +
1460            '<button type="button" class="btn btn-secondary cpg-btn-tight" data-slr-bo="join" data-bid="' + r.id + '" data-label="' + _esc(r.label) + '">Join</button></div>';
1461        }).join('') +
1462        '<button type="button" class="btn btn-primary slr-wide" data-slr-bo="close">Bring everyone back</button>';
1463      return;
1464    }
1465    var here = (R.env && R.env.attendance && R.env.attendance.here) || 0;
1466    if (R.test) here = 0;   // nobody else can come into a test room
1467    // Working groups (854): offered when this space has any; loaded once per room.
1468    if (R.groups == null && !R.groupsLoading) {
1469      R.groupsLoading = true;
1470      R.ctx.api('/community-hosting/spaces/' + R.ctx.space.id + '/groups').then(function (g) {
1471        R.groups = (g && g.groups) || []; R.groupsLoading = false;
1472        if (R.groups.length && R.split === 'random') R.split = 'groups';
1473        if (R.tab === 'breakouts' && !(R.env && R.env.breakouts)) _renderPane();
1474      }).catch(function () { R.groups = []; R.groupsLoading = false; });
1475    }
1476    var opts = [['random', 'At random'], ['choose', 'Choose']];
1477    if (R.groups && R.groups.length) opts.unshift(['groups', 'Working groups']);
1478    var seg = opts.map(function (x) {
1479      return '<button type="button" class="' + (R.split === x[0] ? 'on' : '') + '" data-slr-split="' + x[0] + '" aria-pressed="' + (R.split === x[0]) + '">' + x[1] + '</button>';
1480    }).join('');
1481    var mins = ['5', '10', '15', '20', '30'].map(function (m) { return '<option value="' + m + '"' + (R.minutes === m ? ' selected' : '') + '>' + m + ' min</option>'; }).join('');
1482    // Round 3: any size from 2 to 6 (pairs, trios, pods of four…), for random rooms and as Choose's starting point.
1483    var sizes = ['2', '3', '4', '5', '6'].map(function (n) {
1484      return '<option value="' + n + '"' + (R.size === n ? ' selected' : '') + '>' + (n === '2' ? 'Pairs' : 'Groups of ' + n) + '</option>';
1485    }).join('');
1486    el.innerHTML =
1487      '<div class="slr-head"><div class="slr-ct">Breakout rooms</div><span class="slr-rm">' + (R.test ? 'Only you' : here + ' here now') + '</span></div>' +
1488      /* TK-3: the test room says up front why rooms cannot open, and what they would do. */
1489      (R.test ? '<div class="slr-inroom-note" data-slr-bo-test>In the test room you\u2019re on your own, so breakout rooms can\u2019t open here. ' +
1490        'In a real session they split the people who are here into small rooms for a set time, then bring everyone back. ' +
1491        'They open once at least two people are in the room. You can still look at the settings below.</div>' : '') +
1492      '<div class="slr-seg" role="group" aria-label="How to split">' + seg + '</div>' +
1493      (R.split === 'groups' ? '' : '<label class="slr-size"><span class="slr-lbl">People in each room</span><select id="slr-bo-size">' + sizes + '</select></label>') +
1494      '<div class="slr-grid2"><label><span class="slr-lbl">Time</span><select id="slr-bo-min">' + mins + '</select></label>' +
1495        '<label><span class="slr-lbl">Sharing</span><select id="slr-bo-share"><option value="yes"' + (R.share === 'yes' ? ' selected' : '') + '>Everyone can share</option>' +
1496          '<option value="no"' + (R.share === 'no' ? ' selected' : '') + '>Camera and mic only</option></select></label></div>' +
1497      (R.split === 'choose' ? '<div id="slr-choose"><div class="slr-none">Loading who is here…</div></div>' : '') +
1498      (R.split === 'groups' ? '<div class="slr-rm">Each working group gets its own room with whoever of it is here: ' +
1499        _esc(R.groups.map(function (g) { return g.name; }).join(', ')) + '. Anyone left over is paired at random.</div>' : '') +
1500      '<div class="slr-rm">Everyone goes back to the main room when the time is up, or when you bring them back. Breakout rooms are not recorded.</div>' +
1501      /* TK-3: one rule for every way of splitting. Choose used to stay pressable with
1502         nobody here and then answer "Put at least one person in a room". */
1503      '<button type="button" class="btn btn-primary slr-wide" data-slr-bo="open"' + (here < 2 ? ' disabled' : '') + '>Open breakout rooms</button>' +
1504      (here < 2 ? '<div class="slr-rm" data-slr-bo-need>Breakouts need at least two people in the room.</div>' : '');
1505    if (R.split === 'choose') _renderChoose();
1506  }
1507
1508  /* Choose, the way Zoom does it (round 3: one drop-down per person was 40 picks
1509     for 40 people): everyone starts already in rooms of the chosen size, drawn
1510     room by room, and the host moves only the exceptions. Shuffle deals them out
1511     again. Nobody is left alone in a room by the deal. */
1512  function _deal(people, size, shuffle) {
1513    var list = people.slice();
1514    if (shuffle) for (var i = list.length - 1; i > 0; i--) { var j = Math.floor(Math.random() * (i + 1)); var t = list[i]; list[i] = list[j]; list[j] = t; }
1515    var n = Math.max(2, Math.min(6, parseInt(size, 10) || 2));
1516    var rooms = Math.max(1, Math.ceil(list.length / n));
1517    if (rooms > 1 && list.length % n === 1) rooms -= 1;   // a leftover single joins the last room
1518    var out = {};
1519    list.forEach(function (p, k) { out[p.contact_id] = String(Math.min(rooms, Math.floor(k / 
1519n) + 1)); });
1520    return out;
1521  }
1522  async function _renderChoose(shuffle) {
1523    var box = document.getElementById('slr-choose'); if (!box) return;
1524    var att;
1525    try { att = await R.ctx.api('/community-hosting/streams/' + R.id + '/attendance'); } catch (_) { box.innerHTML = '<div class="slr-none">Who is here could not load.</div>'; return; }
1526    var here = (att.people || []).filter(function (p) { return p.here; });
1527    if (!here.length) { box.innerHTML = '<div class="slr-none">Nobody is here yet.</div>'; return; }
1528    var missing = here.some(function (p) { return !R.choose[p.contact_id]; });
1529    if (shuffle || missing || R.chooseSize !== R.size) {
1530      var fresh = _deal(here, R.size, !!shuffle);
1531      here.forEach(function (p) { if (shuffle || R.chooseSize !== R.size || !R.choose[p.contact_id]) R.choose[p.contact_id] = fresh[p.contact_id]; });
1532      R.chooseSize = R.size;
1533    }
1534    var rooms = 0;
1535    here.forEach(function (p) { rooms = Math.max(rooms, parseInt(R.choose[p.contact_id], 10) || 1); });
1536    var html = '<div class="slr-choose-head"><span class="slr-rm">' + here.length + ' people in ' + rooms + ' room' + (rooms === 1 ? '' : 's') + '. Move anyone who should be somewhere else.</span>' +
1537      '<button type="button" class="btn btn-secondary cpg-btn-tight" data-slr-bo="shuffle">Shuffle</button></div>';
1538    for (var k = 1; k <= rooms; k++) {
1539      var inK = here.filter(function (p) { return String(R.choose[p.contact_id]) === String(k); });
1540      html += '<div class="slr-room-box"><div class="slr-rt">Room ' + k + ' <span class="slr-rm">' + inK.length + (inK.length === 1 ? ' person' : ' people') + '</span></div>' +
1541        (inK.length ? inK.map(function (p) {
1542          var opts = '';
1543          for (var r = 1; r <= rooms + 1; r++) opts += '<option value="' + r + '"' + (r === k ? ' selected' : '') + '>' + (r > rooms ? 'A new room' : 'Room ' + r) + '</option>';
1544          return '<div class="slr-row2"><div class="slr-grow">' + _esc(p.name) + '</div>' +
1545            '<select data-slr-pick="' + p.contact_id + '" aria-label="Move ' + _esc(p.name) + '">' + opts + '</select></div>';
1546        }).join('') : '<div class="slr-rm">Empty: it will not open.</div>') + '</div>';
1547    }
1548    box.innerHTML = html;
1549  }
1550
1551  // ── Actions ─────────────────────────────────────────────────────────────
1552
1553  async function _onClick(e) {
1554    var t = e.target.closest('[data-slr-ctl],[data-slr-tab],[data-slr-send],[data-slr-hand],[data-slr-bo],[data-slr-split],[data-slr-answer],[data-slr-studio],[data-slr-close],[data-slr-slots],[data-slr-topic],[data-slr-more]');
1555    if (!t) return;
1556    e.preventDefault();
1557    if (t.hasAttribute('data-slr-studio')) return openStudio({ live: true });
1558    if (t.hasAttribute('data-slr-close')) return _teardown();
1559    if (t.hasAttribute('data-slr-tab')) { R.tab = t.getAttribute('data-slr-tab'); _renderTabs(); _renderPane(); return; }
1560    if (t.hasAttribute('data-slr-send')) return _sendChat();
1561    if (t.hasAttribute('data-slr-split')) { R.split = t.getAttribute('data-slr-split'); _renderPane(); return; }
1562    if (t.hasAttribute('data-slr-answer')) {
1563      try { await R.ctx.api('/community-hosting/streams/' + R.id + '/questions/' + t.getAttribute('data-slr-answer'), { method: 'POST', body: { answered: true } }); _poll(); }
1564      catch (_) { R.ctx.toast('That did not save.', true); }
1565      return;
1566    }
1567    if (t.hasAttribute('data-slr-hand')) return _hand(t.getAttribute('data-slr-hand'), t.getAttribute('data-cid'));
1568    if (t.hasAttribute('data-slr-topic')) { R.slots.topic = t.getAttribute('data-slr-topic'); _renderPane(); return; }
1569    if (t.hasAttribute('data-slr-more')) { _keepSlotsForm(); R.slots.more = !R.slots.more; _renderPane(); return; }
1570    if (t.hasAttribute('data-slr-slots')) return t.getAttribute('data-slr-slots') === 'open' ? _slotsOpen(t) : _slotsClose(t.getAttribute('data-id'));
1571    if (t.hasAttribute('data-slr-bo')) {
1572      var a = t.getAttribute('data-slr-bo');
1573      if (a === 'open') return _boOpen(t);
1574      if (a === 'shuffle') return _renderChoose(true);
1575      if (a === 'close') return _boClose();
1576      if (a === 'message') return _boMessage();
1577      if (a === 'join') return _boJoin(t.getAttribute('data-bid'), t.getAttribute('data-label'));
1578      if (a === 'leave') return _boLeave();
1579      return;
1580    }
1581    var c = t.getAttribute('data-slr-ctl');
1582    if (c === 'mic') return _toggleMic(t);
1583    if (c === 'cam') return _toggleCam(t);
1584    if (c === 'bg') return _background();
1585    if (c === 'screen') return _toggleScreen(t);
1586    if (c === 'layout') return _chooseLayout();
1587    if (c === 'breakouts') { R.tab = 'breakouts'; _renderTabs(); _renderPane(); return; }
1588    if (c === 'slots') { R.tab = 'slots'; _renderTabs(); _renderPane(); return; }
1589    if (c === 'end') return _end();
1590  }
1591
1592  async function _sendChat() {
1593    var inp = document.getElementById('slr-chat-in'); if (!inp) return;
1594    var body = String(inp.value || '').trim(); if (!body) return;
1595    inp.value = '';
1596    try { await R.ctx.api('/community-hosting/streams/' + R.id + '/chat', { method: 'POST', body: { body: body } }); R.chatJumped = true; _poll(); }
1597    catch (_) { R.ctx.toast('That message did not send.', true); inp.value = body; }
1598  }
1599
1600  async function _hand(action, cid) {
1601    try {
1602      await R.ctx.api('/community-hosting/streams/' + R.id + '/hands/' + cid, { method: 'POST', body: { action: action } });
1603      _poll();
1604    } catch (e) {
1605      R.ctx.toast(/stage_full/.test(String(e && (e.error || e.message))) ? 'The stage is full. Remove someone first.' : 'That did not work. Please try again.', true);
1606    }
1607  }
1608
1609  // Mute and Camera act on the room the host is in (a breakout they are visiting, else the main room).
1610  async function _toggleMic(btn) {
1611    var room = _hostRoom(); if (!room) return;
1612    R.mic = !R.mic;
1613    try { await room.localParticipant.setMicrophoneEnabled(R.mic); } catch (_) { R.mic = !R.mic; }
1614    _ctlSync();
1615  }
1616  async function _toggleCam(btn) {
1617    var room = _hostRoom(); if (!room) return;
1618    R.cam = !R.cam;
1619    try { await room.localParticipant.setCameraEnabled(R.cam); if (R.cam) { _attachSelf(); if (R.bg !== 'off' && room === R.room) await _applyBackground(R.bg, R.bgImage); } }
1620    catch (_) { R.cam = !R.cam; R.ctx.toast('The camera did not start. Check the browser’s permission.', true); }
1621    _ctlSync();
1622  }
1623  async function _toggleScreen(btn) {
1624    if (!R.room) return;
1625    var want = !R.screen;
1626    try { await R.room.localParticipant.setScreenShareEnabled(want); R.screen = want; }
1627    catch (_) { R.ctx.toast('Screen sharing did not start.', true); }
1628    btn.classList.toggle('on', R.screen);
1629    btn.querySelector('span').textContent = R.screen ? 'Stop sharing' : 'Share screen';
1630    _selfName();
1631  }
1632
1633  /** Background: off, blur, or a picture from this device (SPACES-SPEC §3). */
1634  async function _background() {
1635    if (!window.CBSheet) return;
1636    var pick = await window.CBSheet.choose({
1637      title: 'Your background',
1638      message: 'Only your camera picture changes. Nothing is saved.',
1639      items: [
1640        { value: 'off', label: 'No effect' },
1641        { value: 'blur', label: 'Blur my background' },
1642        { value: 'picture', label: 'Choose a picture…', note: 'From this device' },
1643      ],
1644      confirmLabel: 'Use it', cancelLabel: 'Cancel',
1645    });
1646    if (!pick) return;
1647    if (pick === 'picture') {
1648      var file = await new Promise(function (resolve) {
1649        var inp = document.createElement('input'); inp.type = 'file'; inp.accept = 'image/*';
1650        inp.onchange = function () { resolve(inp.files && inp.files[0]); };
1651        inp.click();
1652      });
1653      if (!file) return;
1654      if (R.bgImage) { try { URL.revokeObjectURL(R.bgImage); } catch (_) {} }
1655      R.bgImage = URL.createObjectURL(file);
1656    }
1657    await _applyBackground(pick, R.bgImage);
1658  }
1659
1660  async function _applyBackground(mode, image) {
1661    try {
1662      var TP = await _load(TP_SRC, 'LivekitTrackProcessors');
1663      if (!TP.supportsBackgroundProcessors()) throw new Error('unsupported');
1664      var pub = R.room && R.room.localParticipant.getTrackPublication(R.LK.Track.Source.Camera);
1665      var track = pub && pub.track; if (!track) throw new Error('no_camera');
1666      var opts = mode === 'blur' ? { mode: 'background-blur', blurRadius: 12 }
1667        : mode === 'picture' ? { mode: 'virtual-background', imagePath: image }
1668        : { mode: 'disabled' };
1669      if (!R.processor) {
1670        if (mode === 'off') { R.bg = 'off'; _selfName(); return; }
1671        R.processor = TP.BackgroundProcessor(opts);
1672        await track.setProcessor(R.processor);
1673      } else {
1674        await R.processor.switchTo(opts);
1675      }
1676      R.bg = mode; _selfName();
1677    } catch (err) {
1678      // Loud, never silent (CODING-STANDARDS §7b).
1679      console.error('[space-live] background effect failed —', err && err.message);
1680      R.ctx.toast('Background effects are not available in this browser.', true);
1681    }
1682  }
1683
1684  async function _boOpen(btn) {
1685    var body = { split: R.split, size: parseInt(R.size, 10) || 2, minutes: parseInt(R.minutes, 10) || 10, allow_share: R.share !== 'no' };
1686    if (R.split === 'choose') {
1687      var groups = {};
1688      Object.keys(R.choose).forEach(function (cid) { (groups[R.choose[cid]] = groups[R.choose[cid]] || []).push(parseInt(cid, 10)); });
1689      body.groups = Object.keys(groups).map(function (k) { return groups[k]; });
1690    }
1691    btn.disabled = true;
1692    try {
1693      await R.ctx.api('/community-hosting/streams/' + R.id + '/breakouts', { method: 'POST', body: body });
1694      R.ctx.toast('Breakout rooms are open.');
1695      _poll();
1696    } catch (e) {
1697      var code = String(e && (e.error || e.message));
1698      R.ctx.toast(/not_enough_people/.test(code) ? 'Breakouts need at least two people in the room.'
1699        : /breakouts_open/.test(code) ? 'Breakout rooms are already open.'
1700        : /groups_required/.test(code) ? 'Put at least one person in a room.'
1701        : /no_working_groups/.test(code) ? 'This space has no working groups yet. Add them on the Members tab.'
1702        : 'The rooms did not open. Please try again.', true);
1703      btn.disabled = false;
1704    }
1705  }
1706  async function _boMessage() {
1707    var inp = document.getElementById('slr-bo-msg'); if (!inp) return;
1708    var text = String(inp.value || '').trim(); if (!text) return;
1709    try { await R.ctx.api('/community-hosting/streams/' + R.id + '/breakouts/message', { method: 'POST', body: { text: text } }); inp.value = ''; R.ctx.toast('Sent to every room.'); _poll(); }
1710    catch (_) { R.ctx.toast('That message did not send.', true); }
1711  }
1712  async function _boClose() {
1713    await _boLeave();
1714    try { await R.ctx.api('/community-hosting/streams/' + R.id + '/breakouts', { method: 'DELETE' }); R.ctx.toast('Everyone is back in the main room.'); _poll(); }
1715    catch (_) { R.ctx.toast('That did not work. Please try again.', true); }
1716  }
1717  /** The host joins a breakout room: a second room connection. Their main-room
1718   *  microphone AND camera pause meanwhile (the main room stays connected, so it
1719   *  keeps its host). The camera too (LIVE-14): an iPhone runs one camera at a
1720   *  time, so the room's camera ended the main room's and the host came back
1721   *  black. Pausing it here stops that camera; coming back starts it afresh. */
1722  async function _boJoin(bid, label) {
1723    await _boLeave();
1724    var r = R; if (!r) return;
1725    try {
1726      var out = await r.ctx.api('/community-hosting/streams/' + r.id + '/breakouts/' + bid + '/join', { method: 'POST', body: {} });
1727      var LK = r.LK;
1728      var room = new LK.Room({ adaptiveStream: true });
1729      r.inRoom = { id: bid, label: label, room: room };
1730      var box = document.getElementById('slr-inroom');
1731      if (box) { box.hidden = false; box.innerHTML = '<div class="slr-inroom-head">In ' + _esc(label) + '’s room <a href="#" data-slr-bo="leave">Back to the main room</a></div><div class="slr-inroom-tiles" id="slr-inroom-tiles"></div>'; }
1732      room.on(LK.RoomEvent.TrackSubscribed, function (track, pub, p) {
1733        var tiles = document.getElementById('slr-inroom-tiles'); if (!tiles) return;
1734        if (track.kind === 'video') { var v = document.createElement('video'); v.autoplay = true;
1734 v.playsInline = true; v.muted = true; v.setAttribute('data-pid', p.identity); tiles.appendChild(v); try { track.attach(v); } catch (_) {} }
1735        else { try { var a = track.attach(); tiles.appendChild(a); } catch (_) {} }
1736      });
1737      /* LIVE-3: the room can close under the host. Breakouts usually end on their
1738         timer: the server's sweep deletes the room, nobody presses anything. The
1739         host comes back to the main room as they were, Mute meaning muted. */
1740      room.on(LK.RoomEvent.Disconnected, function () { if (R === r && !r.ended && r.inRoom && r.inRoom.room === room) _boLeave(); });
1741      /* LIVE-9: a browser may refuse the room's sound when it did not start from a
1742         tap (an iPhone). Then one button starts it, inside the tap, as the
1743         member's room does. */
1744      var hear = function () {
1745        if (room.canPlaybackAudio !== false || R !== r || !r.inRoom || r.inRoom.room !== room) return;
1746        var head = document.querySelector('#slr-inroom .slr-inroom-head'); if (!head || head.querySelector('[data-slr-hear]')) return;
1747        var b = document.createElement('button'); b.type = 'button'; b.className = 'btn btn-secondary cpg-btn-tight'; b.setAttribute('data-slr-hear', '1');
1748        b.textContent = '🔊 Tap to hear the call';
1749        b.onclick = function () { try { var pa = room.startAudio(); if (pa && pa.catch) pa.catch(function () {}); } catch (_) {} b.remove(); };
1750        head.appendChild(b);
1751      };
1752      if (LK.RoomEvent.AudioPlaybackStatusChanged) room.on(LK.RoomEvent.AudioPlaybackStatusChanged, hear);
1753      await room.connect(out.token.url, out.token.token);
1754      if (R !== r || !r.inRoom || r.inRoom.room !== room) { try { room.disconnect(); } catch (_) {} return; }   // the rooms closed while it connected
1755      var mlp = r.room && r.room.localParticipant;
1756      if (mlp && r.mic) { try { await mlp.setMicrophoneEnabled(false); } catch (_) {} }
1757      if (mlp && r.cam) { try { await mlp.setCameraEnabled(false); } catch (_) {} }
1758      /* TK-33: the host walks into a room as they are in the main room: a camera
1759         they had off stays off, a microphone they had muted stays muted (Zoom). */
1760      var ilp = room.localParticipant;
1761      if (r.mic) { try { await ilp.setMicrophoneEnabled(true); } catch (e1) { r.mic = false; console.error('[space-live] breakout microphone did not start —', e1 && e1.message); } }
1762      if (r.cam) { try { await ilp.setCameraEnabled(true); } catch (e2) { r.cam = false; console.error('[space-live] breakout camera did not start —', e2 && e2.message); } }
1763      if (R !== r) return;
1764      _attachSelf(); _ctlSync();
1765      _renderPane();
1766      try { var st = room.startAudio(); if (st && st.then) st.then(hear, hear); else hear(); } catch (_) { hear(); }
1767    } catch (err) {
1768      console.error('[space-live] joining a breakout failed —', err && err.message);
1769      if (R !== r) return;
1770      r.ctx.toast('You could not join that room.', true);
1771      await _boLeave();
1772    }
1773  }
1774  /** Back to the main room: from the Back link, "Bring everyone back", the room
1775   *  closing on its timer (its Disconnected), or a poll with no rooms open. The
1776   *  main room gets the host's microphone and camera back as they are (R.mic,
1777   *  R.cam), the camera started afresh, and the buttons are drawn from that. */
1778  async function _boLeave() {
1779    var r = R;
1780    if (!r || !r.inRoom) return;
1781    var ir = r.inRoom;
1782    r.inRoom = null;   // first, so the room's own Disconnected does not bring us here twice
1783    try { await ir.room.disconnect(); } catch (_) {}
1784    var box = document.getElementById('slr-inroom'); if (box) { box.hidden = true; box.innerHTML = ''; }
1785    if (R !== r || r.ended) return;
1786    var lp = r.room && r.room.localParticipant;
1787    if (lp) {
1788      try { await lp.setMicrophoneEnabled(!!r.mic); } catch (e1) { r.mic = false; console.error('[space-live] main-room microphone did not come back:', e1 && e1.message); }
1789      try {
1790        await lp.setCameraEnabled(!!r.cam);
1791        if (r.cam) {
1792          // An iPhone may have ended the camera while the room's was on: start it again rather than show black.
1793          var pub = lp.getTrackPublication(r.LK.Track.Source.Camera);
1794          var mst = pub && pub.track && pub.track.mediaStreamTrack;
1795          if (mst && mst.readyState === 'ended' && typeof pub.track.restartTrack === 'function') await pub.track.restartTrack();
1796          if (R === r && r.bg !== 'off') await _applyBackground(r.bg, r.bgImage);   // as the Camera button does when it turns the camera back on
1797        }
1798      } catch (e2) { r.cam = false; console.error('[space-live] main-room camera did not come back:', e2 && e2.message); }
1799    }
1800    if (R !== r) return;
1801    _attachSelf(); _ctlSync();
1802    _renderPane();
1803  }
1804
1805  /* TK-4: has anybody but the host come in? Mirrors the server's rule in
1806     stream-service.endStream (a call nobody joined is kept, never posted). */
1807  function _anyoneCame() {
1808    var att = R && R.env && R.env.attendance;
1809    if (att && (att.total || att.here)) return true;
1810    if (R && R.env && (R.env.peak_viewers || 0) > 0) return true;
1811    return !!(R && R.messages.some(function (m) { return !m.author_is_coach; }));
1812  }
1813  function _recordingWords() {
1814    /* A private call (a 1:1 or a booked call) is never posted to any feed: the
1815       server refuses it whatever is sent (stream-service NEVER_POSTED_AUDIENCES). */
1816    if (R.private) return 'This is a private call, so the recording is never posted to any feed. Only you and the people in this call can watch it.';
1817    return _anyoneCame()
1818      ? 'The recording is posted to ' + R.ctx.space.name + '\u2019s feed when it is ready.'
1819      : 'Nobody else joined, so the recording is kept for you on your community\u2019s Live page and is not posted to members.';
1820  }
1821
1822  async function _end() {
1823    var c = !R.test && !R.call ? _calls() : null;
1824    var a = _endAsk(c, { test: R.test, call: R.call, spaceName: R.ctx.space.name, recording: R.test ? '' : _recordingWords(), anyone: _anyoneCame() });
1825    var ask = a.ask, straightIn = a.straightIn, next = a.next;
1826    var ok = window.CBSheet && await window.CBSheet.confirm(ask);
1827    if (!ok) return;
1828    try { await R.ctx.api('/community-hosting/streams/' + R.id + '/end', { method: 'POST', body: { post: !R.test && !R.private } }); }
1829    catch (_) { R.ctx.toast('The session did not end cleanly. It ends by itself when you close this page.', true); }
1830    if (straightIn) { var ctx = R.ctx, sid = R.id; R.ended = true; _closeQuiet(); return _goCall(ctx, sid, next); }
1831    _ended('coach');
1832  }
1833  /* What End asks (mockup step 4). Pure: (the queue, { test, call, spaceName, recording, anyone }). */
1834  function _endAsk(c, o) {
1835    var next = o.test || o.call ? null : _toCome(c)[0];
1836    var straightIn = false, ask;
1837    if (o.test) {
1838      ask = { title: 'Leave the test room?',
1839        message: 'Nothing in ' + o.spaceName + ' changes. Your scheduled sessions are all still there.',
1840        confirmLabel: 'Leave the test room', cancelLabel: 'Stay' };
1841    } else if (o.call) {
1842      var after = _callAfter(null, o.call);
1843      ask = { title: 'End your call with ' + _first(o.call.name) + '?',
1844        message: after ? 'Next is ' + _first(after.people[0].name) + ' at ' + _hm(after.starts_at) + '.' : 'That is your last call for this session.',
1845        confirmLabel: 'End the call', cancelLabel: 'Keep talking' };
1846    } else if (next) {
1847      /* R12-SLOTS (mockup step 4): End says what happens next, and the coach goes straight into the first call. */
1848      var p0 = next.people[0];
1849      straightIn = p0.presence === 'here';
1850      ask = { title: 'End the session for everyone?',
1851        message: straightIn
1852          ? 'Your first 1:1 is with ' + _first(p0.name) + ', who is waiting. You’ll go straight to the call.'
1853          : 'Your first 1:1 is with ' + _first(p0.name) + ', who is not in the room right now. You’ll see your calls next and can start when they arrive.',
1854        confirmLabel: 'End and start my 1:1s', cancelLabel: 'Keep going' };
1855    } else if (c && c.offer && c.offer.open && c.offer.stayed_only) {
1856      ask = { title: 'End the session for everyone?',
1857        message: 'People still here can book a call with you now, and you’ll see them arrive. ' + o.recording,
1858        confirmLabel: 'End the session', cancelLabel: 'Keep going' };
1859    } else {
1860      ask = { title: 'End the session for everyone?',
1861        message: (o.anyone ? 'Everyone leaves the room. ' : '') + o.recording,
1862        confirmLabel: 'End the session', cancelLabel: 'Keep going', danger: true };
1863    }
1864    return { ask: ask, straightIn: straightIn, next: next };
1865  }
1866
1867  /* The next call after the one this room is (in a 1:1 call's room). */
1868  function _callAfter(q, call) {
1869    var list = _toCome(q || _lastQueue);
1870    return list.filter(function (x) { return !call || String(x.slot_id) !== String(call.slotId); })[0] || null;
1871  }
1872
1873  /* The room goes, quietly (no reload): the next screen is the coach's next call or the queue. */
1874  function _closeQuiet() {
1875    _stopMedia(); _queueStop();
1876    R = null;
1877    var el = document.getElementById('slr-room'); if (el) el.remove();
1878  }
1879
1880  /* "Start my call with Dee": the call's own room (made at the first booking), live. */
1881  async function _goCall(ctx, sessionId, call) {
1882    var r;
1883    try { r = await ctx.api('/community-hosting/spaces/' + ctx.space.id + '/quick-slots/calls/' + call.slot_id + '/start', { method: 'POST', body: {} }); }
1884    catch (e) { ctx.toast('That call did not start. Please try again.', true); return _queueOpen(ctx, sessionId, {}); }
1885    var ok = await _startRoom(ctx, r.room_id, { call: { sessionId: sessionId, slotId: call.slot_id, name: call.people[0].name } });
1886    if (!ok) _queueOpen(ctx, sessionId, {});
1887  }
1888
1889  /* `reason` is the room's own end_reason ('coach', 'limit', 'idle', 'cap',
1890     'killed'), or null when it is not known. Robert, round 4 (N1): the Studio
1891     line said "you could have kept going" to a coach who ended a two-minute
1892     session himself, because it asked whether the plan HAS a limit. It now
1893     shows only when the free limit is what ended it. */
1894  function _ended(reason) {
1895    if (!R || R.ended) return;
1896    R.ended = true;
1897    var clock = R.lastClock || (R.env && R.env.clock) || {};
1898    /* A free room whose clock was the month's minutes and that ended on that
1899       clock. 'cap' too: on dev several boxes sweep one database and an older
1900       box's sweep names it 'cap'; the room's own clock says which it was. */
1901    var limited = reason === 'limit' || (reason === 'cap' && !!clock.pool_minutes);
1902    var pool = (R.env && R.env.pool) || {};
1903    _stopMedia();
1904    var el = document.getElementById('slr-room'); if (!el) return;
1905    if (R.call) return _queueOpen(R.ctx, R.call.sessionId, { after: R.call.name, el: el, ended: R });
1906    var c0 = _calls();
1907    if (!R.test && c0 && c0.offer && (c0.booked || (c0.offer.open && c0.offer.stayed_only))) {
1908      return _queueOpen(R.ctx, R.id, { el: el, ended: R, recording: _recordingWords(), limited: limited });
1909    }
1910    if (R.test) {
1911      el.innerHTML = '<div class="slr-over"><h2>You have left the test room</h2>' +
1912        '<p>Nothing in ' + _esc(R.ctx.space.name) + ' changed. Your scheduled sessions are all still there.</p>' +
1913        '<button type="button" class="btn btn-primary csp-btn" data-slr-close>Back to the space</button></div>';
1914      return;
1915    }
1916    el.innerHTML = '<div class="slr-over"><h2>The session has ended</h2>' +
1917      '<p>' + _esc(_recordingWords()) + '</p>' +
1918      (c0 && c0.offer ? '<p class="slr-dim">Nobody booked a call this time. The times are closed and off your calendar.</p>' : '') +
1919      (limited ? '<p class="slr-dim">It ended because this month\u2019s ' + (pool.monthly_minutes || clock.pool_minutes || '') +
1920        ' free live minutes ran out. They start again on ' + _resetWords(pool.resets_on) + '.</p>' +
1921        '<p><button type="button" class="btn btn-secondary csp-btn" data-slr-studio>Upgrade for more</button></p>' : '') +
1922      '<button type="button" class="btn btn-primary csp-btn" data-slr-close>Back to the space</button></div>';
1923  }
1924
1925
1926  // ── The coach's queue after the session (mockup steps 4 and 5) ───────────
1927  // "The session has ended" becomes her queue: who is first, who is here, one
1928  // button to start. Between calls: "Call with Priya ended · Next is Jess".
1929  // Its poll is its own and bounded: every 4 s while the screen is up, slower
1930  // after errors, never past 3 hours, and it stops the moment the screen goes.
1931  var Q = null;
1932  var Q_POLL_MS = 4000, Q_MAX_MS = 3 * 3600000;
1933
1934  function _queueOpen(ctx, sessionId, opts) {
1935    var o = opts || {};
1936    if (R) _closeQuiet();
1937    _queueStop();
1938    var old = document.getElementById('slr-queue'); if (old) old.remove();
1939    var el = document.createElement('div');
1940    el.id = 'slr-queue'; el.className = 'slr-room slr-qroom';
1941    el.setAttribute('role', 'dialog'); el.setAttribute('aria-modal', 'true'); el.setAttribute('aria-label', 'Your 1:1 calls');
1942    document.body.appendChild(el);
1943    document.body.classList.add('slr-open');
1944    Q = { ctx: ctx, sessionId: sessionId, after: o.after || null, recording: o.recording || '', limited: !!o.limited,
1945      data: _lastQueue, started: Date.now(), fails: 0, timer: null };
1946    el.addEventListener('click', function (e) {
1947      var t = e.target.closest('[data-slr-q]'); if (!t) return;
1948      e.preventDefault(); _queueAct(t.getAttribute('data-slr-q'), t);
1949    });
1950    _queueRender();
1951    _queueTick();
1952  }
1953  function _queueStop() { if (Q && Q.timer) clearTimeout(Q.timer); if (Q) Q.timer = null; }
1954  function _queueClose(reload) {
1955    var ctx = Q && Q.ctx;
1956    _queueStop(); Q = null;
1957    var el = document.getElementById('slr-queue'); if (el) el.remove();
1958    document.body.classList.remove('slr-open');
1959    if (reload && ctx && ctx.reload) ctx.reload();
1960  }
1961  async function _queueTick() {
1962    if (!Q || !document.getElementById('slr-queue')) return _queueStop();
1963    if (Date.now() - Q.started > Q_MAX_MS) { _queueStop(); return; }
1964    var mine = Q;
1965    try {
1966      var d = await mine.ctx.api('/community-hosting/spaces/' + mine.ctx.space.id + '/quick-slots/queue?stream_id=' + mine.sessionId);
1967      if (Q !== mine) return;
1968      Q.data = d; _lastQueue = d; Q.fails = 0; Q.lost = false;
1969    } catch (e) {
1970      if (Q !== mine) return;
1971      Q.fails++;
1972      if (Q.fails >= 6) Q.lost = true;   // said on screen; the poll keeps trying, slower
1973      console.error('[space-live] the 1:1 queue did not refresh —', e && (e.error || e.message));
1974    }
1975    _queueRender();
1976    var wait = Q.fails ? Math.min(60000, Q_POLL_MS * Math.pow(2, Q.fails)) : Q_POLL_MS;
1977    if (document.hidden) wait = Math.max(wait, 15000);
1978    Q.timer = setTimeout(_queueTick, wait);
1979  }
1980  function _presenceWords(p) {
1981    if (p === 'here') return '<span class="pill p-good">Waiting for you</span>';
1982    if (p === 'left') return '<span class="pill p-mut">Left the room</span>';
1983    return '<span class="pill p-warn">Not here yet</span>';
1984  }
1985  function _nowCard(call) {
1986    var p = call.people[0];
1987    var nm = call.people.map(function (x) { return _first(x.name); }).join(', ');
1988    return '<div class="slr-nowcard"><span class="slr-bigav" aria-hidden="true">' + _esc((String(p.name || '?').trim()[0] || '?').toUpperCase()) + '</span>' +
1989      '<div><p class="slr-ct">' + _esc(nm) + '</p><p>' + _presenceWords(p.presence) + '</p></div></div>' +
1990      '<button type="button" class="btn btn-primary slr-wide slr-big-btn" data-slr-q="start" data-slot="' + call.slot_id + '">Start my call with ' + _esc(_first(p.name)) + '</button>' +
1991      (p.presence !== 'here' ? '<button type="button" class="btn btn-secondary slr-wide" data-slr-q="skip" data-bid="' + p.booking_id + '">Next person</button>' : '');
1992  }
1993  function _queueRender() {
1994    var el = document.getElementById('slr-queue'); if (!el || !Q) return;
1995    el.innerHTML = _queueHtml(Q.data, Q);
1996  }
1997  /* The coach's screen after the session or between calls. Pure: (queue, { after, recording, limited, lost }). */
1998  function _queueHtml(data, Q) {
1999    var d = data || { calls: [], booked: 0, offer: null };
2000    var list = _toCome(d);
2001    var next = list[0] || null;
2002    var stayedOpen = d.offer && d.offer.open && d.offer.stayed_only;
2003    var head = Q.after ? 'Call with ' + _first(Q.after) + ' ended' : 'The session has ended';
2004    var sub = Q.after ? (next ? 'Next is ' + _first(next.people[0].name) + ' at ' + _hm(next.starts_at) + '.' : (stayedOpen ? '' : 'That was your last call.')) : Q.recording;
2005    var rows = (d.calls || []).filter(function (x) { return x.people.length; }).map(function (x) {
2006      var st = x.room_state === 'ended' ? '<span class="pill p-mut">Done</span>' : (x.room_state === 'live' ? '<span class="pill p-acc">With you now</span>'
2007        : (x.people[0].presence === 'here' ? '<span class="pill p-good">Here</span>' : (x.people[0].presence === 'left' ? '<span class="pill p-mut">Left the room</span>' : '<span class="pill p-warn">Not here yet</span>')));
2008      return '<div class="slr-crow"><span class="slr-ct-t">' + _esc(_hm(x.starts_at)) + '</span>' + _ini(x.people[0].name) +
2009        '<span class="slr-grow">' + _esc(x.people.map(function (p) { return p.name; }).join(', ')) + '</span>' + st + '</div>';
2010    }).join('');
2011    var doneBtn = list.length ? '<button type="button" class="btn btn-secondary slr-wide" data-slr-q="done">I’m done for today</button>' : '';
2012    var back = '<button type="button" class="btn ' + (list.length || stayedOpen ? 'btn-secondary' : 'btn-primary') + ' slr-wide" data-slr-q="back">Back to the space</button>';
2013    var lost = Q.lost ? '<p class="slr-rm" role="status">Your calls could not be refreshed just now. We keep trying.</p>' : '';
2014    /* The calls moved to when the session ended; a Google Calendar event that could not follow is said, once, plainly. */
2015    var nCal = d.calendar_not_updated || 0;
2016    if (nCal) lost += '<p class="slr-calwarn" role="status">Calendar not updated for ' + nCal + ' call' + (nCal === 1 ? '' : 's') +
2017      '. Your calls have moved to straight after the session, but ' + (nCal === 1 ? 'its event in your calendar still shows' : 'their events in your calendar still show') + ' the old time.</p>';
2018    var firstCard = next
2019      ? '<div class="slr-qcard"><span class="slr-lbl">' + (Q.after ? 'Next' : 'Your first 1:1') + ' · ' + _esc(_hm(next.starts_at)) + '</span>' + _nowCard(next) + '</div>'
2020      : (stayedOpen
2021        ? '<div class="slr-qcard"><p class="slr-ct">People still here can book a call now</p><p class="slr-rm">They appear here as they book, and you start each call when you are ready.</p></div>'
2022        : '<div class="slr-qcard"><p class="slr-ct">' + (d.booked || (d.calls || []).some(function (x) { return x.people.length; }) ? 'No more calls waiting' : 'Nobody booked a call this time') + '</p>' +
2023          '<p class="slr-rm">' + (d.booked ? 'Everyone booked has had their call.' : 'The times are closed and off your calendar.') + '</p></div>');
2024    var listCard = '<div class="slr-qcard"><p class="slr-ct">Your 1:1 calls · ' + ((d.calls || []).filter(function (x) { return x.people.length; }).length) + ' booked</p>' + rows +
2025      (stayedOpen ? '<p class="slr-rm">People who stayed to the end can still book until you start your first call.</p>'
2026        : '<p class="slr-rm">Nobody else can book now. The free times are closed and taken out of your calendar.</p>') +
2027      '<p class="slr-rm">Somebody who left early shows “Left the room” until they come back.</p></div>';
2028    return '<div class="slr-q">' + (Q.after
2029      ? '<div class="slr-qcol"><h2>' + _esc(head) + '</h2>' + (sub ? '<p class="slr-dim">' + _esc(sub) + '</p>' : '') + lost + '</div>' +
2030        '<div class="slr-qcol">' + firstCard + doneBtn +
2031          (list.length ? '<p class="slr-rm slr-on-dark">“Done for today” tells the people still waiting, and cancels their calls with an apology.</p>' : '') + back + '</div>'
2032      : '<div class="slr-qcol"><h2>' + _esc(head) + '</h2>' + (sub ? '<p class="slr-dim">' + _esc(sub) + '</p>' : '') + lost +
2033          (Q.limited ? '<p class="slr-dim">It ended because this month’s free live minutes ran out.</p>' : '') + firstCard + '</div>' +
2034        '<div class="slr-qcol">' + listCard + doneBtn + back + '</div>') + '</div>';
2035  }
2036  async function _queueAct(a, t) {
2037    if (!Q) return;
2038    var ctx = Q.ctx, sid = Q.sessionId, d = Q.data || { calls: [] };
2039    if (a === 'back') return _queueClose(true);
2040    if (a === 'start') {
2041      var call = (d.calls || []).filter(function (x) { return String(x.slot_id) === t.getAttribute('data-slot'); })[0];
2042      if (!call) return;
2043      t.disabled = true;
2044      _queueClose(false);
2045      return _goCall(ctx, sid, call);
2046    }
2047    if (a === 'skip') {
2048      var who = null;
2049      (d.calls || []).forEach(function (x) { x.people.forEach(function (p) { if (String(p.booking_id) === t.getAttribute('data-bid')) who = p; }); });
2050      var ok = window.CBSheet && await window.CBSheet.confirm({
2051        title: 'Move on from ' + _first(who && who.name) + '?',
2052        message: _first(who && who.name) + ' is told you moved on, and can reply to your email to find another time.',
2053        confirmLabel: 'Move on to the next person', cancelLabel: 'Wait a little longer' });
2054      if (!ok || !Q) return;
2055      try { await ctx.api('/community-hosting/spaces/' + ctx.space.id + '/quick-slots/bookings/' + t.getAttribute('data-bid') + '/skip', { method: 'POST', body: {} }); }
2056      catch (_) { ctx.toast('That did not work. Please try again.', true); }
2057      _queueStop(); return _queueTick();
2058    }
2059    if (a === 'done') {
2060      var waiting = _toCome(d).filter(function (x) { return x.room_state !== 'live'; }).map(function (x) { return _first(x.people[0].name); });
2061      var ok2 = window.CBSheet && await window.CBSheet.confirm({
2062        title: 'Finish for today?',
2063        message: (waiting.length ? _list(waiting) + (waiting.length === 1 ? ' is' : ' are') + ' still waiting. Their calls are cancelled and they are told, with an apology.' : 'Nobody else is waiting.'),
2064        confirmLabel: waiting.length ? 'Finish and tell them' : 'Finish', cancelLabel: 'Keep going' });
2065      if (!ok2 || !Q) return;
2066      try { await ctx.api('/community-hosting/spaces/' + ctx.space.id + '/quick-slots/done', { method: 'POST', body: { stream_id: sid } }); ctx.toast('Done for today. Everyone still waiting has been told.'); }
2067      catch (_) { ctx.toast('That did not work. Please try again.', true); return; }
2068      return _queueClose(true);
2069    }
2070  }
2071
2072  function _stopMedia() {
2073    if (!R) return;
2074    clearInterval(R.timer); clearInterval(R.tick);
2075    var ir = R.inRoom; R.inRoom = null;
2076    try { if (ir) ir.room.disconnect(); } catch (_) {}
2077    try { if (R.room) R.room.disconnect(); } catch (_) {}
2078    if (R.bgImage) { try { URL.revokeObjectURL(R.bgImage); } catch (_) {} }
2079  }
2080
2081  function _teardown() {
2082    _stopMedia(); _queueStop();
2083    var ctx = R && R.ctx;
2084    R = null;
2085    var el = document.getElementById('slr-room'); if (el) el.remove();
2086    document.body.classList.remove('slr-open');
2087    if (ctx && ctx.reload) ctx.reload();
2088  }
2089
2090  // Closing the tab mid-session ends it now rather than in ten minutes (the
2091  // same courtesy the course room has; the server's sweep stays the guarantee).
2092  window.addEventListener('pagehide', function () {
2093    if (!R || R.ended) return;
2094    try {
2095      var t = localStorage.getItem('cb_token');
2096      fetch('/api/community-hosting/streams/' + R.id + '/end', {
2097        method: 'POST', keepalive: true, credentials: 'same-origin',
2098        headers: Object.assign({ 'Content-Type': 'application/json' }, t ? { Authorization: 'Bearer ' + t } : {}),
2099        body: JSON.stringify({ post: !R.test }),
2100      });
2101    } catch (_) {}
2102  });
2103
2104  window.SpaceLive = { pane: pane, nextCard: nextCard, goLive: goLive, schedule: _schedule, tryRoom: tryRoom, allowanceLine: allowanceLine, openStudio: openStudio, _deal: _deal,
2105    lengthWarnHtml: lengthWarnHtml, minutesWords: minutesWords, _wireScheduleSheet: _wireScheduleSheet, _wireLengthWarn: _wireLengthWarn,
2106    // R12-SLOTS: the 1:1 calls' screens as pure functions, for the executed tests.
2107    _calls: { formHtml: _callsFormHtml, openHtml: _callsOpenHtml, queueHtml: _queueHtml, endAsk: _endAsk, sayLine: _sayLine },
2108    // A booked 1:1's room (space-booking.js "Open the room").
2109    openRoom: function (ctx, streamId) { return _startRoom(ctx, streamId); } };
2110})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.