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