openvibers/openvibe.live Commits
Section heads that say what they are, and the robot's panel in a window (#799)
Changes
@@@ -2184,3 +2184,31 @@ html[data-ov-motion="off"] #page-channel .ch-rec-dot { animation: none; }#page-channel .ch-rec-watch { position: absolute; inset: 0; z-index: 1; display: grid; place-items: center; color: #fff; font-size: 1.6rem; opacity: 0; background: rgba(0, 0, 0, .28); transition: opacity var(--ov-dur-2, .2s); pointer-events: none; }#page-channel .ch-rec-card:hover .ch-rec-watch, #page-channel .ch-rec-card:focus-visible .ch-rec-watch { opacity: 1; }#page-channel .ch-ib .ch-ib-t { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }++++/* Section heads, second pass (ask 355, the owner: "those button designs … confusing … needs a little more info and better++ alignment and sizing and consistency … improve the header of each, add an icon for the header … better font sizing").++ - The heading: its icon in a 30 px tile, the name at 1.15rem/800, the count as a quiet pill.++ - The tools: one height (36, 40 on touch), one radius, one tint; from 760 px each says what it is beside its icon++ (the pickers say the value they hold, with a caret); narrower, the icon carries it (the order's icon is the order,++ the filter shows a dot while it filters) and the title says it in words. */++#page-channel .ch-sec-h { align-items: center; gap: 10px; font-size: 1.15rem; line-height: 1.2; }++#page-channel .ch-sec-ic { display: inline-grid; place-items: center; flex: none; width: 30px; height: 30px; border-radius: 9px; font-size: .9rem;++ color: var(--accent-light, var(--accent)); background: color-mix(in srgb, var(--accent) 14%, transparent); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 22%, transparent); }++#page-channel svg.ch-sec-ic { padding: 7px; box-sizing: border-box; }++#page-channel .ch-sec-n { display: inline-flex; align-items: center; height: 22px; padding: 0 8px; border-radius: 999px; font-size: .74rem; background: color-mix(in srgb, var(--text-primary, #fff) 7%, transparent); }++#page-channel .ch-ib { gap: 6px; border-color: color-mix(in srgb, var(--text-primary, #fff) 8%, transparent); }++#page-channel .ch-ib .ch-ib-caret { display: none; font-size: .6rem; opacity: .7; }++@media (min-width: 760px) {++ #page-channel .ch-ib:has(.ch-ib-t, .sel-all-label) { display: inline-flex; width: auto; padding: 0 12px 0 10px; font-size: .82rem; font-weight: 650; }++ #page-channel .ch-ib .ch-ib-t, #page-channel .ch-ib .sel-all-label { position: static; width: auto; height: auto; overflow: visible; clip: auto; max-width: 16ch; text-overflow: ellipsis; white-space: nowrap; }++ #page-channel .ch-ib .ch-ib-t { overflow: hidden; }++ #page-channel .ch-ib-pick .ch-ib-caret { display: inline-block; margin-left: -1px; }++ #page-channel .ch-ib-pick.is-on::after { top: 5px; right: 5px; }++}++@media (min-width: 760px) and (max-width: 1100px) { #page-channel .ch-ib .ch-ib-more { display: none; } }++@media (max-width: 1100px) { #page-channel .ch-ib .ch-ib-more { display: none; } }++++/* The robot's panel in a window (ask 356): the frame fills the window's body; the window sizes it. */++.ch-bot-win { height: 100%; min-height: 200px; }++.ch-bot-win iframe { display: block; width: 100%; height: 100%; border: 0; background: transparent; }++@media (max-width: 768px) { #page-channel [data-bot-mode="window"] { display: none !important; } }@@@ -64,6 +64,7 @@<button type="button" role="menuitemradio" data-bot-mode="overlay">On the video</button><button type="button" role="menuitemradio" data-bot-mode="compact">A row under the video</button><button type="button" role="menuitemradio" data-bot-mode="panel">The full panel</button>++ <button type="button" role="menuitemradio" data-bot-mode="window">In a window</button><button type="button" role="menuitemradio" data-bot-mode="hidden">Hidden</button></div></div>@@@ -126,6 +127,7 @@<button type="button" data-bot-mode="overlay" title="On the video" aria-label="On the video"><i class="fa-solid fa-layer-group" aria-hidden="true"></i><span>On video</span></button><button type="button" data-bot-mode="compact" title="A row under the video" aria-label="A row under the video"><i class="fa-solid fa-sliders" aria-hidden="true"></i><span>Row</span></button><button type="button" data-bot-mode="panel" title="The full panel" aria-label="The full panel"><i class="fa-solid fa-table-cells" aria-hidden="true"></i><span>Panel</span></button>++ <button type="button" data-bot-mode="window" title="In a window you can move and size" aria-label="In a window"><i class="fa-solid fa-window-maximize" aria-hidden="true"></i><span>Window</span></button><button type="button" data-bot-mode="hidden" title="Hide the controls" aria-label="Hide the controls"><i class="fa-solid fa-eye-slash" aria-hidden="true"></i><span>Hide</span></button></div><a class="ch-bot-open" id="ch-bot-open" href="https://openvibe.bot/" target="_blank" rel="noopener" title="Open on openvibe.bot" aria-label="Open on openvibe.bot"><i class="fa-solid fa-arrow-up-right-from-square" aria-hidden="true"></i></a>@@@ -416,7 +418,7 @@<!-- Agents (Agent Streams S2): this channel's AI agent streams, live and past. --><section class="ch-tab-panel" id="ch-panel-agents" aria-labelledby="ch-h-agents">-- <h2 class="ch-sec-h" id="ch-h-agents">Agents</h2>++ <h2 class="ch-sec-h" id="ch-h-agents"><i class="fa-solid fa-robot ch-sec-ic" aria-hidden="true"></i>Agents</h2><p class="ch-panel-hint"><i class="fa-solid fa-robot"></i> AI agents working on this channel — live now, and what they finished.</p><h3 class="agent-schedules-title" id="ch-agents-schedules-title" hidden>Coming up</h3><div class="agent-schedules" id="ch-agents-schedules"></div>@@@ -426,12 +428,12 @@<!-- Videos --><section class="ch-tab-panel" id="ch-panel-videos" aria-labelledby="ch-h-videos">-- <h2 class="ch-sec-h" id="ch-h-videos">Videos <span class="ch-sec-n" id="ch-vods-n" hidden></span></h2>++ <h2 class="ch-sec-h" id="ch-h-videos"><i class="fa-solid fa-video ch-sec-ic" aria-hidden="true"></i>Videos <span class="ch-sec-n" id="ch-vods-n" hidden></span></h2><div class="ch-panel-bar"><div id="ch-vods-filter-bar" class="ch-vods-filter-bar" style="display:none"></div><button type="button" class="ch-ib sel-all-btn" data-sel-container="ch-vods-grid" style="display:none" onclick="_selAllToggle(this.dataset.selContainer)"><i class="fa-solid fa-check-double" aria-hidden="true"></i><span class="sel-all-label">Select all</span></button>-- <a id="ch-vods-video-link" class="ch-ib" href="#" style="display:none" title="This channel's recordings and clips on OpenVibe.Video, with AI chapters, captions and playlists" aria-label="On OpenVibe.Video"><i class="fa-solid fa-film" aria-hidden="true"></i></a>-- <a class="ch-view-all ch-ib" id="ch-vods-all" href="#" title="View all videos"><i class="fa-solid fa-table-cells" aria-hidden="true"></i><span class="ch-ib-t">View all videos</span></a>++ <a id="ch-vods-video-link" class="ch-ib" href="#" style="display:none" title="This channel's recordings and clips on OpenVibe.Video, with AI chapters, captions and playlists" aria-label="On OpenVibe.Video"><i class="fa-solid fa-film" aria-hidden="true"></i><span class="ch-ib-t">Video</span></a>++ <a class="ch-view-all ch-ib" id="ch-vods-all" href="#" title="View all videos"><i class="fa-solid fa-table-cells" aria-hidden="true"></i><span class="ch-ib-t">View all<span class="ch-ib-more"> videos</span></span></a></div><div class="stream-grid" id="ch-vods-grid"><p class="muted">Loading videos…</p></div><div id="ch-vods-pagination" class="pastes-pagination" style="display:none"></div>@@@ -439,9 +441,9 @@<!-- Clips (clips OF this streamer's streams) --><section class="ch-tab-panel" id="ch-panel-clips" aria-labelledby="ch-h-clips">-- <h2 class="ch-sec-h" id="ch-h-clips">Clips <span class="ch-sec-n" id="ch-clips-of-n" hidden></span></h2>++ <h2 class="ch-sec-h" id="ch-h-clips"><i class="fa-solid fa-scissors ch-sec-ic" aria-hidden="true"></i>Clips <span class="ch-sec-n" id="ch-clips-of-n" hidden></span></h2><div class="ch-panel-bar">-- <a class="ch-view-all ch-ib" id="ch-clips-of-all" href="#" title="View all clips"><i class="fa-solid fa-table-cells" aria-hidden="true"></i><span class="ch-ib-t">View all clips</span></a>++ <a class="ch-view-all ch-ib" id="ch-clips-of-all" href="#" title="View all clips"><i class="fa-solid fa-table-cells" aria-hidden="true"></i><span class="ch-ib-t">View all<span class="ch-ib-more"> clips</span></span></a></div><p class="ch-panel-hint" id="ch-clips-of-header"><i class="fa-solid fa-scissors"></i> Moments viewers clipped from these streams.</p><div class="stream-grid" id="ch-clips-of-grid"><p class="muted">Loading clips…</p></div>@@@ -456,12 +458,12 @@<!-- Clips Taken (clips this streamer made of others) --><section class="ch-tab-panel" id="ch-panel-clips-taken" aria-labelledby="ch-h-clips-taken">-- <h2 class="ch-sec-h" id="ch-h-clips-taken">Clips taken <span class="ch-sec-n" id="ch-clips-taken-n" hidden></span></h2>++ <h2 class="ch-sec-h" id="ch-h-clips-taken"><i class="fa-solid fa-film ch-sec-ic" aria-hidden="true"></i>Clips taken <span class="ch-sec-n" id="ch-clips-taken-n" hidden></span></h2><p class="ch-panel-hint"><i class="fa-solid fa-film"></i> Clips this streamer made from other channels' streams.</p><div class="ch-panel-bar"><div class="clips-taken-filters" id="clips-taken-filters" style="display:none"></div><button type="button" class="ch-ib sel-all-btn" data-sel-container="ch-clips-grid" style="display:none" onclick="_selAllToggle(this.dataset.selContainer)"><i class="fa-solid fa-check-double" aria-hidden="true"></i><span class="sel-all-label">Select all</span></button>-- <a class="ch-view-all ch-ib" id="ch-clips-taken-all" href="#" title="View all clips taken"><i class="fa-solid fa-table-cells" aria-hidden="true"></i><span class="ch-ib-t">View all clips taken</span></a>++ <a class="ch-view-all ch-ib" id="ch-clips-taken-all" href="#" title="View all clips taken"><i class="fa-solid fa-table-cells" aria-hidden="true"></i><span class="ch-ib-t">View all<span class="ch-ib-more"> clips taken</span></span></a></div><div class="stream-grid" id="ch-clips-grid"><p class="muted">Loading clips…</p></div><div id="ch-clips-pagination" class="pastes-pagination" style="display:none"></div>@@@ -469,11 +471,11 @@<!-- Pastes --><section class="ch-tab-panel" id="ch-panel-pastes" aria-labelledby="ch-h-pastes">-- <h2 class="ch-sec-h" id="ch-h-pastes">Pastes <span class="ch-sec-n" id="ch-pastes-n" hidden></span></h2>++ <h2 class="ch-sec-h" id="ch-h-pastes"><i class="fa-solid fa-paste ch-sec-ic" aria-hidden="true"></i>Pastes <span class="ch-sec-n" id="ch-pastes-n" hidden></span></h2><div class="ch-panel-bar" id="ch-pastes-header"><span id="ch-pastes-sort"></span><button type="button" class="ch-ib sel-all-btn" data-sel-container="ch-pastes-grid" style="display:none" onclick="_selAllToggle(this.dataset.selContainer)"><i class="fa-solid fa-check-double" aria-hidden="true"></i><span class="sel-all-label">Select all</span></button>-- <a class="ch-view-all ch-ib" id="ch-pastes-all" href="#" title="View all pastes"><i class="fa-solid fa-table-cells" aria-hidden="true"></i><span class="ch-ib-t">View all pastes</span></a>++ <a class="ch-view-all ch-ib" id="ch-pastes-all" href="#" title="View all pastes"><i class="fa-solid fa-table-cells" aria-hidden="true"></i><span class="ch-ib-t">View all<span class="ch-ib-more"> pastes</span></span></a></div><div id="ch-pastes-grid" class="pastes-grid"><p class="muted">Loading pastes…</p></div><div id="ch-pastes-pagination" class="pastes-pagination" style="display:none"></div>@@@ -481,13 +483,13 @@<!-- Media Request --><section class="ch-tab-panel" id="ch-panel-media" aria-labelledby="ch-h-media">-- <h2 class="ch-sec-h" id="ch-h-media">Media requests</h2>++ <h2 class="ch-sec-h" id="ch-h-media"><i class="fa-solid fa-photo-film ch-sec-ic" aria-hidden="true"></i>Media requests</h2><div id="ch-media-content"><div class="loading">Loading…</div></div></section><!-- Analytics --><section class="ch-tab-panel" id="ch-panel-analytics" aria-labelledby="ch-h-analytics">-- <details class="ch-fold" data-ch-fold="analytics"><summary><h2 class="ch-sec-h" id="ch-h-analytics">Analytics</h2><span class="ch-fold-say">Show</span></summary>++ <details class="ch-fold" data-ch-fold="analytics"><summary><h2 class="ch-sec-h" id="ch-h-analytics"><i class="fa-solid fa-chart-line ch-sec-ic" aria-hidden="true"></i>Analytics</h2><span class="ch-fold-say">Show</span></summary><div class="ch-panel-bar" id="ch-analytics-header"><div class="ch-analytics-period-btns" id="ch-analytics-period-btns"><button class="btn btn-sm btn-ghost active" data-days="7">7d</button>@@@ -518,14 +520,14 @@<!-- AI Timeline — the streamer's whole AI-observed history --><section class="ch-tab-panel" id="ch-panel-ai-timeline" aria-labelledby="ch-h-ai-timeline">-- <details class="ch-fold" data-ch-fold="ai-timeline"><summary><h2 class="ch-sec-h" id="ch-h-ai-timeline">AI timeline</h2><span class="ch-fold-n" id="ch-fold-n-ai-timeline"></span><span class="ch-fold-say">Show</span></summary>++ <details class="ch-fold" data-ch-fold="ai-timeline"><summary><h2 class="ch-sec-h" id="ch-h-ai-timeline"><i class="fa-solid fa-clock-rotate-left ch-sec-ic" aria-hidden="true"></i>AI timeline</h2><span class="ch-fold-n" id="ch-fold-n-ai-timeline"></span><span class="ch-fold-say">Show</span></summary><div id="ch-ai-timeline"><div class="loading">Loading AI timeline…</div></div></details></section><!-- About (weather is now an optional panel inside here) --><section class="ch-tab-panel" id="ch-panel-about" aria-labelledby="ch-h-about">-- <h2 class="ch-sec-h" id="ch-h-about">About</h2>++ <h2 class="ch-sec-h" id="ch-h-about"><i class="fa-solid fa-circle-info ch-sec-ic" aria-hidden="true"></i>About</h2><div id="ch-about-content" class="ch-about-content"></div></section></div>@@@ -292,14 +292,14 @@ async function renderChannelVodsSection(username, liveStreams, vods, meta = {})const orderName = { newest: 'Newest first', oldest: 'Oldest first', views: 'Most views', peak_viewers: 'Peak viewers' }[currentChannelVodOrder] || 'Newest first';filterBar.innerHTML = `<label class="ch-ib ch-ib-pick${currentChannelVodFilter !== null ? ' is-on' : ''}" title="${esc(currentChannelVodFilter !== null ? `Stream: ${msName || 'one stream'}` : 'All streams')}">-- <i class="fa-solid fa-filter" aria-hidden="true"></i>++ <i class="fa-solid fa-filter" aria-hidden="true"></i><span class="ch-ib-t">${esc(currentChannelVodFilter !== null ? (msName || 'One stream') : 'All streams')}</span><i class="fa-solid fa-chevron-down ch-ib-caret" aria-hidden="true"></i><select class="ch-vods-filter-select" data-ov-native aria-label="Which stream" onchange="setChannelVodFilter(this.value ? parseInt(this.value) : null)"><option value="" ${currentChannelVodFilter === null ? 'selected' : ''}>All streams</option>${msOptions}</select></label><label class="ch-ib ch-ib-pick" data-order="${esc(currentChannelVodOrder)}" title="${esc(orderName)}">-- <i class="fa-solid ${currentChannelVodOrder === 'oldest' ? 'fa-arrow-down-short-wide' : 'fa-arrow-down-wide-short'}" aria-hidden="true"></i>++ <i class="fa-solid ${{ oldest: 'fa-arrow-down-short-wide', views: 'fa-eye', peak_viewers: 'fa-users' }[currentChannelVodOrder] || 'fa-arrow-down-wide-short'}" aria-hidden="true"></i><span class="ch-ib-t">${esc(orderName)}</span><i class="fa-solid fa-chevron-down ch-ib-caret" aria-hidden="true"></i><select class="ch-vods-filter-select" data-ov-native aria-label="Order" onchange="setChannelVodOrder(this.value)">${orderOptions}</select>@@@ -3075,7 +3075,41 @@ function _applyBotEmbed(botEmbed, username) {}return frame;}-- const MODE_NAME = { overlay: 'on the video', compact: 'a row under the video', panel: 'the full panel', hidden: 'hidden' };++ const MODE_NAME = { overlay: 'on the video', compact: 'a row under the video', panel: 'the full panel', window: 'in a window', hidden: 'hidden' };++ // The panel in the shared window (ov-window.js beside the navbar's scripts): moved by its head, sized by its corner,++ // its place and size kept per robot. Closing it puts the panel back under the video.++ function closeWindow() {++ const w = _applyBotEmbed.win;++ if (!w) return;++ _applyBotEmbed.win = null;++ _applyBotEmbed.closingWin = true;++ try { w.close(); } catch { /* already gone */ }++ _applyBotEmbed.closingWin = false;++ }++ function withWindow(fn) {++ if (window.OVWindow) return fn();++ let sc = document.getElementById('ov-window-script');++ if (!sc) { sc = document.createElement('script'); sc.id = 'ov-window-script'; sc.async = true; sc.src = '/shared/ov-window.js'; document.head.appendChild(sc); }++ sc.addEventListener('load', () => { if (window.OVWindow) fn(); }, { once: true });++ }++ function openWindow() {++ withWindow(() => {++ if (_applyBotEmbed.mode !== 'window' || _applyBotEmbed.win) return;++ const wrap = document.createElement('div');++ wrap.className = 'ch-bot-win';++ const w = window.OVWindow.open({++ // It grows out of the switch's Window button (on a page load too, where nothing was pressed).++ from: (cardSwitch && cardSwitch.querySelector && cardSwitch.querySelector('[data-bot-mode="window"]')) || undefined,++ id: `ov-bot-${robotId}`, kind: 'panel', title: 'Robot', label: 'Robot controls', width: 440, body: wrap,++ resizable: true, minWidth: 300, minHeight: 240,++ onClose: () => { if (!_applyBotEmbed.closingWin && _applyBotEmbed.mode === 'window') { _applyBotEmbed.win = null; _applyBotEmbed.choose('panel'); } },++ });++ if (w.body) w.body.style.padding = '0';++ if (w.el && !w.el.style.height) w.el.style.height = '540px';++ _applyBotEmbed.win = w;++ _applyBotEmbed.frame = mountIn(wrap, 'panel', false);++ });++ }// The page knows the place (phone layout in channel.css) and, for the full panel on a phone, which tab is open.// The Robot tab is open while the full panel is the place and the person has not gone back to Chat; any other place// keeps the sheet on Chat, and the Robot tab offers the panel.@@@ -3128,7 +3162,14 @@ function _applyBotEmbed(botEmbed, username) {if (switchWrap) switchWrap.hidden = !robotId;if (cardBar) cardBar.hidden = !robotId;if (openLink && botEmbed && typeof botEmbed.url === 'string') openLink.href = botEmbed.url.replace(/\/embed$/, '');-- if (mode === 'overlay') {++ if (mode !== 'window') closeWindow();++ if (mode === 'window') {++ stopFrame();++ if (slot) { clear(slot); slot.hidden = true; }++ clear(box); box.hidden = true;++ if (section) section.hidden = false;++ openWindow();++ } else if (mode === 'overlay') {if (slot) {slot.hidden = false;_applyBotEmbed.frame = mountIn(slot, 'overlay', false);@@@ -3237,6 +3278,7 @@ function _applyBotEmbed(botEmbed, username) {render();if (window.ov) ov.scope().onDispose(() => {stopFrame();++ closeWindow();clear(slot);pageClasses(null);if (typeof BotEmbed !== 'undefined') BotEmbed.mount(box, null);@@@ -37,7 +37,8 @@const PANEL_MODES = ['panel', 'overlay', 'compact'];// Where a viewer puts the controls: over the video, a row under it (Bot's compact view), the full panel, or hidden.-- const VIEW_MODES = ['overlay', 'compact', 'panel', 'hidden'];++ // 'window' (ask 356, 2026-11-11: "can be popped out … and resized"): the full panel in the shared window (ov-window.js).++ const VIEW_MODES = ['overlay', 'compact', 'panel', 'window', 'hidden'];const CHOICE_KEY = 'ov:live:bot-controls';function embedSrc(botEmbed, mode = null) {@@@ -109,7 +110,10 @@return phone ? 'overlay' : 'panel';}function effectiveMode(botEmbed, stored, opts) {-- return validView(stored) || defaultMode(botEmbed, opts);++ const v = validView(stored);++ // A phone has no floating windows: its full panel is the Robot tab.++ if (v === 'window' && opts && opts.phone) return 'panel';++ return v || defaultMode(botEmbed, opts);}function readChoice(win, channel, robotId) {try {@@@ -211,6 +215,8 @@// The row (Bot's compact view) is about 120 px: it keeps its own height, the panel at least MIN_HEIGHT.const clamped = Math.max(mode === 'compact' ? COMPACT_MIN : MIN_HEIGHT, Math.min(MAX_HEIGHT, Math.round(height)));try {++ // In a window the window's size is the frame's: the panel scrolls inside it.++ if (typeof frame.closest === 'function' && frame.closest('.ovw')) return;const card = typeof frame.closest === 'function' ? frame.closest('#ch-bot-container') : null;const host = card || frame;host.style.height = `${clamped}px`;@@@ -126,7 +126,9 @@ async function check(name, fn) {assert.strictEqual(BotEmbed.effectiveMode(overlayDefault, 'bogus'), 'overlay', 'a bad stored value is ignored');assert.strictEqual(BotEmbed.effectiveMode({ ...SIM, controls_mode: 'bogus' }, null), 'panel', 'a bad streamer default is panel');// Ask 353 (2026-11-11): a fourth place, a row under the video; on a phone held upright the panel starts on the video.-- assert.deepStrictEqual(BotEmbed.VIEW_MODES, ['overlay', 'compact', 'panel', 'hidden']);++ assert.deepStrictEqual(BotEmbed.VIEW_MODES, ['overlay', 'compact', 'panel', 'window', 'hidden']);++ assert.strictEqual(BotEmbed.effectiveMode(SIM, 'window'), 'window', 'a computer may pop the panel out');++ assert.strictEqual(BotEmbed.effectiveMode(SIM, 'window', { phone: true }), 'panel', 'a phone keeps it in the Robot tab');assert.strictEqual(BotEmbed.effectiveMode(SIM, null, { phone: true }), 'overlay', 'a phone starts with the controls on the video, so the chat keeps its room');assert.strictEqual(BotEmbed.effectiveMode({ ...SIM, controls_mode: 'compact' }, null, { phone: true }), 'compact', 'a streamer\'s row stays a row on a phone');assert.strictEqual(BotEmbed.effectiveMode(overlayDefault, null, { phone: true }), 'overlay', 'the streamer\'s on-the-video stays on a phone');
@@ -2184,3 +2184,31 @@ html[data-ov-motion="off"] #page-channel .ch-rec-dot { animation: none; }
@@ -2184,3 +2184,31 @@ html[data-ov-motion="off"] #page-channel .ch-rec-dot { animation: none; }
#page-channel .ch-rec-watch { position: absolute; inset: 0; z-index: 1; display: grid; place-items: center; color: #fff; font-size: 1.6rem; opacity: 0; background: rgba(0, 0, 0, .28); transition: opacity var(--ov-dur-2, .2s); pointer-events: none; }
#page-channel .ch-rec-watch { position: absolute; inset: 0; z-index: 1; display: grid; place-items: center; color: #fff; font-size: 1.6rem; opacity: 0; background: rgba(0, 0, 0, .28); transition: opacity var(--ov-dur-2, .2s); pointer-events: none; }
#page-channel .ch-rec-card:hover .ch-rec-watch, #page-channel .ch-rec-card:focus-visible .ch-rec-watch { opacity: 1; }
#page-channel .ch-rec-card:hover .ch-rec-watch, #page-channel .ch-rec-card:focus-visible .ch-rec-watch { opacity: 1; }
#page-channel .ch-ib .ch-ib-t { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
#page-channel .ch-ib .ch-ib-t { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
+
+/* Section heads, second pass (ask 355, the owner: "those button designs … confusing … needs a little more info and better
+ alignment and sizing and consistency … improve the header of each, add an icon for the header … better font sizing").
+ - The heading: its icon in a 30 px tile, the name at 1.15rem/800, the count as a quiet pill.
+ - The tools: one height (36, 40 on touch), one radius, one tint; from 760 px each says what it is beside its icon
+ (the pickers say the value they hold, with a caret); narrower, the icon carries it (the order's icon is the order,
+ the filter shows a dot while it filters) and the title says it in words. */
+#page-channel .ch-sec-h { align-items: center; gap: 10px; font-size: 1.15rem; line-height: 1.2; }
+#page-channel .ch-sec-ic { display: inline-grid; place-items: center; flex: none; width: 30px; height: 30px; border-radius: 9px; font-size: .9rem;
+ color: var(--accent-light, var(--accent)); background: color-mix(in srgb, var(--accent) 14%, transparent); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 22%, transparent); }
+#page-channel svg.ch-sec-ic { padding: 7px; box-sizing: border-box; }
+#page-channel .ch-sec-n { display: inline-flex; align-items: center; height: 22px; padding: 0 8px; border-radius: 999px; font-size: .74rem; background: color-mix(in srgb, var(--text-primary, #fff) 7%, transparent); }
+#page-channel .ch-ib { gap: 6px; border-color: color-mix(in srgb, var(--text-primary, #fff) 8%, transparent); }
+#page-channel .ch-ib .ch-ib-caret { display: none; font-size: .6rem; opacity: .7; }
+@media (min-width: 760px) {
+ #page-channel .ch-ib:has(.ch-ib-t, .sel-all-label) { display: inline-flex; width: auto; padding: 0 12px 0 10px; font-size: .82rem; font-weight: 650; }
+ #page-channel .ch-ib .ch-ib-t, #page-channel .ch-ib .sel-all-label { position: static; width: auto; height: auto; overflow: visible; clip: auto; max-width: 16ch; text-overflow: ellipsis; white-space: nowrap; }
+ #page-channel .ch-ib .ch-ib-t { overflow: hidden; }
+ #page-channel .ch-ib-pick .ch-ib-caret { display: inline-block; margin-left: -1px; }
+ #page-channel .ch-ib-pick.is-on::after { top: 5px; right: 5px; }
+}
+@media (min-width: 760px) and (max-width: 1100px) { #page-channel .ch-ib .ch-ib-more { display: none; } }
+@media (max-width: 1100px) { #page-channel .ch-ib .ch-ib-more { display: none; } }
+
+/* The robot's panel in a window (ask 356): the frame fills the window's body; the window sizes it. */
+.ch-bot-win { height: 100%; min-height: 200px; }
+.ch-bot-win iframe { display: block; width: 100%; height: 100%; border: 0; background: transparent; }
+@media (max-width: 768px) { #page-channel [data-bot-mode="window"] { display: none !important; } }
@@ -64,6 +64,7 @@
@@ -64,6 +64,7 @@
<button type="button" role="menuitemradio" data-bot-mode="overlay">On the video</button>
<button type="button" role="menuitemradio" data-bot-mode="overlay">On the video</button>
<button type="button" role="menuitemradio" data-bot-mode="compact">A row under the video</button>
<button type="button" role="menuitemradio" data-bot-mode="compact">A row under the video</button>
<button type="button" role="menuitemradio" data-bot-mode="panel">The full panel</button>
<button type="button" role="menuitemradio" data-bot-mode="panel">The full panel</button>
+ <button type="button" role="menuitemradio" data-bot-mode="window">In a window</button>
<button type="button" role="menuitemradio" data-bot-mode="hidden">Hidden</button>
<button type="button" role="menuitemradio" data-bot-mode="hidden">Hidden</button>
</div>
</div>
</div>
</div>
@@ -126,6 +127,7 @@
@@ -126,6 +127,7 @@
<button type="button" data-bot-mode="overlay" title="On the video" aria-label="On the video"><i class="fa-solid fa-layer-group" aria-hidden="true"></i><span>On video</span></button>
<button type="button" data-bot-mode="overlay" title="On the video" aria-label="On the video"><i class="fa-solid fa-layer-group" aria-hidden="true"></i><span>On video</span></button>
<button type="button" data-bot-mode="compact" title="A row under the video" aria-label="A row under the video"><i class="fa-solid fa-sliders" aria-hidden="true"></i><span>Row</span></button>
<button type="button" data-bot-mode="compact" title="A row under the video" aria-label="A row under the video"><i class="fa-solid fa-sliders" aria-hidden="true"></i><span>Row</span></button>
<button type="button" data-bot-mode="panel" title="The full panel" aria-label="The full panel"><i class="fa-solid fa-table-cells" aria-hidden="true"></i><span>Panel</span></button>
<button type="button" data-bot-mode="panel" title="The full panel" aria-label="The full panel"><i class="fa-solid fa-table-cells" aria-hidden="true"></i><span>Panel</span></button>
+ <button type="button" data-bot-mode="window" title="In a window you can move and size" aria-label="In a window"><i class="fa-solid fa-window-maximize" aria-hidden="true"></i><span>Window</span></button>
<button type="button" data-bot-mode="hidden" title="Hide the controls" aria-label="Hide the controls"><i class="fa-solid fa-eye-slash" aria-hidden="true"></i><span>Hide</span></button>
<button type="button" data-bot-mode="hidden" title="Hide the controls" aria-label="Hide the controls"><i class="fa-solid fa-eye-slash" aria-hidden="true"></i><span>Hide</span></button>
</div>
</div>
<a class="ch-bot-open" id="ch-bot-open" href="https://openvibe.bot/" target="_blank" rel="noopener" title="Open on openvibe.bot" aria-label="Open on openvibe.bot"><i class="fa-solid fa-arrow-up-right-from-square" aria-hidden="true"></i></a>
<a class="ch-bot-open" id="ch-bot-open" href="https://openvibe.bot/" target="_blank" rel="noopener" title="Open on openvibe.bot" aria-label="Open on openvibe.bot"><i class="fa-solid fa-arrow-up-right-from-square" aria-hidden="true"></i></a>
@@ -416,7 +418,7 @@
@@ -416,7 +418,7 @@
<!-- Agents (Agent Streams S2): this channel's AI agent streams, live and past. -->
<!-- Agents (Agent Streams S2): this channel's AI agent streams, live and past. -->
<section class="ch-tab-panel" id="ch-panel-agents" aria-labelledby="ch-h-agents">
<section class="ch-tab-panel" id="ch-panel-agents" aria-labelledby="ch-h-agents">
- <h2 class="ch-sec-h" id="ch-h-agents">Agents</h2>
+ <h2 class="ch-sec-h" id="ch-h-agents"><i class="fa-solid fa-robot ch-sec-ic" aria-hidden="true"></i>Agents</h2>
<p class="ch-panel-hint"><i class="fa-solid fa-robot"></i> AI agents working on this channel — live now, and what they finished.</p>
<p class="ch-panel-hint"><i class="fa-solid fa-robot"></i> AI agents working on this channel — live now, and what they finished.</p>
<h3 class="agent-schedules-title" id="ch-agents-schedules-title" hidden>Coming up</h3>
<h3 class="agent-schedules-title" id="ch-agents-schedules-title" hidden>Coming up</h3>
<div class="agent-schedules" id="ch-agents-schedules"></div>
<div class="agent-schedules" id="ch-agents-schedules"></div>
@@ -426,12 +428,12 @@
@@ -426,12 +428,12 @@
<!-- Videos -->
<!-- Videos -->
<section class="ch-tab-panel" id="ch-panel-videos" aria-labelledby="ch-h-videos">
<section class="ch-tab-panel" id="ch-panel-videos" aria-labelledby="ch-h-videos">
- <h2 class="ch-sec-h" id="ch-h-videos">Videos <span class="ch-sec-n" id="ch-vods-n" hidden></span></h2>
+ <h2 class="ch-sec-h" id="ch-h-videos"><i class="fa-solid fa-video ch-sec-ic" aria-hidden="true"></i>Videos <span class="ch-sec-n" id="ch-vods-n" hidden></span></h2>
<div class="ch-panel-bar">
<div class="ch-panel-bar">
<div id="ch-vods-filter-bar" class="ch-vods-filter-bar" style="display:none"></div>
<div id="ch-vods-filter-bar" class="ch-vods-filter-bar" style="display:none"></div>
<button type="button" class="ch-ib sel-all-btn" data-sel-container="ch-vods-grid" style="display:none" onclick="_selAllToggle(this.dataset.selContainer)"><i class="fa-solid fa-check-double" aria-hidden="true"></i><span class="sel-all-label">Select all</span></button>
<button type="button" class="ch-ib sel-all-btn" data-sel-container="ch-vods-grid" style="display:none" onclick="_selAllToggle(this.dataset.selContainer)"><i class="fa-solid fa-check-double" aria-hidden="true"></i><span class="sel-all-label">Select all</span></button>
- <a id="ch-vods-video-link" class="ch-ib" href="#" style="display:none" title="This channel's recordings and clips on OpenVibe.Video, with AI chapters, captions and playlists" aria-label="On OpenVibe.Video"><i class="fa-solid fa-film" aria-hidden="true"></i></a>
- <a class="ch-view-all ch-ib" id="ch-vods-all" href="#" title="View all videos"><i class="fa-solid fa-table-cells" aria-hidden="true"></i><span class="ch-ib-t">View all videos</span></a>
+ <a id="ch-vods-video-link" class="ch-ib" href="#" style="display:none" title="This channel's recordings and clips on OpenVibe.Video, with AI chapters, captions and playlists" aria-label="On OpenVibe.Video"><i class="fa-solid fa-film" aria-hidden="true"></i><span class="ch-ib-t">Video</span></a>
+ <a class="ch-view-all ch-ib" id="ch-vods-all" href="#" title="View all videos"><i class="fa-solid fa-table-cells" aria-hidden="true"></i><span class="ch-ib-t">View all<span class="ch-ib-more"> videos</span></span></a>
</div>
</div>
<div class="stream-grid" id="ch-vods-grid"><p class="muted">Loading videos…</p></div>
<div class="stream-grid" id="ch-vods-grid"><p class="muted">Loading videos…</p></div>
<div id="ch-vods-pagination" class="pastes-pagination" style="display:none"></div>
<div id="ch-vods-pagination" class="pastes-pagination" style="display:none"></div>
@@ -439,9 +441,9 @@
@@ -439,9 +441,9 @@
<!-- Clips (clips OF this streamer's streams) -->
<!-- Clips (clips OF this streamer's streams) -->
<section class="ch-tab-panel" id="ch-panel-clips" aria-labelledby="ch-h-clips">
<section class="ch-tab-panel" id="ch-panel-clips" aria-labelledby="ch-h-clips">
- <h2 class="ch-sec-h" id="ch-h-clips">Clips <span class="ch-sec-n" id="ch-clips-of-n" hidden></span></h2>
+ <h2 class="ch-sec-h" id="ch-h-clips"><i class="fa-solid fa-scissors ch-sec-ic" aria-hidden="true"></i>Clips <span class="ch-sec-n" id="ch-clips-of-n" hidden></span></h2>
<div class="ch-panel-bar">
<div class="ch-panel-bar">
- <a class="ch-view-all ch-ib" id="ch-clips-of-all" href="#" title="View all clips"><i class="fa-solid fa-table-cells" aria-hidden="true"></i><span class="ch-ib-t">View all clips</span></a>
+ <a class="ch-view-all ch-ib" id="ch-clips-of-all" href="#" title="View all clips"><i class="fa-solid fa-table-cells" aria-hidden="true"></i><span class="ch-ib-t">View all<span class="ch-ib-more"> clips</span></span></a>
</div>
</div>
<p class="ch-panel-hint" id="ch-clips-of-header"><i class="fa-solid fa-scissors"></i> Moments viewers clipped from these streams.</p>
<p class="ch-panel-hint" id="ch-clips-of-header"><i class="fa-solid fa-scissors"></i> Moments viewers clipped from these streams.</p>
<div class="stream-grid" id="ch-clips-of-grid"><p class="muted">Loading clips…</p></div>
<div class="stream-grid" id="ch-clips-of-grid"><p class="muted">Loading clips…</p></div>
@@ -456,12 +458,12 @@
@@ -456,12 +458,12 @@
<!-- Clips Taken (clips this streamer made of others) -->
<!-- Clips Taken (clips this streamer made of others) -->
<section class="ch-tab-panel" id="ch-panel-clips-taken" aria-labelledby="ch-h-clips-taken">
<section class="ch-tab-panel" id="ch-panel-clips-taken" aria-labelledby="ch-h-clips-taken">
- <h2 class="ch-sec-h" id="ch-h-clips-taken">Clips taken <span class="ch-sec-n" id="ch-clips-taken-n" hidden></span></h2>
+ <h2 class="ch-sec-h" id="ch-h-clips-taken"><i class="fa-solid fa-film ch-sec-ic" aria-hidden="true"></i>Clips taken <span class="ch-sec-n" id="ch-clips-taken-n" hidden></span></h2>
<p class="ch-panel-hint"><i class="fa-solid fa-film"></i> Clips this streamer made from other channels' streams.</p>
<p class="ch-panel-hint"><i class="fa-solid fa-film"></i> Clips this streamer made from other channels' streams.</p>
<div class="ch-panel-bar">
<div class="ch-panel-bar">
<div class="clips-taken-filters" id="clips-taken-filters" style="display:none"></div>
<div class="clips-taken-filters" id="clips-taken-filters" style="display:none"></div>
<button type="button" class="ch-ib sel-all-btn" data-sel-container="ch-clips-grid" style="display:none" onclick="_selAllToggle(this.dataset.selContainer)"><i class="fa-solid fa-check-double" aria-hidden="true"></i><span class="sel-all-label">Select all</span></button>
<button type="button" class="ch-ib sel-all-btn" data-sel-container="ch-clips-grid" style="display:none" onclick="_selAllToggle(this.dataset.selContainer)"><i class="fa-solid fa-check-double" aria-hidden="true"></i><span class="sel-all-label">Select all</span></button>
- <a class="ch-view-all ch-ib" id="ch-clips-taken-all" href="#" title="View all clips taken"><i class="fa-solid fa-table-cells" aria-hidden="true"></i><span class="ch-ib-t">View all clips taken</span></a>
+ <a class="ch-view-all ch-ib" id="ch-clips-taken-all" href="#" title="View all clips taken"><i class="fa-solid fa-table-cells" aria-hidden="true"></i><span class="ch-ib-t">View all<span class="ch-ib-more"> clips taken</span></span></a>
</div>
</div>
<div class="stream-grid" id="ch-clips-grid"><p class="muted">Loading clips…</p></div>
<div class="stream-grid" id="ch-clips-grid"><p class="muted">Loading clips…</p></div>
<div id="ch-clips-pagination" class="pastes-pagination" style="display:none"></div>
<div id="ch-clips-pagination" class="pastes-pagination" style="display:none"></div>
@@ -469,11 +471,11 @@
@@ -469,11 +471,11 @@
<!-- Pastes -->
<!-- Pastes -->
<section class="ch-tab-panel" id="ch-panel-pastes" aria-labelledby="ch-h-pastes">
<section class="ch-tab-panel" id="ch-panel-pastes" aria-labelledby="ch-h-pastes">
- <h2 class="ch-sec-h" id="ch-h-pastes">Pastes <span class="ch-sec-n" id="ch-pastes-n" hidden></span></h2>
+ <h2 class="ch-sec-h" id="ch-h-pastes"><i class="fa-solid fa-paste ch-sec-ic" aria-hidden="true"></i>Pastes <span class="ch-sec-n" id="ch-pastes-n" hidden></span></h2>
<div class="ch-panel-bar" id="ch-pastes-header">
<div class="ch-panel-bar" id="ch-pastes-header">
<span id="ch-pastes-sort"></span>
<span id="ch-pastes-sort"></span>
<button type="button" class="ch-ib sel-all-btn" data-sel-container="ch-pastes-grid" style="display:none" onclick="_selAllToggle(this.dataset.selContainer)"><i class="fa-solid fa-check-double" aria-hidden="true"></i><span class="sel-all-label">Select all</span></button>
<button type="button" class="ch-ib sel-all-btn" data-sel-container="ch-pastes-grid" style="display:none" onclick="_selAllToggle(this.dataset.selContainer)"><i class="fa-solid fa-check-double" aria-hidden="true"></i><span class="sel-all-label">Select all</span></button>
- <a class="ch-view-all ch-ib" id="ch-pastes-all" href="#" title="View all pastes"><i class="fa-solid fa-table-cells" aria-hidden="true"></i><span class="ch-ib-t">View all pastes</span></a>
+ <a class="ch-view-all ch-ib" id="ch-pastes-all" href="#" title="View all pastes"><i class="fa-solid fa-table-cells" aria-hidden="true"></i><span class="ch-ib-t">View all<span class="ch-ib-more"> pastes</span></span></a>
</div>
</div>
<div id="ch-pastes-grid" class="pastes-grid"><p class="muted">Loading pastes…</p></div>
<div id="ch-pastes-grid" class="pastes-grid"><p class="muted">Loading pastes…</p></div>
<div id="ch-pastes-pagination" class="pastes-pagination" style="display:none"></div>
<div id="ch-pastes-pagination" class="pastes-pagination" style="display:none"></div>
@@ -481,13 +483,13 @@
@@ -481,13 +483,13 @@
<!-- Media Request -->
<!-- Media Request -->
<section class="ch-tab-panel" id="ch-panel-media" aria-labelledby="ch-h-media">
<section class="ch-tab-panel" id="ch-panel-media" aria-labelledby="ch-h-media">
- <h2 class="ch-sec-h" id="ch-h-media">Media requests</h2>
+ <h2 class="ch-sec-h" id="ch-h-media"><i class="fa-solid fa-photo-film ch-sec-ic" aria-hidden="true"></i>Media requests</h2>
<div id="ch-media-content"><div class="loading">Loading…</div></div>
<div id="ch-media-content"><div class="loading">Loading…</div></div>
</section>
</section>
<!-- Analytics -->
<!-- Analytics -->
<section class="ch-tab-panel" id="ch-panel-analytics" aria-labelledby="ch-h-analytics">
<section class="ch-tab-panel" id="ch-panel-analytics" aria-labelledby="ch-h-analytics">
- <details class="ch-fold" data-ch-fold="analytics"><summary><h2 class="ch-sec-h" id="ch-h-analytics">Analytics</h2><span class="ch-fold-say">Show</span></summary>
+ <details class="ch-fold" data-ch-fold="analytics"><summary><h2 class="ch-sec-h" id="ch-h-analytics"><i class="fa-solid fa-chart-line ch-sec-ic" aria-hidden="true"></i>Analytics</h2><span class="ch-fold-say">Show</span></summary>
<div class="ch-panel-bar" id="ch-analytics-header">
<div class="ch-panel-bar" id="ch-analytics-header">
<div class="ch-analytics-period-btns" id="ch-analytics-period-btns">
<div class="ch-analytics-period-btns" id="ch-analytics-period-btns">
<button class="btn btn-sm btn-ghost active" data-days="7">7d</button>
<button class="btn btn-sm btn-ghost active" data-days="7">7d</button>
@@ -518,14 +520,14 @@
@@ -518,14 +520,14 @@
<!-- AI Timeline — the streamer's whole AI-observed history -->
<!-- AI Timeline — the streamer's whole AI-observed history -->
<section class="ch-tab-panel" id="ch-panel-ai-timeline" aria-labelledby="ch-h-ai-timeline">
<section class="ch-tab-panel" id="ch-panel-ai-timeline" aria-labelledby="ch-h-ai-timeline">
- <details class="ch-fold" data-ch-fold="ai-timeline"><summary><h2 class="ch-sec-h" id="ch-h-ai-timeline">AI timeline</h2><span class="ch-fold-n" id="ch-fold-n-ai-timeline"></span><span class="ch-fold-say">Show</span></summary>
+ <details class="ch-fold" data-ch-fold="ai-timeline"><summary><h2 class="ch-sec-h" id="ch-h-ai-timeline"><i class="fa-solid fa-clock-rotate-left ch-sec-ic" aria-hidden="true"></i>AI timeline</h2><span class="ch-fold-n" id="ch-fold-n-ai-timeline"></span><span class="ch-fold-say">Show</span></summary>
<div id="ch-ai-timeline"><div class="loading">Loading AI timeline…</div></div>
<div id="ch-ai-timeline"><div class="loading">Loading AI timeline…</div></div>
</details>
</details>
</section>
</section>
<!-- About (weather is now an optional panel inside here) -->
<!-- About (weather is now an optional panel inside here) -->
<section class="ch-tab-panel" id="ch-panel-about" aria-labelledby="ch-h-about">
<section class="ch-tab-panel" id="ch-panel-about" aria-labelledby="ch-h-about">
- <h2 class="ch-sec-h" id="ch-h-about">About</h2>
+ <h2 class="ch-sec-h" id="ch-h-about"><i class="fa-solid fa-circle-info ch-sec-ic" aria-hidden="true"></i>About</h2>
<div id="ch-about-content" class="ch-about-content"></div>
<div id="ch-about-content" class="ch-about-content"></div>
</section>
</section>
</div>
</div>
@@ -292,14 +292,14 @@ async function renderChannelVodsSection(username, liveStreams, vods, meta = {})
@@ -292,14 +292,14 @@ async function renderChannelVodsSection(username, liveStreams, vods, meta = {})
const orderName = { newest: 'Newest first', oldest: 'Oldest first', views: 'Most views', peak_viewers: 'Peak viewers' }[currentChannelVodOrder] || 'Newest first';
const orderName = { newest: 'Newest first', oldest: 'Oldest first', views: 'Most views', peak_viewers: 'Peak viewers' }[currentChannelVodOrder] || 'Newest first';
filterBar.innerHTML = `
filterBar.innerHTML = `
<label class="ch-ib ch-ib-pick${currentChannelVodFilter !== null ? ' is-on' : ''}" title="${esc(currentChannelVodFilter !== null ? `Stream: ${msName || 'one stream'}` : 'All streams')}">
<label class="ch-ib ch-ib-pick${currentChannelVodFilter !== null ? ' is-on' : ''}" title="${esc(currentChannelVodFilter !== null ? `Stream: ${msName || 'one stream'}` : 'All streams')}">
- <i class="fa-solid fa-filter" aria-hidden="true"></i>
+ <i class="fa-solid fa-filter" aria-hidden="true"></i><span class="ch-ib-t">${esc(currentChannelVodFilter !== null ? (msName || 'One stream') : 'All streams')}</span><i class="fa-solid fa-chevron-down ch-ib-caret" aria-hidden="true"></i>
<select class="ch-vods-filter-select" data-ov-native aria-label="Which stream" onchange="setChannelVodFilter(this.value ? parseInt(this.value) : null)">
<select class="ch-vods-filter-select" data-ov-native aria-label="Which stream" onchange="setChannelVodFilter(this.value ? parseInt(this.value) : null)">
<option value="" ${currentChannelVodFilter === null ? 'selected' : ''}>All streams</option>
<option value="" ${currentChannelVodFilter === null ? 'selected' : ''}>All streams</option>
${msOptions}
${msOptions}
</select>
</select>
</label>
</label>
<label class="ch-ib ch-ib-pick" data-order="${esc(currentChannelVodOrder)}" title="${esc(orderName)}">
<label class="ch-ib ch-ib-pick" data-order="${esc(currentChannelVodOrder)}" title="${esc(orderName)}">
- <i class="fa-solid ${currentChannelVodOrder === 'oldest' ? 'fa-arrow-down-short-wide' : 'fa-arrow-down-wide-short'}" aria-hidden="true"></i>
+ <i class="fa-solid ${{ oldest: 'fa-arrow-down-short-wide', views: 'fa-eye', peak_viewers: 'fa-users' }[currentChannelVodOrder] || 'fa-arrow-down-wide-short'}" aria-hidden="true"></i><span class="ch-ib-t">${esc(orderName)}</span><i class="fa-solid fa-chevron-down ch-ib-caret" aria-hidden="true"></i>
<select class="ch-vods-filter-select" data-ov-native aria-label="Order" onchange="setChannelVodOrder(this.value)">
<select class="ch-vods-filter-select" data-ov-native aria-label="Order" onchange="setChannelVodOrder(this.value)">
${orderOptions}
${orderOptions}
</select>
</select>
@@ -3075,7 +3075,41 @@ function _applyBotEmbed(botEmbed, username) {
@@ -3075,7 +3075,41 @@ function _applyBotEmbed(botEmbed, username) {
}
}
return frame;
return frame;
}
}
- const MODE_NAME = { overlay: 'on the video', compact: 'a row under the video', panel: 'the full panel', hidden: 'hidden' };
+ const MODE_NAME = { overlay: 'on the video', compact: 'a row under the video', panel: 'the full panel', window: 'in a window', hidden: 'hidden' };
+ // The panel in the shared window (ov-window.js beside the navbar's scripts): moved by its head, sized by its corner,
+ // its place and size kept per robot. Closing it puts the panel back under the video.
+ function closeWindow() {
+ const w = _applyBotEmbed.win;
+ if (!w) return;
+ _applyBotEmbed.win = null;
+ _applyBotEmbed.closingWin = true;
+ try { w.close(); } catch { /* already gone */ }
+ _applyBotEmbed.closingWin = false;
+ }
+ function withWindow(fn) {
+ if (window.OVWindow) return fn();
+ let sc = document.getElementById('ov-window-script');
+ if (!sc) { sc = document.createElement('script'); sc.id = 'ov-window-script'; sc.async = true; sc.src = '/shared/ov-window.js'; document.head.appendChild(sc); }
+ sc.addEventListener('load', () => { if (window.OVWindow) fn(); }, { once: true });
+ }
+ function openWindow() {
+ withWindow(() => {
+ if (_applyBotEmbed.mode !== 'window' || _applyBotEmbed.win) return;
+ const wrap = document.createElement('div');
+ wrap.className = 'ch-bot-win';
+ const w = window.OVWindow.open({
+ // It grows out of the switch's Window button (on a page load too, where nothing was pressed).
+ from: (cardSwitch && cardSwitch.querySelector && cardSwitch.querySelector('[data-bot-mode="window"]')) || undefined,
+ id: `ov-bot-${robotId}`, kind: 'panel', title: 'Robot', label: 'Robot controls', width: 440, body: wrap,
+ resizable: true, minWidth: 300, minHeight: 240,
+ onClose: () => { if (!_applyBotEmbed.closingWin && _applyBotEmbed.mode === 'window') { _applyBotEmbed.win = null; _applyBotEmbed.choose('panel'); } },
+ });
+ if (w.body) w.body.style.padding = '0';
+ if (w.el && !w.el.style.height) w.el.style.height = '540px';
+ _applyBotEmbed.win = w;
+ _applyBotEmbed.frame = mountIn(wrap, 'panel', false);
+ });
+ }
// The page knows the place (phone layout in channel.css) and, for the full panel on a phone, which tab is open.
// The page knows the place (phone layout in channel.css) and, for the full panel on a phone, which tab is open.
// The Robot tab is open while the full panel is the place and the person has not gone back to Chat; any other place
// The Robot tab is open while the full panel is the place and the person has not gone back to Chat; any other place
// keeps the sheet on Chat, and the Robot tab offers the panel.
// keeps the sheet on Chat, and the Robot tab offers the panel.
@@ -3128,7 +3162,14 @@ function _applyBotEmbed(botEmbed, username) {
@@ -3128,7 +3162,14 @@ function _applyBotEmbed(botEmbed, username) {
if (switchWrap) switchWrap.hidden = !robotId;
if (switchWrap) switchWrap.hidden = !robotId;
if (cardBar) cardBar.hidden = !robotId;
if (cardBar) cardBar.hidden = !robotId;
if (openLink && botEmbed && typeof botEmbed.url === 'string') openLink.href = botEmbed.url.replace(/\/embed$/, '');
if (openLink && botEmbed && typeof botEmbed.url === 'string') openLink.href = botEmbed.url.replace(/\/embed$/, '');
- if (mode === 'overlay') {
+ if (mode !== 'window') closeWindow();
+ if (mode === 'window') {
+ stopFrame();
+ if (slot) { clear(slot); slot.hidden = true; }
+ clear(box); box.hidden = true;
+ if (section) section.hidden = false;
+ openWindow();
+ } else if (mode === 'overlay') {
if (slot) {
if (slot) {
slot.hidden = false;
slot.hidden = false;
_applyBotEmbed.frame = mountIn(slot, 'overlay', false);
_applyBotEmbed.frame = mountIn(slot, 'overlay', false);
@@ -3237,6 +3278,7 @@ function _applyBotEmbed(botEmbed, username) {
@@ -3237,6 +3278,7 @@ function _applyBotEmbed(botEmbed, username) {
render();
render();
if (window.ov) ov.scope().onDispose(() => {
if (window.ov) ov.scope().onDispose(() => {
stopFrame();
stopFrame();
+ closeWindow();
clear(slot);
clear(slot);
pageClasses(null);
pageClasses(null);
if (typeof BotEmbed !== 'undefined') BotEmbed.mount(box, null);
if (typeof BotEmbed !== 'undefined') BotEmbed.mount(box, null);
@@ -37,7 +37,8 @@
@@ -37,7 +37,8 @@
const PANEL_MODES = ['panel', 'overlay', 'compact'];
const PANEL_MODES = ['panel', 'overlay', 'compact'];
// Where a viewer puts the controls: over the video, a row under it (Bot's compact view), the full panel, or hidden.
// Where a viewer puts the controls: over the video, a row under it (Bot's compact view), the full panel, or hidden.
- const VIEW_MODES = ['overlay', 'compact', 'panel', 'hidden'];
+ // 'window' (ask 356, 2026-11-11: "can be popped out … and resized"): the full panel in the shared window (ov-window.js).
+ const VIEW_MODES = ['overlay', 'compact', 'panel', 'window', 'hidden'];
const CHOICE_KEY = 'ov:live:bot-controls';
const CHOICE_KEY = 'ov:live:bot-controls';
function embedSrc(botEmbed, mode = null) {
function embedSrc(botEmbed, mode = null) {
@@ -109,7 +110,10 @@
@@ -109,7 +110,10 @@
return phone ? 'overlay' : 'panel';
return phone ? 'overlay' : 'panel';
}
}
function effectiveMode(botEmbed, stored, opts) {
function effectiveMode(botEmbed, stored, opts) {
- return validView(stored) || defaultMode(botEmbed, opts);
+ const v = validView(stored);
+ // A phone has no floating windows: its full panel is the Robot tab.
+ if (v === 'window' && opts && opts.phone) return 'panel';
+ return v || defaultMode(botEmbed, opts);
}
}
function readChoice(win, channel, robotId) {
function readChoice(win, channel, robotId) {
try {
try {
@@ -211,6 +215,8 @@
@@ -211,6 +215,8 @@
// The row (Bot's compact view) is about 120 px: it keeps its own height, the panel at least MIN_HEIGHT.
// The row (Bot's compact view) is about 120 px: it keeps its own height, the panel at least MIN_HEIGHT.
const clamped = Math.max(mode === 'compact' ? COMPACT_MIN : MIN_HEIGHT, Math.min(MAX_HEIGHT, Math.round(height)));
const clamped = Math.max(mode === 'compact' ? COMPACT_MIN : MIN_HEIGHT, Math.min(MAX_HEIGHT, Math.round(height)));
try {
try {
+ // In a window the window's size is the frame's: the panel scrolls inside it.
+ if (typeof frame.closest === 'function' && frame.closest('.ovw')) return;
const card = typeof frame.closest === 'function' ? frame.closest('#ch-bot-container') : null;
const card = typeof frame.closest === 'function' ? frame.closest('#ch-bot-container') : null;
const host = card || frame;
const host = card || frame;
host.style.height = `${clamped}px`;
host.style.height = `${clamped}px`;
@@ -126,7 +126,9 @@ async function check(name, fn) {
@@ -126,7 +126,9 @@ async function check(name, fn) {
assert.strictEqual(BotEmbed.effectiveMode(overlayDefault, 'bogus'), 'overlay', 'a bad stored value is ignored');
assert.strictEqual(BotEmbed.effectiveMode(overlayDefault, 'bogus'), 'overlay', 'a bad stored value is ignored');
assert.strictEqual(BotEmbed.effectiveMode({ ...SIM, controls_mode: 'bogus' }, null), 'panel', 'a bad streamer default is panel');
assert.strictEqual(BotEmbed.effectiveMode({ ...SIM, controls_mode: 'bogus' }, null), 'panel', 'a bad streamer default is panel');
// Ask 353 (2026-11-11): a fourth place, a row under the video; on a phone held upright the panel starts on the video.
// Ask 353 (2026-11-11): a fourth place, a row under the video; on a phone held upright the panel starts on the video.
- assert.deepStrictEqual(BotEmbed.VIEW_MODES, ['overlay', 'compact', 'panel', 'hidden']);
+ assert.deepStrictEqual(BotEmbed.VIEW_MODES, ['overlay', 'compact', 'panel', 'window', 'hidden']);
+ assert.strictEqual(BotEmbed.effectiveMode(SIM, 'window'), 'window', 'a computer may pop the panel out');
+ assert.strictEqual(BotEmbed.effectiveMode(SIM, 'window', { phone: true }), 'panel', 'a phone keeps it in the Robot tab');
assert.strictEqual(BotEmbed.effectiveMode(SIM, null, { phone: true }), 'overlay', 'a phone starts with the controls on the video, so the chat keeps its room');
assert.strictEqual(BotEmbed.effectiveMode(SIM, null, { phone: true }), 'overlay', 'a phone starts with the controls on the video, so the chat keeps its room');
assert.strictEqual(BotEmbed.effectiveMode({ ...SIM, controls_mode: 'compact' }, null, { phone: true }), 'compact', 'a streamer\'s row stays a row on a phone');
assert.strictEqual(BotEmbed.effectiveMode({ ...SIM, controls_mode: 'compact' }, null, { phone: true }), 'compact', 'a streamer\'s row stays a row on a phone');
assert.strictEqual(BotEmbed.effectiveMode(overlayDefault, null, { phone: true }), 'overlay', 'the streamer\'s on-the-video stays on a phone');
assert.strictEqual(BotEmbed.effectiveMode(overlayDefault, null, { phone: true }), 'overlay', 'the streamer\'s on-the-video stays on a phone');