openvibers/openvibe.live Commits
A robot channel on a phone, the chat as a sheet, and the sections' tools as icons (#797)
Changes
nnew file mode 100644@@@ -0,0 +1,6 @@++-- phase: expand++-- The streamer's default place for a robot's controls may also be 'compact': a row under the video (ask 353,++-- 2026-11-11). The release before this reads an unknown value as 'panel' (server/bot/embed.js controlMode), so it++-- keeps working on a channel that chose the row.++ALTER TABLE channels DROP CONSTRAINT IF EXISTS channels_bot_controls_mode;++ALTER TABLE channels ADD CONSTRAINT channels_bot_controls_mode CHECK (bot_controls_mode IS NULL OR bot_controls_mode IN ('overlay', 'compact', 'panel'));@@@ -1160,6 +1160,14 @@ button.viewer-badge:focus-visible { outline: 2px solid #93c5fd; outline-offset:align-self: flex-start;}}++/* Two streams live at once (the stream tabs bar above the player): the bar stays under the navbar and the chat under++ the bar. Before, the chat started under the bar but was the navbar's height short of the window, so its last 42 px,++ the tools under the message box, sat below the screen (found 2026-11-11). The sections' jump bar sticks under both. */++@media (min-width: 1181px) {++ #page-channel.has-live-tabs .live-stream-tabs { position: sticky; top: var(--navbar-h); z-index: 7; }++ #page-channel.has-live-tabs .stream-layout > .chat-sidebar { top: calc(var(--navbar-h) + 42px); height: calc(100vh - var(--navbar-h) - 42px); }++ #page-channel.has-live-tabs .ch-tabs-wrap { top: calc(var(--ovnav-h, 56px) + 42px); }++}.ch-stream-ai { margin-top: 8px; max-width: 760px; }.cu-btn-sm { padding: 4px 10px; font-size: 0.78rem; }.cu-btn-ghost { background: transparent; border: 1px solid var(--border); }@@@ -2040,19 +2048,139 @@ html[data-ov-motion=off] #page-channel .ch-tab-panel{scroll-margin-top: calc(var.ch-bot-mode-menu[hidden] { display: none; }.ch-bot-mode-menu button { min-height: 36px; padding: 0 10px; border: 0; border-radius: 7px; background: transparent; color: #fff; text-align: left; font-size: .8rem; }.ch-bot-mode-menu button[aria-checked="true"] { background: color-mix(in srgb, var(--accent) 35%, transparent); }--/* The card's switch and its one-line state when the controls are on the video. */--.ch-bot-card-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 8px 14px; border-bottom: 1px solid var(--border); }--.ch-bot-card-bar[hidden] { display: none; }--.ch-bot-card-label { color: var(--text-muted, var(--text-secondary)); font-size: .78rem; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; }--.ch-bot-switch { display: inline-flex; gap: 2px; padding: 2px; border-radius: 10px; background: color-mix(in srgb, var(--text-primary, #fff) 7%, transparent); }--.ch-bot-switch button { min-height: 30px; padding: 0 9px; border: 0; border-radius: 8px; background: transparent; color: var(--text-secondary); font-size: .78rem; font-weight: 600; }--.ch-bot-switch button[aria-pressed="true"] { background: var(--accent); color: var(--on-accent-strong, #fff); }--.ch-bot-card-line { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; padding: 12px 14px; color: var(--text-secondary); font-size: .9rem; }--.ch-bot-card-line[hidden] { display: none; }--.ch-bot-card-line button { min-height: 36px; padding: 0 12px; border: 1px solid var(--border); border-radius: 9px; background: transparent; color: var(--text-primary); font-size: .85rem; font-weight: 600; }++/* The card (ask 353, 2026-11-11: "more compact, use icons and buttons in a smarter way"): one head row with the name,++ the four places for the controls as icons (the chosen one says its name) and the robot's own page; then the frame,++ which is the full panel or a row (Bot's compact view). On the video, or hidden, the card is just that row. */#ch-bot-section { margin: 10px 0 0; border: 1px solid var(--border); border-radius: 16px; background: var(--bg-card, var(--bg-secondary)); overflow: hidden; }--#ch-bot-section > h3 { display: flex; align-items: center; gap: 9px; margin: 0; padding: 10px 14px; font-size: .9rem; font-weight: 700; color: var(--text-primary); border-bottom: 1px solid var(--border); }--#ch-bot-section > h3::before { content: ""; flex: none; width: 8px; height: 8px; border-radius: 50%; background: var(--success-text, #34d399); box-shadow: 0 0 0 3px color-mix(in srgb, var(--success-text, #34d399) 22%, transparent); }--#ch-bot-container { resize: vertical; overflow: hidden; height: clamp(230px, 36vh, 460px); min-height: 160px; max-height: 80vh; }++#ch-bot-section[hidden] { display: none !important; }++.ch-bot-head { display: flex; align-items: center; gap: 8px; min-height: 46px; padding: 5px 6px 5px 14px; }++.ch-bot-head[hidden] { display: none; }++#ch-bot-section:has(#ch-bot-container:not([hidden])) > .ch-bot-head { border-bottom: 1px solid var(--border); }++.ch-bot-head h3 { display: flex; align-items: center; gap: 9px; flex: 1 1 auto; min-width: 0; margin: 0; font-size: .9rem; font-weight: 700; color: var(--text-primary); }++.ch-bot-head h3::before { content: ""; flex: none; width: 8px; height: 8px; border-radius: 50%; background: var(--success-text, #34d399); box-shadow: 0 0 0 3px color-mix(in srgb, var(--success-text, #34d399) 22%, transparent); }++.ch-bot-tabs { display: none; flex: 1 1 auto; min-width: 0; gap: 4px; }++.chat-header .ch-bot-tabs { margin-right: auto; }++.ch-bot-tabs button { display: inline-flex; align-items: center; gap: 7px; min-height: 36px; padding: 0 12px; border: 0; border-radius: 10px; background: transparent; color: var(--text-secondary); font: 700 .85rem/1 inherit; }++.ch-bot-tabs button[aria-selected="true"] { background: color-mix(in srgb, var(--accent) 18%, transparent); color: var(--text-primary); }++.ch-bot-switch { display: inline-flex; flex: none; gap: 2px; padding: 3px; border-radius: 12px; background: color-mix(in srgb, var(--text-primary, #fff) 7%, transparent); }++.ch-bot-switch button { display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-width: 36px; min-height: 34px; padding: 0 10px; border: 0; border-radius: 9px; background: transparent; color: var(--text-secondary); font-size: .78rem; font-weight: 600; }++.ch-bot-switch button:hover { color: var(--text-primary); }++.ch-bot-switch button[aria-pressed="true"] { background: var(--accent-strong, var(--accent)); color: var(--on-accent-strong, #fff); }++.ch-bot-open { display: inline-flex; flex: none; align-items: center; justify-content: center; width: 36px; height: 36px; border-radius: 10px; color: var(--text-secondary); text-decoration: none; }++.ch-bot-open:hover, .ch-bot-open:focus-visible { color: var(--text-primary); background: color-mix(in srgb, var(--text-primary, #fff) 7%, transparent); }++@media (max-width: 1100px) {++ .ch-bot-switch button:not([aria-pressed="true"]) { width: 36px; padding: 0; }++ .ch-bot-switch button:not([aria-pressed="true"]) span { display: none; }++}++#ch-bot-container { resize: vertical; overflow: hidden; height: clamp(230px, 36vh, 460px); min-height: 112px; max-height: 80vh; }++#ch-bot-container[hidden] { display: none; }#ch-bot-container iframe { display: block; width: 100%; height: 100%; border: 0; background: transparent; }++#page-channel.ch-bot-compact #ch-bot-container { height: 120px; resize: none; }@media (max-width: 768px) { #ch-bot-section { margin: 8px; } #ch-bot-container { height: clamp(220px, 44vh, 420px); resize: none; } }++++/* A phone held upright, live (ask 353: "how do you even open the chat anymore?"; then, with the first version open:++ "the chat should go all the way up to the video … then it can be collapsed … a little circle in the corner … resize++ how tall the chat is"). The column under the player is one sheet:++ - the chat fills it, right up to the player, with one header row: Chat and Robot tabs (with a robot), its tools and++ the fold. The grip on its top edge sets its height (or the arrow keys on it): the channel line shows in the room it++ leaves; all the way up is the default again; most of the way down folds it to a round chat button in the corner.++ - the robot: over the video (the default here), a row just under the player, or the full panel in the Robot tab.++ - folded, the page reads as before: the player, the channel line, the robot's card, the sections.++ Where the robot's controls go is the player's robot button. A portrait stream keeps its own layout. */++@media (max-width: 768px) and (orientation: portrait) {++ #page-channel.ch-is-live .stream-layout:not(:has(.video-container.is-vertical)) > .stream-main { display: contents; }++ #page-channel.ch-is-live .stream-layout:not(:has(.video-container.is-vertical)) > .stream-main > .video-container { order: 0; flex: none; }++ #page-channel.ch-is-live .stream-layout:not(:has(.video-container.is-vertical)) > .stream-main > :is(.ch-captions, .ch-info-bar, .ch-weather-widget) { order: 1; flex: none; }++ #page-channel.ch-is-live .stream-layout:not(:has(.video-container.is-vertical)) > .stream-main > #ch-bot-section { order: 2; flex: none; margin: 0; border-width: 1px 0 0; border-radius: 0; }++ #page-channel.ch-is-live .stream-layout:not(:has(.video-container.is-vertical)) > .chat-sidebar { order: 3; }++ /* All the way up: the channel line waits under the chat (fold or lower the chat to see it). */++ #page-channel.ch-is-live:not(.ch-chat-sized):not(.chat-collapsed) .stream-layout:not(:has(.video-container.is-vertical)) > .stream-main > :is(.ch-info-bar, .ch-weather-widget) { display: none !important; }++ /* A height of its own: the chat keeps it, the channel line takes the rest above it. */++ #page-channel.ch-is-live.ch-chat-sized:not(.chat-collapsed) .stream-layout:not(:has(.video-container.is-vertical)) > .stream-main > .ch-info-bar { flex: 1 1 0; min-height: 0; overflow-y: auto; align-items: flex-start; align-content: flex-start; }++ #page-channel.ch-is-live.ch-chat-sized:not(.chat-collapsed) .stream-layout:not(:has(.video-container.is-vertical)) > .stream-main > .ch-weather-widget { display: none !important; }++ #page-channel.ch-is-live.ch-chat-sized.page .stream-layout:not(:has(.video-container.is-vertical)) > .chat-sidebar { flex: 0 1 var(--chat-sheet-h, 60%); min-height: 120px; }++++ /* The chat's header: one row with the grip above it. */++ #page-channel.ch-is-live .stream-layout:not(:has(.video-container.is-vertical)) > .chat-sidebar > .chat-grip { display: block !important; height: 16px; }++ #page-channel.ch-is-live .stream-layout:not(:has(.video-container.is-vertical)) > .chat-sidebar > .chat-grip::after { top: 5px; opacity: .8; }++ #page-channel.ch-is-live .stream-layout:not(:has(.video-container.is-vertical)) > .chat-sidebar .chat-header { padding-top: 12px; min-height: 52px; }++ #page-channel.ch-is-live.ch-has-bot .chat-header .ch-chat-title { display: none; }++ #page-channel.ch-is-live.ch-has-bot .ch-bot-tabs { display: flex; flex: 0 1 auto; }++++ /* The robot's place. Its card's own head row is the desktop's; here the tabs and the player's button do that. */++ #page-channel.ch-is-live .ch-bot-head { display: none !important; }++ #page-channel.ch-is-live:not(.ch-bot-panel):not(.ch-bot-compact) #ch-bot-section { display: none !important; }++ #page-channel.ch-is-live.ch-bot-compact #ch-bot-container { height: 120px; }++ /* The Robot tab: the panel takes the sheet under the chat's header, and scrolls inside its frame. */++ #page-channel.ch-is-live.ch-bot-panel:not(.ch-bot-chat):not(.chat-collapsed) .stream-layout:not(:has(.video-container.is-vertical)) > .stream-main > :is(.ch-info-bar, .ch-weather-widget) { display: none !important; }++ #page-channel.ch-is-live.ch-bot-panel:not(.ch-bot-chat):not(.chat-collapsed) .stream-layout:not(:has(.video-container.is-vertical)) > .stream-main > #ch-bot-section { order: 4; flex: 1 1 0; min-height: 0; display: flex; flex-direction: column; border: 0; }++ #page-channel.ch-is-live.ch-bot-panel:not(.ch-bot-chat):not(.chat-collapsed) #ch-bot-container { flex: 1 1 0; min-height: 0; height: auto !important; max-height: none; }++ #page-channel.ch-is-live.ch-bot-panel:not(.ch-bot-chat):not(.chat-collapsed).page .stream-layout > .chat-sidebar { flex: none; min-height: 0; }++ #page-channel.ch-is-live.ch-bot-panel:not(.ch-bot-chat):not(.chat-collapsed) .stream-layout > .chat-sidebar > :not(.chat-header) { display: none !important; }++ /* The Chat tab: the panel's frame waits, still loaded (a driver keeps their place). */++ #page-channel.ch-is-live.ch-bot-panel.ch-bot-chat:not(.chat-collapsed) #ch-bot-section { display: none !important; }++++ /* Folded: the round chat button in the corner brings the sheet back. */++ body:has(#page-channel.active.ch-is-live) .ch-chat-reopen:not([hidden]) { width: 56px; height: 56px; padding: 0; gap: 0; justify-content: center; border-radius: 50%; }++ body:has(#page-channel.active.ch-is-live) .ch-chat-reopen span { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }++ body:has(#page-channel.active.ch-is-live) .ch-chat-reopen :is(.fa-comments, svg) { font-size: 22px; width: 22px; height: 22px; }++ body:has(#page-channel.active.ch-is-live) .ch-chat-reopen .fa-angles-left { display: none !important; }++++ /* The channel line, folded: who, live now and the title, with its actions; a tap on it (the chevron) opens the++ followers, the alert, the labels and the links. The goal bar is one slim row. */++ #page-channel.ch-is-live .stream-layout:not(:has(.video-container.is-vertical)) .ch-info-bar:not(.ch-info-open) :is(.ch-username, .ch-meta, .ch-social-pills, .stream-labels) { display: none !important; }++ #page-channel.ch-is-live .stream-layout:not(:has(.video-container.is-vertical)) .ch-info-bar { row-gap: 8px; padding: 8px 12px; }++ #page-channel.ch-is-live .stream-layout:not(:has(.video-container.is-vertical)) .ch-info-bar-top h2::after { content: ""; display: inline-block; flex: none; width: 6px; height: 6px; margin: 0 0 3px 8px; border: solid currentColor; border-width: 0 2px 2px 0; transform: rotate(45deg); opacity: .6; transition: transform .2s; }++ #page-channel.ch-is-live .stream-layout:not(:has(.video-container.is-vertical)) .ch-info-bar.ch-info-open .ch-info-bar-top h2::after { transform: translateY(3px) rotate(225deg); }++ #page-channel.ch-is-live .stream-layout:not(:has(.video-container.is-vertical)) .ch-info-bar-right :is(.ch-action, .ovk-ask) { height: 40px; min-height: 40px; }++ #page-channel.ch-is-live .chat-sidebar .ch-goal-widget { padding: 4px 6px 0; }++ #page-channel.ch-is-live .chat-sidebar .cgw-goal { height: 32px; }++ #page-channel.ch-is-live .chat-sidebar .cgw-track .cgw-goal { margin-bottom: 4px; }++}++++/* ── A section's head, compact (ask 355, 2026-11-11: "buttons need to be moved to where the title bar is … the filters++ need to use icons … smaller, compact … button icons with nice custom animations"). The count sits beside the++ heading; the tools are 36 px icon buttons (40 on a touch screen) in the same row, at every width: filter and order++ carry the native picker over the icon (a phone opens its own list), then select, OpenVibe.Video, and View all. ─── */++#page-channel .ch-sec-h { display: flex; align-items: baseline; gap: 8px; }++#page-channel .ch-sec-n { font-size: .78rem; font-weight: 700; color: var(--text-muted, var(--text-secondary)); font-variant-numeric: tabular-nums; letter-spacing: 0; }++#page-channel .ch-sec-n[hidden] { display: none; }++#page-channel .ch-panel-bar { gap: 4px; }++#page-channel .ch-panel-bar > .ch-vods-filter-bar { display: inline-flex; gap: 4px; margin: 0; }++#page-channel .ch-ib {++ position: relative; display: inline-grid; place-items: center; flex: none; width: 36px; height: 36px; min-height: 0; padding: 0;++ border: 1px solid transparent; border-radius: 10px; background: color-mix(in srgb, var(--text-primary, #fff) 6%, transparent);++ color: var(--text-secondary); cursor: pointer; text-decoration: none; font-size: .95rem;++ transition: background-color var(--ov-dur-1, .12s) var(--ov-ease-out, ease), color var(--ov-dur-1, .12s), border-color var(--ov-dur-1, .12s), transform var(--ov-dur-1, .12s) var(--ov-ease-out, ease);++}++#page-channel .ch-ib:hover { color: var(--text-primary); background: color-mix(in srgb, var(--text-primary, #fff) 11%, transparent); }++#page-channel .ch-ib:active { transform: scale(.9); }++#page-channel .ch-ib:focus-visible, #page-channel .ch-ib:has(select:focus-visible) { outline: 2px solid var(--accent); outline-offset: 2px; }++#page-channel .ch-ib > :is(i, svg) { transition: transform var(--ov-dur-2, .2s) var(--ov-ease-out, cubic-bezier(.22,1,.36,1)); pointer-events: none; }++#page-channel .ch-ib:hover > :is(i, svg) { transform: translateY(-1px); }++#page-channel .ch-view-all.ch-ib:hover > :is(i, svg) { transform: scale(1.12); }++#page-channel .ch-ib .sel-all-label { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }++/* The picker over the icon: the whole button is the select, invisible, so the platform's own list opens. */++#page-channel .ch-ib-pick select { position: absolute; inset: 0; width: 100%; height: 100%; min-height: 0; margin: 0; padding: 0; border: 0; opacity: 0; cursor: pointer; font-size: 16px; -webkit-appearance: none; appearance: none; }++#page-channel .ch-ib-pick.is-on { color: var(--accent-light, var(--accent)); background: color-mix(in srgb, var(--accent) 16%, transparent); }++#page-channel .ch-ib-pick.is-on::after { content: ""; position: absolute; top: 6px; right: 6px; width: 6px; height: 6px; border-radius: 50%; background: var(--accent); box-shadow: 0 0 0 2px var(--bg-primary); }++#page-channel .ch-ib-pick[data-order="views"] > :is(i, svg), #page-channel .ch-ib-pick[data-order="peak_viewers"] > :is(i, svg) { color: var(--accent-light, var(--accent)); }++@media (hover: none) { #page-channel .ch-ib { width: 40px; height: 40px; } }++@media (max-width: 600px) {++ /* The tools stay in the heading's row: they are icons now. */++ #page-channel :is(#ch-panel-videos, #ch-panel-clips, #ch-panel-clips-taken, #ch-panel-pastes) { grid-template-columns: minmax(0, 1fr) auto; }++ #page-channel :is(#ch-panel-videos, #ch-panel-clips, #ch-panel-clips-taken, #ch-panel-pastes) > .ch-panel-bar { grid-column: 2; grid-row: 1; justify-content: flex-end; }++ #page-channel .ch-view-all.ch-ib { min-height: 0; }++}++++/* A recording in progress: the stream's own picture as a live card (style.css .stream-card.is-live), a Recording pill++ whose dot breathes, the clock that runs, and a play mark on hover. */++#page-channel .ch-rec-card { border-width: 1px; }++#page-channel .ch-rec-pill { position: absolute; top: 8px; left: 8px; z-index: 2; display: inline-flex; align-items: center; gap: 6px; height: 24px; padding: 0 9px 0 8px; border-radius: 999px; background: var(--live-red, #e5484d); color: #fff; font-size: .7rem; font-weight: 800; letter-spacing: .04em; text-transform: uppercase; }++#page-channel .ch-rec-dot { width: 7px; height: 7px; border-radius: 50%; background: #fff; animation: chRecBreathe 1.6s var(--ov-ease-in-out, ease-in-out) infinite; }++@keyframes chRecBreathe { 50% { opacity: .35; transform: scale(.8); } }++html[data-ov-motion="off"] #page-channel .ch-rec-dot { animation: none; }++#page-channel .ch-rec-time { font-variant-numeric: tabular-nums; }++#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; }@@@ -62,7 +62,8 @@</button><div class="ch-bot-mode-menu" id="ch-bot-mode-menu" role="menu" hidden><button type="button" role="menuitemradio" data-bot-mode="overlay">On the video</button>-- <button type="button" role="menuitemradio" data-bot-mode="panel">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="hidden">Hidden</button></div></div>@@@ -116,19 +117,18 @@</div></div><!-- OpenVibe.Bot robot panel for a channel bound to a robot (LIVE_BOT_EMBED; filled by public/js/bot-embed.js) -->++ <!-- One head row (ask 353): the name, the four places for the controls, and the robot's own page. Hidden++ keeps this row. On a phone the row steps aside: the Chat/Robot tabs are in the chat's header. --><section id="ch-bot-section" aria-labelledby="ch-bot-heading" hidden>-- <h3 id="ch-bot-heading">Robot</h3>-- <div class="ch-bot-card-bar" id="ch-bot-card-bar" hidden>-- <span class="ch-bot-card-label">Controls</span>-- <div class="ch-bot-switch" id="ch-bot-card-switch" role="group" aria-label="Robot controls">-- <button type="button" data-bot-mode="overlay">On the video</button>-- <button type="button" data-bot-mode="panel">Under the video</button>-- <button type="button" data-bot-mode="hidden">Hidden</button>++ <div class="ch-bot-head" id="ch-bot-card-bar" hidden>++ <h3 id="ch-bot-heading">Robot</h3>++ <div class="ch-bot-switch" id="ch-bot-card-switch" role="group" aria-label="Where the robot's controls go">++ <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="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>-- <div class="ch-bot-card-line" id="ch-bot-card-line" hidden>-- <span>Robot · controls are on the video</span>-- <button type="button" data-bot-show-panel>Show panel</button>++ <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></div><div id="ch-bot-container"></div></section>@@@ -189,7 +189,12 @@<div class="chat-sidebar" id="chat-sidebar"><div class="chat-header"><button type="button" class="chat-settings-btn ch-chat-collapse" onclick="toggleChannelChat(true)" title="Hide chat" aria-label="Hide chat"><i class="fa-solid fa-angles-right"></i></button>-- <span>STREAM CHAT</span>++ <span class="ch-chat-title">STREAM CHAT</span>++ <!-- A channel with a robot, on a phone: Chat and Robot share this sheet (app-channel.js _applyBotEmbed). -->++ <div class="ch-bot-tabs" id="ch-bot-tabs" role="tablist" aria-label="Chat or robot">++ <button type="button" role="tab" data-bot-tab="chat" aria-selected="true"><i class="fa-solid fa-comments" aria-hidden="true"></i><span>Chat</span></button>++ <button type="button" role="tab" data-bot-tab="robot" aria-selected="false"><i class="fa-solid fa-robot" aria-hidden="true"></i><span>Robot</span></button>++ </div><div class="chat-header-actions"><button class="chat-settings-btn" onclick="toggleChatUsers(this)" title="Users"><i class="fa-solid fa-users"></i>@@@ -421,12 +426,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</h2>++ <h2 class="ch-sec-h" id="ch-h-videos">Videos <span class="ch-sec-n" id="ch-vods-n" hidden></span></h2><div class="ch-panel-bar">-- <a class="ch-view-all" id="ch-vods-all" href="#">View all videos</a><div id="ch-vods-filter-bar" class="ch-vods-filter-bar" style="display:none"></div>-- <a id="ch-vods-video-link" class="btn btn-small btn-outline" href="#" style="display:none" title="This channel's recordings and clips on OpenVibe.Video, with AI chapters, captions and playlists"><i class="fa-solid fa-film"></i> On OpenVibe.Video</a>-- <button class="btn btn-small btn-outline sel-all-btn" data-sel-container="ch-vods-grid" style="display:none" onclick="_selAllToggle(this.dataset.selContainer)"><i class="fa-solid fa-check-double"></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></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>@@@ -434,8 +439,10 @@<!-- 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</h2>-- <div class="ch-panel-bar"><a class="ch-view-all" id="ch-clips-of-all" href="#">View all clips</a></div>++ <h2 class="ch-sec-h" id="ch-h-clips">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>++ </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><div id="ch-clips-of-pagination" class="pastes-pagination" style="display:none"></div>@@@ -449,12 +456,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</h2>++ <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><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">-- <a class="ch-view-all" id="ch-clips-taken-all" href="#">View all clips taken</a><div class="clips-taken-filters" id="clips-taken-filters" style="display:none"></div>-- <button class="btn btn-small btn-outline sel-all-btn" data-sel-container="ch-clips-grid" style="display:none" onclick="_selAllToggle(this.dataset.selContainer)"><i class="fa-solid fa-check-double"></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></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>@@@ -462,11 +469,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</h2>++ <h2 class="ch-sec-h" id="ch-h-pastes">Pastes <span class="ch-sec-n" id="ch-pastes-n" hidden></span></h2><div class="ch-panel-bar" id="ch-pastes-header">-- <a class="ch-view-all" id="ch-pastes-all" href="#">View all pastes</a><span id="ch-pastes-sort"></span>-- <button class="btn btn-small btn-outline sel-all-btn" data-sel-container="ch-pastes-grid" style="display:none" onclick="_selAllToggle(this.dataset.selContainer)"><i class="fa-solid fa-check-double"></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></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>@@@ -136,7 +136,8 @@<label class="settings-row-label" for="dash-bot-controls-mode">Controls start</label><div class="settings-row-content"><select id="dash-bot-controls-mode">-- <option value="panel">Under the video</option>++ <option value="panel">The full panel under the video</option>++ <option value="compact">A row under the video</option><option value="overlay">On the video</option></select><p class="muted" style="font-size:0.8rem;margin:4px 0 0">Each viewer can move them or hide them; this is where they start before anyone chooses.</p>@@@ -198,6 +198,70 @@ function _channelRail(grid, offset, count, total, loadNext, old = []) {grid._railObserver = observer;observer.observe(sentinel);}++// A section's count beside its heading (ask 355: "View all (96)" was a button of its own).++function _secCount(id, total) {++ const n = document.getElementById(id);++ if (!n) return;++ const v = Number(total) || 0;++ n.textContent = v > 999 ? `${Math.floor(v / 100) / 10}k` : String(v);++ n.hidden = v <= 0;++}++++// Recordings in progress (ask 355, "auto update how long they've been live … updating thumbnail … preview"): one clock++// for every such card on the page, ticking each second while one is on screen, and the stream's picture fetched again++// every 30 s (10 s while a pointer rests on it), only while visible and the tab is in front. Nothing runs without them.++let _chRecTimer = 0, _chRecIO = null;++const _chRecSeen = new Set();++function _chRecSince(raw) {++ const s = String(raw || '');++ const t = Date.parse(/[zZ]|[+-]\d\d:?\d\d$/.test(s) ? s : `${s.replace(' ', 'T')}Z`);++ return Number.isFinite(t) ? t : 0;++}++function _chRecTick() {++ // The clock stops when no recording card is left on the page; a card off screen or a tab behind costs nothing.++ if (!document.querySelector('.ch-rec-card')) { clearInterval(_chRecTimer); _chRecTimer = 0; _chRecSeen.clear(); return; }++ const cards = [..._chRecSeen].filter((c) => c.isConnected);++ if (!cards.length || document.hidden) return;++ const now = Date.now();++ for (const card of cards) {++ const time = card.querySelector('.ch-rec-time[data-since] .ch-rec-t');++ const since = time ? _chRecSince(time.closest('.ch-rec-time').dataset.since) : 0;++ if (since) time.textContent = formatDuration(Math.max(0, Math.floor((now - since) / 1000)));++ const every = card.matches(':hover') ? 10000 : 30000;++ if (now - (card._ovPicAt || now) >= every && !card._ovPicBusy) _chRecPicture(card);++ if (!card._ovPicAt) card._ovPicAt = now;++ }++}++async function _chRecPicture(card) {++ const id = card.dataset.streamId;++ if (!id) return;++ card._ovPicBusy = true;++ try {++ const s = await api(`/streams/${encodeURIComponent(id)}`);++ const url = s && s.thumbnail_url;++ const img = card.querySelector('.stream-card-thumb > img');++ if (url && img && img.getAttribute('src') !== url) {++ // The next picture loads off screen, then takes the old one's place without a blank frame.++ const next = new Image();++ next.onload = () => { img.src = url; img.style.display = ''; const ph = img.nextElementSibling; if (ph && ph.tagName === 'I') ph.style.display = 'none'; };++ next.src = url;++ }++ if (s && !s.is_live) _chRecSeen.delete(card);++ } catch { /* the next tick tries again */ }++ card._ovPicAt = Date.now();++ card._ovPicBusy = false;++}++function _chRecWatch(root) {++ const cards = root ? [...root.querySelectorAll('.ch-rec-card')] : [];++ if (!cards.length) return;++ if (typeof IntersectionObserver === 'function') {++ if (!_chRecIO) _chRecIO = new IntersectionObserver((entries) => entries.forEach((e) => { if (e.isIntersecting) _chRecSeen.add(e.target); else _chRecSeen.delete(e.target); }));++ cards.forEach((c) => _chRecIO.observe(c));++ } else cards.forEach((c) => _chRecSeen.add(c));++ if (!_chRecTimer) _chRecTimer = setInterval(_chRecTick, 1000);++ if (window.ov && ov.scope) ov.scope().onDispose(() => { clearInterval(_chRecTimer); _chRecTimer = 0; _chRecSeen.clear(); if (_chRecIO) { _chRecIO.disconnect(); _chRecIO = null; } });++}++// Channel VOD filter/sort statelet currentChannelVodFilter = null; // numeric managed stream id or null = alllet currentChannelVodOrder = 'newest'; // newest|oldest|views|peak_viewers@@@ -222,14 +286,24 @@ async function renderChannelVodsSection(username, liveStreams, vods, meta = {})].map(([val, label]) =>`<option value="${val}" ${currentChannelVodOrder === val ? 'selected' : ''}>${label}</option>`).join('');++ // Ask 355 (2026-11-11, "use icons, buttons"): two icon buttons in the head row, each with the native picker laid++ // over it, so a phone opens its own list and a computer its own menu; the filter shows a dot while it filters.++ const msName = (currentChannelManagedStreams.find((ms) => ms.id === currentChannelVodFilter) || {}).title;++ const orderName = { newest: 'Newest first', oldest: 'Oldest first', views: 'Most views', peak_viewers: 'Peak viewers' }[currentChannelVodOrder] || 'Newest first';filterBar.innerHTML = `-- <select class="ch-vods-filter-select" onchange="setChannelVodFilter(this.value ? parseInt(this.value) : null)">-- <option value="" ${currentChannelVodFilter === null ? 'selected' : ''}>All streams</option>-- ${msOptions}-- </select>-- <select class="ch-vods-filter-select" onchange="setChannelVodOrder(this.value)">-- ${orderOptions}-- </select>++ <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>++ <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>++ <select class="ch-vods-filter-select" data-ov-native aria-label="Order" onchange="setChannelVodOrder(this.value)">++ ${orderOptions}++ </select>++ </label>`;filterBar.style.display = '';} else if (filterBar) {@@@ -257,17 +331,22 @@ async function renderChannelVodsSection(username, liveStreams, vods, meta = {})const liveVod = await api(`/vods/stream/${ls.id}/live`);if (liveVod && liveVod.vod) {const v = liveVod.vod;++ // Ask 355: a recording in progress shows the stream's own picture (the recording has none until it++ // ends), refreshed while it is on screen; its clock runs from the stream's start (_chRecWatch).++ const pic = ls.thumbnail_url || v.thumbnail_url;++ const bg = typeof _liveBg === 'function' ? _liveBg(pic) : '';liveVodHtml += `-- <a class="stream-card" href="${videoHref('vod', v.id)}" onclick="return handleLinkClick(event, '${videoHref('vod', v.id)}')" style="border:2px solid var(--accent);position:relative">++ <a class="stream-card ch-rec-card is-live" data-stream-id="${esc(String(ls.id))}"${bg ? ` style="--live-bg:${esc(bg)}"` : ''} href="${videoHref('vod', v.id)}" onclick="return handleLinkClick(event, '${videoHref('vod', v.id)}')"><div class="stream-card-thumb">-- ${thumbImg(v.thumbnail_url, 'fa-video', v.title, `/api/thumbnails/generate/vod/${v.id}`)}++ ${thumbImg(pic, 'fa-video', v.title, ls.thumbnail_url ? null : `/api/thumbnails/generate/vod/${v.id}`)}${audioChip(v)}-- <span class="stream-card-nsfw" style="background:#e53e3e;animation:pulse 2s infinite">● RECORDING</span>-- <span class="stream-card-viewers"><i class="fa-solid fa-clock"></i> ${formatDuration(v.duration_seconds || 0)}</span>++ <span class="ch-rec-pill"><span class="ch-rec-dot" aria-hidden="true"></span>Recording</span>++ <span class="stream-card-viewers ch-rec-time"${ls.started_at ? ` data-since="${esc(ls.started_at)}"` : ''}><i class="fa-solid fa-clock" aria-hidden="true"></i> <span class="ch-rec-t">${formatDuration(v.duration_seconds || 0)}</span></span>++ <span class="ch-rec-watch" aria-hidden="true"><i class="fa-solid fa-play"></i></span></div><div class="stream-card-info"><div class="stream-card-title">${esc(v.title || 'Live Recording')}</div>-- <div class="stream-card-streamer muted">In progress — ${esc(ls.title || 'Live Stream')}</div>++ <div class="stream-card-streamer muted">Recording now · ${esc(ls.title || 'Live Stream')}</div></div></a>`;}@@@ -299,8 +378,10 @@ async function renderChannelVodsSection(username, liveStreams, vods, meta = {})}_channelRail(vodsGrid, offset, vods.length, total, () => setChannelVodsPage(page + 1), oldCards);++ _chRecWatch(vodsGrid);const all = document.getElementById('ch-vods-all');-- if (all) { all.href = `/@${encodeURIComponent(username)}?section=videos`; all.textContent = `View all (${total})`; }++ if (all) all.href = `/@${encodeURIComponent(username)}?section=videos`;++ _secCount('ch-vods-n', total);document.getElementById('ch-vods-pagination').style.display = 'none';_selSyncAllBtns();}@@@ -462,7 +543,8 @@ function _renderClipsTakenGrid(username, data) {const page = Math.floor((data.offset || 0) / CLIPS_TAKEN_PAGE_SIZE) + 1;_channelRail(grid, data.offset || 0, clips.length, data.total || 0, () => setClipsTakenPage(page + 1), oldCards);const all = document.getElementById('ch-clips-taken-all');-- if (all) { all.href = `/@${encodeURIComponent(username)}?section=clips-taken`; all.textContent = `View all (${data.total || 0})`; }++ if (all) all.href = `/@${encodeURIComponent(username)}?section=clips-taken`;++ _secCount('ch-clips-taken-n', data.total || 0);document.getElementById('ch-clips-pagination').style.display = 'none';_selSyncAllBtns();}@@@ -510,7 +592,8 @@ function renderChannelClipsOfSection(username, clips, meta = {}) {_channelRail(grid, offset, clips.length, total, () => setChannelClipsOfPage(page + 1), oldCards);const all = document.getElementById('ch-clips-of-all');-- if (all) { all.href = `/@${encodeURIComponent(username)}?section=clips`; all.textContent = `View all (${total})`; }++ if (all) all.href = `/@${encodeURIComponent(username)}?section=clips`;++ _secCount('ch-clips-of-n', total);document.getElementById('ch-clips-of-pagination').style.display = 'none';}@@@ -650,7 +733,8 @@ async function loadChannelPastes(username = currentChannelUsername, { append = fconst total = Number(data.total) || pastes.length;_channelRail(grid, offset, pastes.length, total, () => loadChannelPastes(username, { append: true }), oldCards);const all = document.getElementById('ch-pastes-all');-- if (all) { all.href = `/@${encodeURIComponent(username)}?section=pastes`; all.textContent = `View all (${total})`; }++ if (all) all.href = `/@${encodeURIComponent(username)}?section=pastes`;++ _secCount('ch-pastes-n', total);if (pager) { pager.style.display = 'none'; pager.innerHTML = ''; }_selSyncAllBtns();} catch { /* silent */ }@@@ -2938,7 +3022,11 @@ function _applyBotEmbed(botEmbed, username) {const menuBtn = document.getElementById('ch-bot-mode-btn');const cardBar = document.getElementById('ch-bot-card-bar');const cardSwitch = document.getElementById('ch-bot-card-switch');-- const line = document.getElementById('ch-bot-card-line');++ const tabs = document.getElementById('ch-bot-tabs');++ const openLink = document.getElementById('ch-bot-open');++ const pageEl = document.getElementById('page-channel');++ // A phone held upright: the full panel shares the chat's place behind two tabs, and the default is a row.++ const phoneMq = typeof window !== 'undefined' && typeof window.matchMedia === 'function' ? window.matchMedia('(max-width: 768px) and (orientation: portrait)') : null;const player = document.getElementById('video-container');const robotId = botEmbed && botEmbed.robot_id ? botEmbed.robot_id : null;@@@ -2960,7 +3048,7 @@ function _applyBotEmbed(botEmbed, username) {_applyBotEmbed.setBar = setBar;function mountIn(container, mode, toggleSection) {stopFrame();-- const frame = BotEmbed.mount(container, botEmbed, { mode: mode === 'overlay' ? 'overlay' : null, toggleSection });++ const frame = BotEmbed.mount(container, botEmbed, { mode: mode === 'panel' ? null : mode, toggleSection });if (frame && typeof BotEmbed.listen === 'function') {_botEmbedStop = BotEmbed.listen({frame, botEmbed,@@@ -2975,8 +3063,8 @@ function _applyBotEmbed(botEmbed, username) {stopFrame();clear(slot); clear(box);if (slot) slot.hidden = true;-- if (line) line.hidden = true;if (section) section.hidden = true;++ pageClasses(null);},onOpenPanel: () => _applyBotEmbed.choose('panel'),onFocusPlayer: () => {@@@ -2987,7 +3075,28 @@ function _applyBotEmbed(botEmbed, username) {}return frame;}-- const MODE_NAME = { overlay: 'on the video', panel: 'under the video', hidden: 'hidden' };++ const MODE_NAME = { overlay: 'on the video', compact: 'a row under the video', panel: 'the full panel', hidden: 'hidden' };++ // 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.++ function pageClasses(mode) {++ if (!pageEl || !pageEl.classList) return;++ const robotPane = mode === 'panel' && _applyBotEmbed.tab !== 'chat';++ pageEl.classList.toggle('ch-has-bot', !!mode);++ pageEl.classList.toggle('ch-bot-panel', mode === 'panel');++ pageEl.classList.toggle('ch-bot-compact', mode === 'compact');++ pageEl.classList.toggle('ch-bot-chat', mode === 'panel' && !robotPane);++ if (tabs && tabs.querySelectorAll) {++ for (const b of tabs.querySelectorAll('[data-bot-tab]')) b.setAttribute('aria-selected', String((b.dataset.botTab === 'robot') === robotPane));++ }++ }++ function setTab(tab) {++ _applyBotEmbed.tab = tab === 'chat' ? 'chat' : 'robot';++ pageClasses(_applyBotEmbed.mode);++ // A folded chat comes back with its tab.++ if (pageEl && pageEl.classList && pageEl.classList.contains('chat-collapsed') && typeof toggleChannelChat === 'function') toggleChannelChat(false);++ }++ _applyBotEmbed.setTab = setTab;function closeMenu() {if (!menu) return;menu.hidden = true;@@@ -3012,12 +3121,13 @@ function _applyBotEmbed(botEmbed, username) {}function render() {if (typeof BotEmbed === 'undefined') return;-- const mode = BotEmbed.effectiveMode(botEmbed, BotEmbed.readChoice(window, username, robotId));++ const mode = BotEmbed.effectiveMode(botEmbed, BotEmbed.readChoice(window, username, robotId), { phone: !!(phoneMq && phoneMq.matches) });_applyBotEmbed.mode = mode;_applyBotEmbed.frame = null;_applyBotEmbed.botEmbed = botEmbed;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 (slot) {slot.hidden = false;@@@ -3025,27 +3135,27 @@ function _applyBotEmbed(botEmbed, username) {}clear(box);box.hidden = true;-- if (line) line.hidden = false;if (section) section.hidden = false;-- } else if (mode === 'panel') {++ } else if (mode === 'panel' || mode === 'compact') {if (slot) { clear(slot); slot.hidden = true; }-- if (line) line.hidden = true;box.hidden = false;if (section) section.hidden = false;-- _applyBotEmbed.frame = mountIn(box, 'panel', true);++ _applyBotEmbed.frame = mountIn(box, mode, true);} else {++ // Hidden: no frame, and the head row stays so the controls can come back from where they went.stopFrame();clear(box); clear(slot);box.hidden = true;if (slot) slot.hidden = true;-- if (line) line.hidden = true;-- if (section) section.hidden = true;++ if (section) section.hidden = !robotId;}++ pageClasses(mode);sync(mode);}function choose(mode, persist = true) {if (typeof BotEmbed === 'undefined' || !BotEmbed.validView(mode)) return;if (persist && robotId && BotEmbed.writeChoice) BotEmbed.writeChoice(window, username, robotId, mode);++ if (mode === 'panel') setTab('robot'); // asked for the panel: show it, on a phone toocloseMenu();render();}@@@ -3081,12 +3191,27 @@ function _applyBotEmbed(botEmbed, username) {if (b) _applyBotEmbed.choose(b.dataset.botMode);});}-- if (line && line.dataset.botBound !== '1') {-- line.dataset.botBound = '1';-- line.addEventListener('click', (e) => {-- if (e.target && e.target.closest && e.target.closest('[data-bot-show-panel]')) _applyBotEmbed.choose('panel');++ if (tabs && tabs.dataset && tabs.dataset.botBound !== '1') {++ tabs.dataset.botBound = '1';++ tabs.addEventListener('click', (e) => {++ const b = e.target && e.target.closest ? e.target.closest('[data-bot-tab]') : null;++ if (!b) return;++ // Robot asks for the full panel when the controls are somewhere else (on the video, a row, hidden).++ if (b.dataset.botTab === 'robot' && _applyBotEmbed.mode !== 'panel') _applyBotEmbed.choose('panel');++ else _applyBotEmbed.setTab(b.dataset.botTab);++ });++ }++ // Turning the phone (or a window crossing the phone width) changes the default; a viewer's own choice stays.++ if (phoneMq && typeof phoneMq.addEventListener === 'function' && !_applyBotEmbed.mqBound) {++ _applyBotEmbed.mqBound = true;++ phoneMq.addEventListener('change', () => {++ const be = _applyBotEmbed.botEmbed;++ if (!be || typeof BotEmbed === 'undefined' || !_applyBotEmbed.render) return;++ if (BotEmbed.readChoice(window, _applyBotEmbed.username, be.robot_id)) return;++ _applyBotEmbed.render();});}++ _applyBotEmbed.username = username;// The pointer on the page (outside the clipped controls) wakes the overlay's idle fade.if (player && player.dataset.botActivity !== '1') {player.dataset.botActivity = '1';@@@ -3113,6 +3238,7 @@ function _applyBotEmbed(botEmbed, username) {if (window.ov) ov.scope().onDispose(() => {stopFrame();clear(slot);++ pageClasses(null);if (typeof BotEmbed !== 'undefined') BotEmbed.mount(box, null);else clear(box);if (_applyBotEmbed.barObserver) { _applyBotEmbed.barObserver.disconnect(); _applyBotEmbed.barObserver = null; }@@@ -36,7 +36,8 @@const SANDBOX = 'allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox allow-forms';const PANEL_MODES = ['panel', 'overlay', 'compact'];-- const VIEW_MODES = ['overlay', 'panel', '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'];const CHOICE_KEY = 'ov:live:bot-controls';function embedSrc(botEmbed, mode = null) {@@@ -99,9 +100,16 @@return `${CHOICE_KEY}:${String(channel || '')}:${String(robotId || '')}`;}function validView(mode) { return VIEW_MODES.includes(mode) ? mode : null; }-- function defaultMode(botEmbed) { return botEmbed && botEmbed.controls_mode === 'overlay' ? 'overlay' : 'panel'; }-- function effectiveMode(botEmbed, stored) {-- return validView(stored) || defaultMode(botEmbed);++ // The streamer's default, except that on a phone held upright the full panel starts on the video (ask 353,++ // 2026-11-11: the panel under the player took the whole screen and the chat could not be reached; Maticus, 11-10:++ // the stick and the head slider over the stream "would be great for phone"). A streamer's row stays a row.++ function defaultMode(botEmbed, { phone = false } = {}) {++ const m = botEmbed && botEmbed.controls_mode;++ if (m === 'overlay' || m === 'compact') return m;++ return phone ? 'overlay' : 'panel';++ }++ function effectiveMode(botEmbed, stored, opts) {++ return validView(stored) || defaultMode(botEmbed, opts);}function readChoice(win, channel, robotId) {try {@@@ -161,6 +169,7 @@// computed style. Plain colours only on the Bot side; --color-scheme is 'dark' or 'light'.const THEME_TOKENS = ['--bg-primary', '--bg-card', '--text-primary', '--text-secondary', '--accent', '--accent-strong', '--on-accent-strong', '--border'];const MIN_HEIGHT = 160;++ const COMPACT_MIN = 96;const MAX_HEIGHT = 720;function pageTheme(win) {@@@ -196,10 +205,11 @@// The panel's height: it measures its content (a ResizeObserver on its end, at most 5×/s) and the page sets// the card around the frame to it, between MIN_HEIGHT and MAX_HEIGHT. Once it has spoken the card is no// longer hand-resizable (#ch-bot-container's resize handle in public/css/features/channel.css).-- const setHeight = (value) => {++ const setHeight = (value, mode) => {const height = Number(value);if (!Number.isFinite(height) || height <= 0) return;-- const clamped = Math.max(MIN_HEIGHT, Math.min(MAX_HEIGHT, Math.round(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)));try {const card = typeof frame.closest === 'function' ? frame.closest('#ch-bot-container') : null;const host = card || frame;@@@ -301,7 +311,7 @@if (d.robot_id == null || d.robot_id === robotId) {// An overlay fills the player; its clip keeps the presses outside the controls going through.if (d.mode === 'overlay') applyClip(frame, d);-- else { applyClip(frame, null); setHeight(d.height); }++ else { applyClip(frame, null); setHeight(d.height, d.mode); }if (typeof onSize === 'function') onSize(d);}return;@@@ -6,6 +6,10 @@sheet 1180px and narrower, portrait: the bottom sheet; the grip on its top edge sets the height, andletting go most of the way down closes the sheet++ A phone held upright while a stream is live (ask 353, 2026-11-11): the chat is docked under the player and starts++ all the way up to it; its grip sets how tall it is (the channel line shows in the room it leaves), all the way up++ again is the default, and letting go most of the way down folds it to the round chat button.++Double-click or double-tap a grip (or press Enter on it) for the default size; arrow keys move it 24px.Each layout remembers its own size (localStorage), applied as CSS variables on #page-channel, whichpublic/css/features/channel.css reads: --chat-w, --chat-ow, --chat-sheet-h.@@@ -14,12 +18,29 @@'use strict';const WIDE = window.matchMedia('(min-width: 1181px)');const LAND = window.matchMedia('(orientation: landscape)');++ const PHONE = window.matchMedia('(max-width: 768px) and (orientation: portrait)');++ const page = () => document.getElementById('page-channel');++ // Docked: a phone upright, live, and not a portrait stream (that one keeps its chat over the picture).++ const docked = () => {++ const p = page();++ return PHONE.matches && !!p && p.classList.contains('ch-is-live') && !p.querySelector('.video-container.is-vertical');++ };++ // The docked chat's room: from the player's bottom (or the robot's row under it) to the column's end.++ function dockRoom() {++ const p = page();++ const layout = p && p.querySelector('.stream-layout');++ const v = p && p.querySelector('#video-container');++ if (!layout || !v) return window.innerHeight * 0.6;++ let top = v.getBoundingClientRect().bottom;++ const row = p.classList.contains('ch-bot-compact') ? p.querySelector('#ch-bot-section') : null;++ if (row) top += row.getBoundingClientRect().height;++ return Math.max(180, layout.getBoundingClientRect().bottom - top);++ }const MODES = {side: { key: 'openvibe_chat_width', prop: '--chat-w', axis: 'x', min: 260, max: () => Math.min(window.innerWidth * 0.5, 760) },overlay: { key: 'openvibe_chat_overlay_w', prop: '--chat-ow', axis: 'x', min: 260, max: () => window.innerWidth * 0.7 },-- sheet: { key: 'openvibe_chat_sheet_h', prop: '--chat-sheet-h', axis: 'y', min: 180, max: () => window.innerHeight * 0.92 },++ sheet: { key: 'openvibe_chat_sheet_h', prop: '--chat-sheet-h', axis: 'y', min: 180, max: () => (docked() ? dockRoom() : window.innerHeight * 0.92) },};-- const page = () => document.getElementById('page-channel');const modeOf = () => (WIDE.matches ? 'side' : LAND.matches ? 'overlay' : 'sheet');const clamp = (v, a, b) => Math.round(Math.max(a, Math.min(b, v)));const read = (k) => { try { return parseInt(localStorage.getItem(k), 10) || 0; } catch { return 0; } };@@@ -29,6 +50,8 @@const p = page(); const m = MODES[mode];if (!p) return;if (v) p.style.setProperty(m.prop, `${clamp(v, m.min, m.max())}px`); else p.style.removeProperty(m.prop);++ // A sheet with a height of its own leaves the rest to the channel line; without one it fills (channel.css).++ if (mode === 'sheet') p.classList.toggle('ch-chat-sized', !!v);}function applySaved() { for (const mode of Object.keys(MODES)) setSize(mode, read(MODES[mode].key)); }function settle() { window.dispatchEvent(new Event('resize')); } // the player and the diagrams re-measure@@@ -78,7 +101,7 @@if (Math.abs(delta) > 3) drag.moved = true;// The sheet may go below its minimum while dragging: letting go there closes it.drag.cur = Math.round(Math.max(drag.mode === 'sheet' ? 60 : m.min, Math.min(m.max(), drag.from + delta)));-- const p = page(); if (p) p.style.setProperty(m.prop, `${drag.cur}px`);++ const p = page(); if (p) { p.style.setProperty(m.prop, `${drag.cur}px`); if (drag.mode === 'sheet') p.classList.add('ch-chat-sized'); }sync(g, sidebar);});const end = (e) => {@@@ -93,6 +116,16 @@return;}const m = MODES[d.mode];++ if (d.mode === 'sheet' && docked()) {++ // Docked: most of the way down folds the chat to its round button; all the way up is "fill" again.++ if (d.cur < m.min) {++ setSize('sheet', read(m.key));++ if (typeof window.toggleChannelChat === 'function') window.toggleChannelChat(true);++ sync(g, sidebar);++ return;++ }++ if (d.cur >= m.max() - 16) { reset('sheet'); sync(g, sidebar); return; }++ }if (d.mode === 'sheet' && d.cur < m.min) {setSize('sheet', read(m.key)); // keep the last real height for next timeconst s = document.querySelector('#page-channel .chat-sidebar.mobile-chat-open');@@@ -115,6 +148,7 @@e.preventDefault();const r = sidebar.getBoundingClientRect();const v = clamp((m.axis === 'x' ? r.width : r.height) + grow[e.key] * 24, m.min, m.max());++ if (mode === 'sheet' && docked() && v >= m.max()) { reset('sheet'); sync(g, sidebar); return; }setSize(mode, v); write(m.key, v); settle();sync(g, sidebar);});@@@ -776,7 +776,7 @@ async function loadBotEmbedSetting() {if (!be || !be.enabled) { card.style.display = 'none'; return; }document.getElementById('dash-bot-robot-id').value = be.robot_id || '';const mode = document.getElementById('dash-bot-controls-mode');-- if (mode) mode.value = be.controls_mode === 'overlay' ? 'overlay' : 'panel';++ if (mode) mode.value = ['overlay', 'compact'].includes(be.controls_mode) ? be.controls_mode : 'panel';card.style.display = '';} catch { card.style.display = 'none'; }}@@@ -788,7 +788,7 @@ async function saveBotEmbedSetting() {const id = input.value.trim();try {const body = { robot_id: id || null };-- if (mode && (mode.value === 'overlay' || mode.value === 'panel')) body.controls_mode = mode.value;++ if (mode && ['overlay', 'compact', 'panel'].includes(mode.value)) body.controls_mode = mode.value;const data = await api(`/streams/channel/${encodeURIComponent(currentUser.username)}/bot`, { method: 'PUT', body });const bound = data && data.bot_embed && data.bot_embed.robot_id;if (status) status.textContent = bound ? `Bound to ${bound}` : 'No robot bound';@@@ -49,8 +49,8 @@ function embedUrl(robotId) {return `${botOrigin()}/panel/${encodeURIComponent(robotId)}/embed`;}--/** The two places a streamer may put the controls by default; a viewer may still hide them or move them. */--const CONTROL_MODES = ['overlay', 'panel'];++/** The places a streamer may put the controls by default (a row since ask 353); a viewer may still hide or move them. */++const CONTROL_MODES = ['overlay', 'compact', 'panel'];const controlMode = (value) => (CONTROL_MODES.includes(value) ? value : 'panel');/** The channel page's `bot_embed` value: undefined with the flag off, else { enabled, robot_id, url, controls_mode }. */@@@ -43,7 +43,7 @@ router.put('/channel/:username/bot', (req, res, next) => (embed.enabled() ? nextlet mode = embed.controlMode(channel.bot_controls_mode);if (hasMode) {if (!embed.CONTROL_MODES.includes(body.controls_mode)) {-- return res.status(400).json({ error: "controls_mode must be 'overlay' or 'panel'" });++ return res.status(400).json({ error: "controls_mode must be 'overlay', 'compact' or 'panel'" });}mode = body.controls_mode;}@@@ -974,8 +974,12 @@ function render(meta, urlPath) {const contentEnd = html.indexOf('</div>', contentStart);if (contentEnd !== -1) html = html.slice(0, contentStart) + cards + html.slice(contentEnd);}-- for (const [section, id] of [['videos', 'ch-vods-all'], ['clips', 'ch-clips-of-all'], ['clips-taken', 'ch-clips-taken-all'], ['pastes', 'ch-pastes-all']]) {-- html = html.replace(`id="${id}" href="#">View all`, `id="${id}" href="/@${esc(meta.channelUsername)}?section=${section}">View all (${Number(meta.railCounts[section]) || 0})`);++ // Each section's View all is an icon button with its words for readers and crawlers; the count sits beside the++ // heading (ask 355, 2026-11-11).++ for (const [section, id, countId] of [['videos', 'ch-vods-all', 'ch-vods-n'], ['clips', 'ch-clips-of-all', 'ch-clips-of-n'], ['clips-taken', 'ch-clips-taken-all', 'ch-clips-taken-n'], ['pastes', 'ch-pastes-all', 'ch-pastes-n']]) {++ const count = Number(meta.railCounts[section]) || 0;++ html = html.replace(`id="${id}" href="#"`, `id="${id}" href="/@${esc(meta.channelUsername)}?section=${section}"`);++ if (count > 0) html = html.replace(`<span class="ch-sec-n" id="${countId}" hidden></span>`, `<span class="ch-sec-n" id="${countId}">${count > 999 ? `${Math.floor(count / 100) / 10}k` : count}</span>`);}}@@@ -138,6 +138,11 @@ console.warn = (...a) => { warnings.push(a.join(' ')); };assert.strictEqual(mode.status, 200, mode.text);assert.deepStrictEqual(mode.body.bot_embed, { enabled: true, robot_id: 'rob_sim123', url: 'https://openvibe.bot/panel/rob_sim123/embed', controls_mode: 'overlay' });assert.strictEqual((await channelGet()).body.bot_embed.controls_mode, 'overlay', 'the channel serializer carries it to the page');++ // A row under the video (ask 353): the database takes it (migration 0039) and the page gets it.++ const row = await call('PUT', '/api/streams/channel/rover/bot', { user: 1, body: { controls_mode: 'compact' } });++ assert.strictEqual(row.status, 200, row.text);++ assert.strictEqual((await channelGet()).body.bot_embed.controls_mode, 'compact');++ assert.strictEqual((await db.getChannelByUserId(1)).bot_controls_mode, 'compact');// Both together, then an invalid value changes nothing.const both = await call('PUT', '/api/streams/channel/rover/bot', { user: 1, body: { robot_id: 'rob_sim123', controls_mode: 'panel' } });assert.strictEqual(both.status, 200);@@@ -125,6 +125,14 @@ async function check(name, fn) {assert.strictEqual(BotEmbed.effectiveMode(overlayDefault, 'hidden'), 'hidden', 'or hide them');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.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');++ assert.strictEqual(BotEmbed.effectiveMode({ ...SIM, controls_mode: 'compact' }, null), 'compact', 'a streamer may start everyone with a row');++ assert.strictEqual(BotEmbed.effectiveMode(SIM, 'compact'), 'compact', 'and a viewer may choose it');++ assert.strictEqual(BotEmbed.effectiveMode(SIM, 'panel', { phone: true }), 'panel', 'a viewer\'s own choice beats the phone default');assert.strictEqual(BotEmbed.iframeAttrs(SIM, { mode: 'overlay' }).src, 'https://openvibe.bot/panel/rob_sim123/embed?mode=overlay');assert.strictEqual(BotEmbed.iframeAttrs(SIM, { mode: 'compact' }).src, 'https://openvibe.bot/panel/rob_sim123/embed?mode=compact');assert.strictEqual(BotEmbed.iframeAttrs(SIM, { mode: 'bogus' }), null, 'only the three views');@@@ -39,10 +39,22 @@ for (const [name, text] of [['channel.css', css]]) {assert.match(String(mediaAt(text, i)), /max-width:\s*1180px\)\s*and\s*\(orientation:\s*landscape/, `${name}: --chat-ow outside the landscape overlay`);}for (const i of uses(text, '--chat-sheet-h')) {-- assert.match(String(mediaAt(text, i)), /max-width:\s*1180px\)\s*and\s*\(orientation:\s*portrait/, `${name}: --chat-sheet-h outside the portrait sheet`);++ // The phone's docked chat (768px and narrower, upright; ask 353) is the same sheet on a smaller screen.++ assert.match(String(mediaAt(text, i)), /max-width:\s*(1180|768)px\)\s*and\s*\(orientation:\s*portrait/, `${name}: --chat-sheet-h outside the portrait sheet`);}}assert.ok(uses(css, '--chat-w').length === 1, 'the side width is set in one place');// The sheet itself is the full width of the screen.assert.match(css, /@media \(max-width: 1180px\) and \(orientation: portrait\) \{ #page-channel \.stream-layout > \.chat-sidebar \{ width: 100%;/);++// The phone's docked chat (ask 353, 2026-11-11): it fills up to the player until it has a height of its own++// (ch-chat-sized, set by public/js/channel-chat-resize.js with --chat-sheet-h), its grip is shown, and letting go low++// folds it to the round chat button rather than the tablet sheet's close.++const resize = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'channel-chat-resize.js'), 'utf8');++assert.match(resize, /if \(mode === 'sheet'\) p\.classList\.toggle\('ch-chat-sized', !!v\);/, 'a saved height marks the page');++assert.match(resize, /max: \(\) => \(docked\(\) \? dockRoom\(\) : window\.innerHeight \* 0\.92\)/, 'docked, the room under the player is the most');++assert.match(resize, /if \(d\.cur < m\.min\) \{\s*setSize\('sheet', read\(m\.key\)\);\s*if \(typeof window\.toggleChannelChat === 'function'\) window\.toggleChannelChat\(true\);/, 'low folds the chat');++assert.match(resize, /if \(d\.cur >= m\.max\(\) - 16\) \{ reset\('sheet'\);/, 'all the way up is the default again');++assert.match(css, /#page-channel\.ch-is-live:not\(\.ch-chat-sized\):not\(\.chat-collapsed\) [^{]*:is\(\.ch-info-bar, \.ch-weather-widget\) \{ display: none !important; \}/, 'full: the chat reaches the player');++assert.match(css, /#page-channel\.ch-is-live\.ch-chat-sized\.page [^{]*> \.chat-sidebar \{ flex: 0 1 var\(--chat-sheet-h, 60%\); min-height: 120px; \}/, 'sized: the chat keeps its height');++assert.match(css, /> \.chat-sidebar > \.chat-grip \{ display: block !important;/, 'the grip shows on the docked chat');console.log('chat-sheet-width: each saved chat size applies only in its own layout');
new file mode 100644
new file mode 100644
@@ -0,0 +1,6 @@
@@ -0,0 +1,6 @@
+-- phase: expand
+-- The streamer's default place for a robot's controls may also be 'compact': a row under the video (ask 353,
+-- 2026-11-11). The release before this reads an unknown value as 'panel' (server/bot/embed.js controlMode), so it
+-- keeps working on a channel that chose the row.
+ALTER TABLE channels DROP CONSTRAINT IF EXISTS channels_bot_controls_mode;
+ALTER TABLE channels ADD CONSTRAINT channels_bot_controls_mode CHECK (bot_controls_mode IS NULL OR bot_controls_mode IN ('overlay', 'compact', 'panel'));
@@ -1160,6 +1160,14 @@ button.viewer-badge:focus-visible { outline: 2px solid #93c5fd; outline-offset:
@@ -1160,6 +1160,14 @@ button.viewer-badge:focus-visible { outline: 2px solid #93c5fd; outline-offset:
align-self: flex-start;
align-self: flex-start;
}
}
}
}
+/* Two streams live at once (the stream tabs bar above the player): the bar stays under the navbar and the chat under
+ the bar. Before, the chat started under the bar but was the navbar's height short of the window, so its last 42 px,
+ the tools under the message box, sat below the screen (found 2026-11-11). The sections' jump bar sticks under both. */
+@media (min-width: 1181px) {
+ #page-channel.has-live-tabs .live-stream-tabs { position: sticky; top: var(--navbar-h); z-index: 7; }
+ #page-channel.has-live-tabs .stream-layout > .chat-sidebar { top: calc(var(--navbar-h) + 42px); height: calc(100vh - var(--navbar-h) - 42px); }
+ #page-channel.has-live-tabs .ch-tabs-wrap { top: calc(var(--ovnav-h, 56px) + 42px); }
+}
.ch-stream-ai { margin-top: 8px; max-width: 760px; }
.ch-stream-ai { margin-top: 8px; max-width: 760px; }
.cu-btn-sm { padding: 4px 10px; font-size: 0.78rem; }
.cu-btn-sm { padding: 4px 10px; font-size: 0.78rem; }
.cu-btn-ghost { background: transparent; border: 1px solid var(--border); }
.cu-btn-ghost { background: transparent; border: 1px solid var(--border); }
@@ -2040,19 +2048,139 @@ html[data-ov-motion=off] #page-channel .ch-tab-panel{scroll-margin-top: calc(var
@@ -2040,19 +2048,139 @@ html[data-ov-motion=off] #page-channel .ch-tab-panel{scroll-margin-top: calc(var
.ch-bot-mode-menu[hidden] { display: none; }
.ch-bot-mode-menu[hidden] { display: none; }
.ch-bot-mode-menu button { min-height: 36px; padding: 0 10px; border: 0; border-radius: 7px; background: transparent; color: #fff; text-align: left; font-size: .8rem; }
.ch-bot-mode-menu button { min-height: 36px; padding: 0 10px; border: 0; border-radius: 7px; background: transparent; color: #fff; text-align: left; font-size: .8rem; }
.ch-bot-mode-menu button[aria-checked="true"] { background: color-mix(in srgb, var(--accent) 35%, transparent); }
.ch-bot-mode-menu button[aria-checked="true"] { background: color-mix(in srgb, var(--accent) 35%, transparent); }
-/* The card's switch and its one-line state when the controls are on the video. */
-.ch-bot-card-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 8px 14px; border-bottom: 1px solid var(--border); }
-.ch-bot-card-bar[hidden] { display: none; }
-.ch-bot-card-label { color: var(--text-muted, var(--text-secondary)); font-size: .78rem; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; }
-.ch-bot-switch { display: inline-flex; gap: 2px; padding: 2px; border-radius: 10px; background: color-mix(in srgb, var(--text-primary, #fff) 7%, transparent); }
-.ch-bot-switch button { min-height: 30px; padding: 0 9px; border: 0; border-radius: 8px; background: transparent; color: var(--text-secondary); font-size: .78rem; font-weight: 600; }
-.ch-bot-switch button[aria-pressed="true"] { background: var(--accent); color: var(--on-accent-strong, #fff); }
-.ch-bot-card-line { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; padding: 12px 14px; color: var(--text-secondary); font-size: .9rem; }
-.ch-bot-card-line[hidden] { display: none; }
-.ch-bot-card-line button { min-height: 36px; padding: 0 12px; border: 1px solid var(--border); border-radius: 9px; background: transparent; color: var(--text-primary); font-size: .85rem; font-weight: 600; }
+/* The card (ask 353, 2026-11-11: "more compact, use icons and buttons in a smarter way"): one head row with the name,
+ the four places for the controls as icons (the chosen one says its name) and the robot's own page; then the frame,
+ which is the full panel or a row (Bot's compact view). On the video, or hidden, the card is just that row. */
#ch-bot-section { margin: 10px 0 0; border: 1px solid var(--border); border-radius: 16px; background: var(--bg-card, var(--bg-secondary)); overflow: hidden; }
#ch-bot-section { margin: 10px 0 0; border: 1px solid var(--border); border-radius: 16px; background: var(--bg-card, var(--bg-secondary)); overflow: hidden; }
-#ch-bot-section > h3 { display: flex; align-items: center; gap: 9px; margin: 0; padding: 10px 14px; font-size: .9rem; font-weight: 700; color: var(--text-primary); border-bottom: 1px solid var(--border); }
-#ch-bot-section > h3::before { content: ""; flex: none; width: 8px; height: 8px; border-radius: 50%; background: var(--success-text, #34d399); box-shadow: 0 0 0 3px color-mix(in srgb, var(--success-text, #34d399) 22%, transparent); }
-#ch-bot-container { resize: vertical; overflow: hidden; height: clamp(230px, 36vh, 460px); min-height: 160px; max-height: 80vh; }
+#ch-bot-section[hidden] { display: none !important; }
+.ch-bot-head { display: flex; align-items: center; gap: 8px; min-height: 46px; padding: 5px 6px 5px 14px; }
+.ch-bot-head[hidden] { display: none; }
+#ch-bot-section:has(#ch-bot-container:not([hidden])) > .ch-bot-head { border-bottom: 1px solid var(--border); }
+.ch-bot-head h3 { display: flex; align-items: center; gap: 9px; flex: 1 1 auto; min-width: 0; margin: 0; font-size: .9rem; font-weight: 700; color: var(--text-primary); }
+.ch-bot-head h3::before { content: ""; flex: none; width: 8px; height: 8px; border-radius: 50%; background: var(--success-text, #34d399); box-shadow: 0 0 0 3px color-mix(in srgb, var(--success-text, #34d399) 22%, transparent); }
+.ch-bot-tabs { display: none; flex: 1 1 auto; min-width: 0; gap: 4px; }
+.chat-header .ch-bot-tabs { margin-right: auto; }
+.ch-bot-tabs button { display: inline-flex; align-items: center; gap: 7px; min-height: 36px; padding: 0 12px; border: 0; border-radius: 10px; background: transparent; color: var(--text-secondary); font: 700 .85rem/1 inherit; }
+.ch-bot-tabs button[aria-selected="true"] { background: color-mix(in srgb, var(--accent) 18%, transparent); color: var(--text-primary); }
+.ch-bot-switch { display: inline-flex; flex: none; gap: 2px; padding: 3px; border-radius: 12px; background: color-mix(in srgb, var(--text-primary, #fff) 7%, transparent); }
+.ch-bot-switch button { display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-width: 36px; min-height: 34px; padding: 0 10px; border: 0; border-radius: 9px; background: transparent; color: var(--text-secondary); font-size: .78rem; font-weight: 600; }
+.ch-bot-switch button:hover { color: var(--text-primary); }
+.ch-bot-switch button[aria-pressed="true"] { background: var(--accent-strong, var(--accent)); color: var(--on-accent-strong, #fff); }
+.ch-bot-open { display: inline-flex; flex: none; align-items: center; justify-content: center; width: 36px; height: 36px; border-radius: 10px; color: var(--text-secondary); text-decoration: none; }
+.ch-bot-open:hover, .ch-bot-open:focus-visible { color: var(--text-primary); background: color-mix(in srgb, var(--text-primary, #fff) 7%, transparent); }
+@media (max-width: 1100px) {
+ .ch-bot-switch button:not([aria-pressed="true"]) { width: 36px; padding: 0; }
+ .ch-bot-switch button:not([aria-pressed="true"]) span { display: none; }
+}
+#ch-bot-container { resize: vertical; overflow: hidden; height: clamp(230px, 36vh, 460px); min-height: 112px; max-height: 80vh; }
+#ch-bot-container[hidden] { display: none; }
#ch-bot-container iframe { display: block; width: 100%; height: 100%; border: 0; background: transparent; }
#ch-bot-container iframe { display: block; width: 100%; height: 100%; border: 0; background: transparent; }
+#page-channel.ch-bot-compact #ch-bot-container { height: 120px; resize: none; }
@media (max-width: 768px) { #ch-bot-section { margin: 8px; } #ch-bot-container { height: clamp(220px, 44vh, 420px); resize: none; } }
@media (max-width: 768px) { #ch-bot-section { margin: 8px; } #ch-bot-container { height: clamp(220px, 44vh, 420px); resize: none; } }
+
+/* A phone held upright, live (ask 353: "how do you even open the chat anymore?"; then, with the first version open:
+ "the chat should go all the way up to the video … then it can be collapsed … a little circle in the corner … resize
+ how tall the chat is"). The column under the player is one sheet:
+ - the chat fills it, right up to the player, with one header row: Chat and Robot tabs (with a robot), its tools and
+ the fold. The grip on its top edge sets its height (or the arrow keys on it): the channel line shows in the room it
+ leaves; all the way up is the default again; most of the way down folds it to a round chat button in the corner.
+ - the robot: over the video (the default here), a row just under the player, or the full panel in the Robot tab.
+ - folded, the page reads as before: the player, the channel line, the robot's card, the sections.
+ Where the robot's controls go is the player's robot button. A portrait stream keeps its own layout. */
+@media (max-width: 768px) and (orientation: portrait) {
+ #page-channel.ch-is-live .stream-layout:not(:has(.video-container.is-vertical)) > .stream-main { display: contents; }
+ #page-channel.ch-is-live .stream-layout:not(:has(.video-container.is-vertical)) > .stream-main > .video-container { order: 0; flex: none; }
+ #page-channel.ch-is-live .stream-layout:not(:has(.video-container.is-vertical)) > .stream-main > :is(.ch-captions, .ch-info-bar, .ch-weather-widget) { order: 1; flex: none; }
+ #page-channel.ch-is-live .stream-layout:not(:has(.video-container.is-vertical)) > .stream-main > #ch-bot-section { order: 2; flex: none; margin: 0; border-width: 1px 0 0; border-radius: 0; }
+ #page-channel.ch-is-live .stream-layout:not(:has(.video-container.is-vertical)) > .chat-sidebar { order: 3; }
+ /* All the way up: the channel line waits under the chat (fold or lower the chat to see it). */
+ #page-channel.ch-is-live:not(.ch-chat-sized):not(.chat-collapsed) .stream-layout:not(:has(.video-container.is-vertical)) > .stream-main > :is(.ch-info-bar, .ch-weather-widget) { display: none !important; }
+ /* A height of its own: the chat keeps it, the channel line takes the rest above it. */
+ #page-channel.ch-is-live.ch-chat-sized:not(.chat-collapsed) .stream-layout:not(:has(.video-container.is-vertical)) > .stream-main > .ch-info-bar { flex: 1 1 0; min-height: 0; overflow-y: auto; align-items: flex-start; align-content: flex-start; }
+ #page-channel.ch-is-live.ch-chat-sized:not(.chat-collapsed) .stream-layout:not(:has(.video-container.is-vertical)) > .stream-main > .ch-weather-widget { display: none !important; }
+ #page-channel.ch-is-live.ch-chat-sized.page .stream-layout:not(:has(.video-container.is-vertical)) > .chat-sidebar { flex: 0 1 var(--chat-sheet-h, 60%); min-height: 120px; }
+
+ /* The chat's header: one row with the grip above it. */
+ #page-channel.ch-is-live .stream-layout:not(:has(.video-container.is-vertical)) > .chat-sidebar > .chat-grip { display: block !important; height: 16px; }
+ #page-channel.ch-is-live .stream-layout:not(:has(.video-container.is-vertical)) > .chat-sidebar > .chat-grip::after { top: 5px; opacity: .8; }
+ #page-channel.ch-is-live .stream-layout:not(:has(.video-container.is-vertical)) > .chat-sidebar .chat-header { padding-top: 12px; min-height: 52px; }
+ #page-channel.ch-is-live.ch-has-bot .chat-header .ch-chat-title { display: none; }
+ #page-channel.ch-is-live.ch-has-bot .ch-bot-tabs { display: flex; flex: 0 1 auto; }
+
+ /* The robot's place. Its card's own head row is the desktop's; here the tabs and the player's button do that. */
+ #page-channel.ch-is-live .ch-bot-head { display: none !important; }
+ #page-channel.ch-is-live:not(.ch-bot-panel):not(.ch-bot-compact) #ch-bot-section { display: none !important; }
+ #page-channel.ch-is-live.ch-bot-compact #ch-bot-container { height: 120px; }
+ /* The Robot tab: the panel takes the sheet under the chat's header, and scrolls inside its frame. */
+ #page-channel.ch-is-live.ch-bot-panel:not(.ch-bot-chat):not(.chat-collapsed) .stream-layout:not(:has(.video-container.is-vertical)) > .stream-main > :is(.ch-info-bar, .ch-weather-widget) { display: none !important; }
+ #page-channel.ch-is-live.ch-bot-panel:not(.ch-bot-chat):not(.chat-collapsed) .stream-layout:not(:has(.video-container.is-vertical)) > .stream-main > #ch-bot-section { order: 4; flex: 1 1 0; min-height: 0; display: flex; flex-direction: column; border: 0; }
+ #page-channel.ch-is-live.ch-bot-panel:not(.ch-bot-chat):not(.chat-collapsed) #ch-bot-container { flex: 1 1 0; min-height: 0; height: auto !important; max-height: none; }
+ #page-channel.ch-is-live.ch-bot-panel:not(.ch-bot-chat):not(.chat-collapsed).page .stream-layout > .chat-sidebar { flex: none; min-height: 0; }
+ #page-channel.ch-is-live.ch-bot-panel:not(.ch-bot-chat):not(.chat-collapsed) .stream-layout > .chat-sidebar > :not(.chat-header) { display: none !important; }
+ /* The Chat tab: the panel's frame waits, still loaded (a driver keeps their place). */
+ #page-channel.ch-is-live.ch-bot-panel.ch-bot-chat:not(.chat-collapsed) #ch-bot-section { display: none !important; }
+
+ /* Folded: the round chat button in the corner brings the sheet back. */
+ body:has(#page-channel.active.ch-is-live) .ch-chat-reopen:not([hidden]) { width: 56px; height: 56px; padding: 0; gap: 0; justify-content: center; border-radius: 50%; }
+ body:has(#page-channel.active.ch-is-live) .ch-chat-reopen span { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
+ body:has(#page-channel.active.ch-is-live) .ch-chat-reopen :is(.fa-comments, svg) { font-size: 22px; width: 22px; height: 22px; }
+ body:has(#page-channel.active.ch-is-live) .ch-chat-reopen .fa-angles-left { display: none !important; }
+
+ /* The channel line, folded: who, live now and the title, with its actions; a tap on it (the chevron) opens the
+ followers, the alert, the labels and the links. The goal bar is one slim row. */
+ #page-channel.ch-is-live .stream-layout:not(:has(.video-container.is-vertical)) .ch-info-bar:not(.ch-info-open) :is(.ch-username, .ch-meta, .ch-social-pills, .stream-labels) { display: none !important; }
+ #page-channel.ch-is-live .stream-layout:not(:has(.video-container.is-vertical)) .ch-info-bar { row-gap: 8px; padding: 8px 12px; }
+ #page-channel.ch-is-live .stream-layout:not(:has(.video-container.is-vertical)) .ch-info-bar-top h2::after { content: ""; display: inline-block; flex: none; width: 6px; height: 6px; margin: 0 0 3px 8px; border: solid currentColor; border-width: 0 2px 2px 0; transform: rotate(45deg); opacity: .6; transition: transform .2s; }
+ #page-channel.ch-is-live .stream-layout:not(:has(.video-container.is-vertical)) .ch-info-bar.ch-info-open .ch-info-bar-top h2::after { transform: translateY(3px) rotate(225deg); }
+ #page-channel.ch-is-live .stream-layout:not(:has(.video-container.is-vertical)) .ch-info-bar-right :is(.ch-action, .ovk-ask) { height: 40px; min-height: 40px; }
+ #page-channel.ch-is-live .chat-sidebar .ch-goal-widget { padding: 4px 6px 0; }
+ #page-channel.ch-is-live .chat-sidebar .cgw-goal { height: 32px; }
+ #page-channel.ch-is-live .chat-sidebar .cgw-track .cgw-goal { margin-bottom: 4px; }
+}
+
+/* ── A section's head, compact (ask 355, 2026-11-11: "buttons need to be moved to where the title bar is … the filters
+ need to use icons … smaller, compact … button icons with nice custom animations"). The count sits beside the
+ heading; the tools are 36 px icon buttons (40 on a touch screen) in the same row, at every width: filter and order
+ carry the native picker over the icon (a phone opens its own list), then select, OpenVibe.Video, and View all. ─── */
+#page-channel .ch-sec-h { display: flex; align-items: baseline; gap: 8px; }
+#page-channel .ch-sec-n { font-size: .78rem; font-weight: 700; color: var(--text-muted, var(--text-secondary)); font-variant-numeric: tabular-nums; letter-spacing: 0; }
+#page-channel .ch-sec-n[hidden] { display: none; }
+#page-channel .ch-panel-bar { gap: 4px; }
+#page-channel .ch-panel-bar > .ch-vods-filter-bar { display: inline-flex; gap: 4px; margin: 0; }
+#page-channel .ch-ib {
+ position: relative; display: inline-grid; place-items: center; flex: none; width: 36px; height: 36px; min-height: 0; padding: 0;
+ border: 1px solid transparent; border-radius: 10px; background: color-mix(in srgb, var(--text-primary, #fff) 6%, transparent);
+ color: var(--text-secondary); cursor: pointer; text-decoration: none; font-size: .95rem;
+ transition: background-color var(--ov-dur-1, .12s) var(--ov-ease-out, ease), color var(--ov-dur-1, .12s), border-color var(--ov-dur-1, .12s), transform var(--ov-dur-1, .12s) var(--ov-ease-out, ease);
+}
+#page-channel .ch-ib:hover { color: var(--text-primary); background: color-mix(in srgb, var(--text-primary, #fff) 11%, transparent); }
+#page-channel .ch-ib:active { transform: scale(.9); }
+#page-channel .ch-ib:focus-visible, #page-channel .ch-ib:has(select:focus-visible) { outline: 2px solid var(--accent); outline-offset: 2px; }
+#page-channel .ch-ib > :is(i, svg) { transition: transform var(--ov-dur-2, .2s) var(--ov-ease-out, cubic-bezier(.22,1,.36,1)); pointer-events: none; }
+#page-channel .ch-ib:hover > :is(i, svg) { transform: translateY(-1px); }
+#page-channel .ch-view-all.ch-ib:hover > :is(i, svg) { transform: scale(1.12); }
+#page-channel .ch-ib .sel-all-label { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
+/* The picker over the icon: the whole button is the select, invisible, so the platform's own list opens. */
+#page-channel .ch-ib-pick select { position: absolute; inset: 0; width: 100%; height: 100%; min-height: 0; margin: 0; padding: 0; border: 0; opacity: 0; cursor: pointer; font-size: 16px; -webkit-appearance: none; appearance: none; }
+#page-channel .ch-ib-pick.is-on { color: var(--accent-light, var(--accent)); background: color-mix(in srgb, var(--accent) 16%, transparent); }
+#page-channel .ch-ib-pick.is-on::after { content: ""; position: absolute; top: 6px; right: 6px; width: 6px; height: 6px; border-radius: 50%; background: var(--accent); box-shadow: 0 0 0 2px var(--bg-primary); }
+#page-channel .ch-ib-pick[data-order="views"] > :is(i, svg), #page-channel .ch-ib-pick[data-order="peak_viewers"] > :is(i, svg) { color: var(--accent-light, var(--accent)); }
+@media (hover: none) { #page-channel .ch-ib { width: 40px; height: 40px; } }
+@media (max-width: 600px) {
+ /* The tools stay in the heading's row: they are icons now. */
+ #page-channel :is(#ch-panel-videos, #ch-panel-clips, #ch-panel-clips-taken, #ch-panel-pastes) { grid-template-columns: minmax(0, 1fr) auto; }
+ #page-channel :is(#ch-panel-videos, #ch-panel-clips, #ch-panel-clips-taken, #ch-panel-pastes) > .ch-panel-bar { grid-column: 2; grid-row: 1; justify-content: flex-end; }
+ #page-channel .ch-view-all.ch-ib { min-height: 0; }
+}
+
+/* A recording in progress: the stream's own picture as a live card (style.css .stream-card.is-live), a Recording pill
+ whose dot breathes, the clock that runs, and a play mark on hover. */
+#page-channel .ch-rec-card { border-width: 1px; }
+#page-channel .ch-rec-pill { position: absolute; top: 8px; left: 8px; z-index: 2; display: inline-flex; align-items: center; gap: 6px; height: 24px; padding: 0 9px 0 8px; border-radius: 999px; background: var(--live-red, #e5484d); color: #fff; font-size: .7rem; font-weight: 800; letter-spacing: .04em; text-transform: uppercase; }
+#page-channel .ch-rec-dot { width: 7px; height: 7px; border-radius: 50%; background: #fff; animation: chRecBreathe 1.6s var(--ov-ease-in-out, ease-in-out) infinite; }
+@keyframes chRecBreathe { 50% { opacity: .35; transform: scale(.8); } }
+html[data-ov-motion="off"] #page-channel .ch-rec-dot { animation: none; }
+#page-channel .ch-rec-time { font-variant-numeric: tabular-nums; }
+#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; }
@@ -62,7 +62,8 @@
@@ -62,7 +62,8 @@
</button>
</button>
<div class="ch-bot-mode-menu" id="ch-bot-mode-menu" role="menu" hidden>
<div class="ch-bot-mode-menu" id="ch-bot-mode-menu" role="menu" hidden>
<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="panel">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="hidden">Hidden</button>
<button type="button" role="menuitemradio" data-bot-mode="hidden">Hidden</button>
</div>
</div>
</div>
</div>
@@ -116,19 +117,18 @@
@@ -116,19 +117,18 @@
</div>
</div>
</div>
</div>
<!-- OpenVibe.Bot robot panel for a channel bound to a robot (LIVE_BOT_EMBED; filled by public/js/bot-embed.js) -->
<!-- OpenVibe.Bot robot panel for a channel bound to a robot (LIVE_BOT_EMBED; filled by public/js/bot-embed.js) -->
+ <!-- One head row (ask 353): the name, the four places for the controls, and the robot's own page. Hidden
+ keeps this row. On a phone the row steps aside: the Chat/Robot tabs are in the chat's header. -->
<section id="ch-bot-section" aria-labelledby="ch-bot-heading" hidden>
<section id="ch-bot-section" aria-labelledby="ch-bot-heading" hidden>
- <h3 id="ch-bot-heading">Robot</h3>
- <div class="ch-bot-card-bar" id="ch-bot-card-bar" hidden>
- <span class="ch-bot-card-label">Controls</span>
- <div class="ch-bot-switch" id="ch-bot-card-switch" role="group" aria-label="Robot controls">
- <button type="button" data-bot-mode="overlay">On the video</button>
- <button type="button" data-bot-mode="panel">Under the video</button>
- <button type="button" data-bot-mode="hidden">Hidden</button>
+ <div class="ch-bot-head" id="ch-bot-card-bar" hidden>
+ <h3 id="ch-bot-heading">Robot</h3>
+ <div class="ch-bot-switch" id="ch-bot-card-switch" role="group" aria-label="Where the robot's controls go">
+ <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="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>
- </div>
- <div class="ch-bot-card-line" id="ch-bot-card-line" hidden>
- <span>Robot · controls are on the video</span>
- <button type="button" data-bot-show-panel>Show panel</button>
+ <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>
</div>
</div>
<div id="ch-bot-container"></div>
<div id="ch-bot-container"></div>
</section>
</section>
@@ -189,7 +189,12 @@
@@ -189,7 +189,12 @@
<div class="chat-sidebar" id="chat-sidebar">
<div class="chat-sidebar" id="chat-sidebar">
<div class="chat-header">
<div class="chat-header">
<button type="button" class="chat-settings-btn ch-chat-collapse" onclick="toggleChannelChat(true)" title="Hide chat" aria-label="Hide chat"><i class="fa-solid fa-angles-right"></i></button>
<button type="button" class="chat-settings-btn ch-chat-collapse" onclick="toggleChannelChat(true)" title="Hide chat" aria-label="Hide chat"><i class="fa-solid fa-angles-right"></i></button>
- <span>STREAM CHAT</span>
+ <span class="ch-chat-title">STREAM CHAT</span>
+ <!-- A channel with a robot, on a phone: Chat and Robot share this sheet (app-channel.js _applyBotEmbed). -->
+ <div class="ch-bot-tabs" id="ch-bot-tabs" role="tablist" aria-label="Chat or robot">
+ <button type="button" role="tab" data-bot-tab="chat" aria-selected="true"><i class="fa-solid fa-comments" aria-hidden="true"></i><span>Chat</span></button>
+ <button type="button" role="tab" data-bot-tab="robot" aria-selected="false"><i class="fa-solid fa-robot" aria-hidden="true"></i><span>Robot</span></button>
+ </div>
<div class="chat-header-actions">
<div class="chat-header-actions">
<button class="chat-settings-btn" onclick="toggleChatUsers(this)" title="Users">
<button class="chat-settings-btn" onclick="toggleChatUsers(this)" title="Users">
<i class="fa-solid fa-users"></i>
<i class="fa-solid fa-users"></i>
@@ -421,12 +426,12 @@
@@ -421,12 +426,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</h2>
+ <h2 class="ch-sec-h" id="ch-h-videos">Videos <span class="ch-sec-n" id="ch-vods-n" hidden></span></h2>
<div class="ch-panel-bar">
<div class="ch-panel-bar">
- <a class="ch-view-all" id="ch-vods-all" href="#">View all videos</a>
<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>
- <a id="ch-vods-video-link" class="btn btn-small btn-outline" href="#" style="display:none" title="This channel's recordings and clips on OpenVibe.Video, with AI chapters, captions and playlists"><i class="fa-solid fa-film"></i> On OpenVibe.Video</a>
- <button class="btn btn-small btn-outline sel-all-btn" data-sel-container="ch-vods-grid" style="display:none" onclick="_selAllToggle(this.dataset.selContainer)"><i class="fa-solid fa-check-double"></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>
</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>
@@ -434,8 +439,10 @@
@@ -434,8 +439,10 @@
<!-- 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</h2>
- <div class="ch-panel-bar"><a class="ch-view-all" id="ch-clips-of-all" href="#">View all clips</a></div>
+ <h2 class="ch-sec-h" id="ch-h-clips">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>
+ </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>
<div id="ch-clips-of-pagination" class="pastes-pagination" style="display:none"></div>
<div id="ch-clips-of-pagination" class="pastes-pagination" style="display:none"></div>
@@ -449,12 +456,12 @@
@@ -449,12 +456,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</h2>
+ <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>
<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">
- <a class="ch-view-all" id="ch-clips-taken-all" href="#">View all clips taken</a>
<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 class="btn btn-small btn-outline sel-all-btn" data-sel-container="ch-clips-grid" style="display:none" onclick="_selAllToggle(this.dataset.selContainer)"><i class="fa-solid fa-check-double"></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>
</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>
@@ -462,11 +469,11 @@
@@ -462,11 +469,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</h2>
+ <h2 class="ch-sec-h" id="ch-h-pastes">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">
- <a class="ch-view-all" id="ch-pastes-all" href="#">View all pastes</a>
<span id="ch-pastes-sort"></span>
<span id="ch-pastes-sort"></span>
- <button class="btn btn-small btn-outline sel-all-btn" data-sel-container="ch-pastes-grid" style="display:none" onclick="_selAllToggle(this.dataset.selContainer)"><i class="fa-solid fa-check-double"></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>
</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>
@@ -136,7 +136,8 @@
@@ -136,7 +136,8 @@
<label class="settings-row-label" for="dash-bot-controls-mode">Controls start</label>
<label class="settings-row-label" for="dash-bot-controls-mode">Controls start</label>
<div class="settings-row-content">
<div class="settings-row-content">
<select id="dash-bot-controls-mode">
<select id="dash-bot-controls-mode">
- <option value="panel">Under the video</option>
+ <option value="panel">The full panel under the video</option>
+ <option value="compact">A row under the video</option>
<option value="overlay">On the video</option>
<option value="overlay">On the video</option>
</select>
</select>
<p class="muted" style="font-size:0.8rem;margin:4px 0 0">Each viewer can move them or hide them; this is where they start before anyone chooses.</p>
<p class="muted" style="font-size:0.8rem;margin:4px 0 0">Each viewer can move them or hide them; this is where they start before anyone chooses.</p>
@@ -198,6 +198,70 @@ function _channelRail(grid, offset, count, total, loadNext, old = []) {
@@ -198,6 +198,70 @@ function _channelRail(grid, offset, count, total, loadNext, old = []) {
grid._railObserver = observer;
grid._railObserver = observer;
observer.observe(sentinel);
observer.observe(sentinel);
}
}
+// A section's count beside its heading (ask 355: "View all (96)" was a button of its own).
+function _secCount(id, total) {
+ const n = document.getElementById(id);
+ if (!n) return;
+ const v = Number(total) || 0;
+ n.textContent = v > 999 ? `${Math.floor(v / 100) / 10}k` : String(v);
+ n.hidden = v <= 0;
+}
+
+// Recordings in progress (ask 355, "auto update how long they've been live … updating thumbnail … preview"): one clock
+// for every such card on the page, ticking each second while one is on screen, and the stream's picture fetched again
+// every 30 s (10 s while a pointer rests on it), only while visible and the tab is in front. Nothing runs without them.
+let _chRecTimer = 0, _chRecIO = null;
+const _chRecSeen = new Set();
+function _chRecSince(raw) {
+ const s = String(raw || '');
+ const t = Date.parse(/[zZ]|[+-]\d\d:?\d\d$/.test(s) ? s : `${s.replace(' ', 'T')}Z`);
+ return Number.isFinite(t) ? t : 0;
+}
+function _chRecTick() {
+ // The clock stops when no recording card is left on the page; a card off screen or a tab behind costs nothing.
+ if (!document.querySelector('.ch-rec-card')) { clearInterval(_chRecTimer); _chRecTimer = 0; _chRecSeen.clear(); return; }
+ const cards = [..._chRecSeen].filter((c) => c.isConnected);
+ if (!cards.length || document.hidden) return;
+ const now = Date.now();
+ for (const card of cards) {
+ const time = card.querySelector('.ch-rec-time[data-since] .ch-rec-t');
+ const since = time ? _chRecSince(time.closest('.ch-rec-time').dataset.since) : 0;
+ if (since) time.textContent = formatDuration(Math.max(0, Math.floor((now - since) / 1000)));
+ const every = card.matches(':hover') ? 10000 : 30000;
+ if (now - (card._ovPicAt || now) >= every && !card._ovPicBusy) _chRecPicture(card);
+ if (!card._ovPicAt) card._ovPicAt = now;
+ }
+}
+async function _chRecPicture(card) {
+ const id = card.dataset.streamId;
+ if (!id) return;
+ card._ovPicBusy = true;
+ try {
+ const s = await api(`/streams/${encodeURIComponent(id)}`);
+ const url = s && s.thumbnail_url;
+ const img = card.querySelector('.stream-card-thumb > img');
+ if (url && img && img.getAttribute('src') !== url) {
+ // The next picture loads off screen, then takes the old one's place without a blank frame.
+ const next = new Image();
+ next.onload = () => { img.src = url; img.style.display = ''; const ph = img.nextElementSibling; if (ph && ph.tagName === 'I') ph.style.display = 'none'; };
+ next.src = url;
+ }
+ if (s && !s.is_live) _chRecSeen.delete(card);
+ } catch { /* the next tick tries again */ }
+ card._ovPicAt = Date.now();
+ card._ovPicBusy = false;
+}
+function _chRecWatch(root) {
+ const cards = root ? [...root.querySelectorAll('.ch-rec-card')] : [];
+ if (!cards.length) return;
+ if (typeof IntersectionObserver === 'function') {
+ if (!_chRecIO) _chRecIO = new IntersectionObserver((entries) => entries.forEach((e) => { if (e.isIntersecting) _chRecSeen.add(e.target); else _chRecSeen.delete(e.target); }));
+ cards.forEach((c) => _chRecIO.observe(c));
+ } else cards.forEach((c) => _chRecSeen.add(c));
+ if (!_chRecTimer) _chRecTimer = setInterval(_chRecTick, 1000);
+ if (window.ov && ov.scope) ov.scope().onDispose(() => { clearInterval(_chRecTimer); _chRecTimer = 0; _chRecSeen.clear(); if (_chRecIO) { _chRecIO.disconnect(); _chRecIO = null; } });
+}
+
// Channel VOD filter/sort state
// Channel VOD filter/sort state
let currentChannelVodFilter = null; // numeric managed stream id or null = all
let currentChannelVodFilter = null; // numeric managed stream id or null = all
let currentChannelVodOrder = 'newest'; // newest|oldest|views|peak_viewers
let currentChannelVodOrder = 'newest'; // newest|oldest|views|peak_viewers
@@ -222,14 +286,24 @@ async function renderChannelVodsSection(username, liveStreams, vods, meta = {})
@@ -222,14 +286,24 @@ async function renderChannelVodsSection(username, liveStreams, vods, meta = {})
].map(([val, label]) =>
].map(([val, label]) =>
`<option value="${val}" ${currentChannelVodOrder === val ? 'selected' : ''}>${label}</option>`
`<option value="${val}" ${currentChannelVodOrder === val ? 'selected' : ''}>${label}</option>`
).join('');
).join('');
+ // Ask 355 (2026-11-11, "use icons, buttons"): two icon buttons in the head row, each with the native picker laid
+ // over it, so a phone opens its own list and a computer its own menu; the filter shows a dot while it filters.
+ const msName = (currentChannelManagedStreams.find((ms) => ms.id === currentChannelVodFilter) || {}).title;
+ const orderName = { newest: 'Newest first', oldest: 'Oldest first', views: 'Most views', peak_viewers: 'Peak viewers' }[currentChannelVodOrder] || 'Newest first';
filterBar.innerHTML = `
filterBar.innerHTML = `
- <select class="ch-vods-filter-select" onchange="setChannelVodFilter(this.value ? parseInt(this.value) : null)">
- <option value="" ${currentChannelVodFilter === null ? 'selected' : ''}>All streams</option>
- ${msOptions}
- </select>
- <select class="ch-vods-filter-select" onchange="setChannelVodOrder(this.value)">
- ${orderOptions}
- </select>
+ <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>
+ <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>
+ <select class="ch-vods-filter-select" data-ov-native aria-label="Order" onchange="setChannelVodOrder(this.value)">
+ ${orderOptions}
+ </select>
+ </label>
`;
`;
filterBar.style.display = '';
filterBar.style.display = '';
} else if (filterBar) {
} else if (filterBar) {
@@ -257,17 +331,22 @@ async function renderChannelVodsSection(username, liveStreams, vods, meta = {})
@@ -257,17 +331,22 @@ async function renderChannelVodsSection(username, liveStreams, vods, meta = {})
const liveVod = await api(`/vods/stream/${ls.id}/live`);
const liveVod = await api(`/vods/stream/${ls.id}/live`);
if (liveVod && liveVod.vod) {
if (liveVod && liveVod.vod) {
const v = liveVod.vod;
const v = liveVod.vod;
+ // Ask 355: a recording in progress shows the stream's own picture (the recording has none until it
+ // ends), refreshed while it is on screen; its clock runs from the stream's start (_chRecWatch).
+ const pic = ls.thumbnail_url || v.thumbnail_url;
+ const bg = typeof _liveBg === 'function' ? _liveBg(pic) : '';
liveVodHtml += `
liveVodHtml += `
- <a class="stream-card" href="${videoHref('vod', v.id)}" onclick="return handleLinkClick(event, '${videoHref('vod', v.id)}')" style="border:2px solid var(--accent);position:relative">
+ <a class="stream-card ch-rec-card is-live" data-stream-id="${esc(String(ls.id))}"${bg ? ` style="--live-bg:${esc(bg)}"` : ''} href="${videoHref('vod', v.id)}" onclick="return handleLinkClick(event, '${videoHref('vod', v.id)}')">
<div class="stream-card-thumb">
<div class="stream-card-thumb">
- ${thumbImg(v.thumbnail_url, 'fa-video', v.title, `/api/thumbnails/generate/vod/${v.id}`)}
+ ${thumbImg(pic, 'fa-video', v.title, ls.thumbnail_url ? null : `/api/thumbnails/generate/vod/${v.id}`)}
${audioChip(v)}
${audioChip(v)}
- <span class="stream-card-nsfw" style="background:#e53e3e;animation:pulse 2s infinite">● RECORDING</span>
- <span class="stream-card-viewers"><i class="fa-solid fa-clock"></i> ${formatDuration(v.duration_seconds || 0)}</span>
+ <span class="ch-rec-pill"><span class="ch-rec-dot" aria-hidden="true"></span>Recording</span>
+ <span class="stream-card-viewers ch-rec-time"${ls.started_at ? ` data-since="${esc(ls.started_at)}"` : ''}><i class="fa-solid fa-clock" aria-hidden="true"></i> <span class="ch-rec-t">${formatDuration(v.duration_seconds || 0)}</span></span>
+ <span class="ch-rec-watch" aria-hidden="true"><i class="fa-solid fa-play"></i></span>
</div>
</div>
<div class="stream-card-info">
<div class="stream-card-info">
<div class="stream-card-title">${esc(v.title || 'Live Recording')}</div>
<div class="stream-card-title">${esc(v.title || 'Live Recording')}</div>
- <div class="stream-card-streamer muted">In progress — ${esc(ls.title || 'Live Stream')}</div>
+ <div class="stream-card-streamer muted">Recording now · ${esc(ls.title || 'Live Stream')}</div>
</div>
</div>
</a>`;
</a>`;
}
}
@@ -299,8 +378,10 @@ async function renderChannelVodsSection(username, liveStreams, vods, meta = {})
@@ -299,8 +378,10 @@ async function renderChannelVodsSection(username, liveStreams, vods, meta = {})
}
}
_channelRail(vodsGrid, offset, vods.length, total, () => setChannelVodsPage(page + 1), oldCards);
_channelRail(vodsGrid, offset, vods.length, total, () => setChannelVodsPage(page + 1), oldCards);
+ _chRecWatch(vodsGrid);
const all = document.getElementById('ch-vods-all');
const all = document.getElementById('ch-vods-all');
- if (all) { all.href = `/@${encodeURIComponent(username)}?section=videos`; all.textContent = `View all (${total})`; }
+ if (all) all.href = `/@${encodeURIComponent(username)}?section=videos`;
+ _secCount('ch-vods-n', total);
document.getElementById('ch-vods-pagination').style.display = 'none';
document.getElementById('ch-vods-pagination').style.display = 'none';
_selSyncAllBtns();
_selSyncAllBtns();
}
}
@@ -462,7 +543,8 @@ function _renderClipsTakenGrid(username, data) {
@@ -462,7 +543,8 @@ function _renderClipsTakenGrid(username, data) {
const page = Math.floor((data.offset || 0) / CLIPS_TAKEN_PAGE_SIZE) + 1;
const page = Math.floor((data.offset || 0) / CLIPS_TAKEN_PAGE_SIZE) + 1;
_channelRail(grid, data.offset || 0, clips.length, data.total || 0, () => setClipsTakenPage(page + 1), oldCards);
_channelRail(grid, data.offset || 0, clips.length, data.total || 0, () => setClipsTakenPage(page + 1), oldCards);
const all = document.getElementById('ch-clips-taken-all');
const all = document.getElementById('ch-clips-taken-all');
- if (all) { all.href = `/@${encodeURIComponent(username)}?section=clips-taken`; all.textContent = `View all (${data.total || 0})`; }
+ if (all) all.href = `/@${encodeURIComponent(username)}?section=clips-taken`;
+ _secCount('ch-clips-taken-n', data.total || 0);
document.getElementById('ch-clips-pagination').style.display = 'none';
document.getElementById('ch-clips-pagination').style.display = 'none';
_selSyncAllBtns();
_selSyncAllBtns();
}
}
@@ -510,7 +592,8 @@ function renderChannelClipsOfSection(username, clips, meta = {}) {
@@ -510,7 +592,8 @@ function renderChannelClipsOfSection(username, clips, meta = {}) {
_channelRail(grid, offset, clips.length, total, () => setChannelClipsOfPage(page + 1), oldCards);
_channelRail(grid, offset, clips.length, total, () => setChannelClipsOfPage(page + 1), oldCards);
const all = document.getElementById('ch-clips-of-all');
const all = document.getElementById('ch-clips-of-all');
- if (all) { all.href = `/@${encodeURIComponent(username)}?section=clips`; all.textContent = `View all (${total})`; }
+ if (all) all.href = `/@${encodeURIComponent(username)}?section=clips`;
+ _secCount('ch-clips-of-n', total);
document.getElementById('ch-clips-of-pagination').style.display = 'none';
document.getElementById('ch-clips-of-pagination').style.display = 'none';
}
}
@@ -650,7 +733,8 @@ async function loadChannelPastes(username = currentChannelUsername, { append = f
@@ -650,7 +733,8 @@ async function loadChannelPastes(username = currentChannelUsername, { append = f
const total = Number(data.total) || pastes.length;
const total = Number(data.total) || pastes.length;
_channelRail(grid, offset, pastes.length, total, () => loadChannelPastes(username, { append: true }), oldCards);
_channelRail(grid, offset, pastes.length, total, () => loadChannelPastes(username, { append: true }), oldCards);
const all = document.getElementById('ch-pastes-all');
const all = document.getElementById('ch-pastes-all');
- if (all) { all.href = `/@${encodeURIComponent(username)}?section=pastes`; all.textContent = `View all (${total})`; }
+ if (all) all.href = `/@${encodeURIComponent(username)}?section=pastes`;
+ _secCount('ch-pastes-n', total);
if (pager) { pager.style.display = 'none'; pager.innerHTML = ''; }
if (pager) { pager.style.display = 'none'; pager.innerHTML = ''; }
_selSyncAllBtns();
_selSyncAllBtns();
} catch { /* silent */ }
} catch { /* silent */ }
@@ -2938,7 +3022,11 @@ function _applyBotEmbed(botEmbed, username) {
@@ -2938,7 +3022,11 @@ function _applyBotEmbed(botEmbed, username) {
const menuBtn = document.getElementById('ch-bot-mode-btn');
const menuBtn = document.getElementById('ch-bot-mode-btn');
const cardBar = document.getElementById('ch-bot-card-bar');
const cardBar = document.getElementById('ch-bot-card-bar');
const cardSwitch = document.getElementById('ch-bot-card-switch');
const cardSwitch = document.getElementById('ch-bot-card-switch');
- const line = document.getElementById('ch-bot-card-line');
+ const tabs = document.getElementById('ch-bot-tabs');
+ const openLink = document.getElementById('ch-bot-open');
+ const pageEl = document.getElementById('page-channel');
+ // A phone held upright: the full panel shares the chat's place behind two tabs, and the default is a row.
+ const phoneMq = typeof window !== 'undefined' && typeof window.matchMedia === 'function' ? window.matchMedia('(max-width: 768px) and (orientation: portrait)') : null;
const player = document.getElementById('video-container');
const player = document.getElementById('video-container');
const robotId = botEmbed && botEmbed.robot_id ? botEmbed.robot_id : null;
const robotId = botEmbed && botEmbed.robot_id ? botEmbed.robot_id : null;
@@ -2960,7 +3048,7 @@ function _applyBotEmbed(botEmbed, username) {
@@ -2960,7 +3048,7 @@ function _applyBotEmbed(botEmbed, username) {
_applyBotEmbed.setBar = setBar;
_applyBotEmbed.setBar = setBar;
function mountIn(container, mode, toggleSection) {
function mountIn(container, mode, toggleSection) {
stopFrame();
stopFrame();
- const frame = BotEmbed.mount(container, botEmbed, { mode: mode === 'overlay' ? 'overlay' : null, toggleSection });
+ const frame = BotEmbed.mount(container, botEmbed, { mode: mode === 'panel' ? null : mode, toggleSection });
if (frame && typeof BotEmbed.listen === 'function') {
if (frame && typeof BotEmbed.listen === 'function') {
_botEmbedStop = BotEmbed.listen({
_botEmbedStop = BotEmbed.listen({
frame, botEmbed,
frame, botEmbed,
@@ -2975,8 +3063,8 @@ function _applyBotEmbed(botEmbed, username) {
@@ -2975,8 +3063,8 @@ function _applyBotEmbed(botEmbed, username) {
stopFrame();
stopFrame();
clear(slot); clear(box);
clear(slot); clear(box);
if (slot) slot.hidden = true;
if (slot) slot.hidden = true;
- if (line) line.hidden = true;
if (section) section.hidden = true;
if (section) section.hidden = true;
+ pageClasses(null);
},
},
onOpenPanel: () => _applyBotEmbed.choose('panel'),
onOpenPanel: () => _applyBotEmbed.choose('panel'),
onFocusPlayer: () => {
onFocusPlayer: () => {
@@ -2987,7 +3075,28 @@ function _applyBotEmbed(botEmbed, username) {
@@ -2987,7 +3075,28 @@ function _applyBotEmbed(botEmbed, username) {
}
}
return frame;
return frame;
}
}
- const MODE_NAME = { overlay: 'on the video', panel: 'under the video', hidden: 'hidden' };
+ const MODE_NAME = { overlay: 'on the video', compact: 'a row under the video', panel: 'the full panel', hidden: 'hidden' };
+ // 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.
+ function pageClasses(mode) {
+ if (!pageEl || !pageEl.classList) return;
+ const robotPane = mode === 'panel' && _applyBotEmbed.tab !== 'chat';
+ pageEl.classList.toggle('ch-has-bot', !!mode);
+ pageEl.classList.toggle('ch-bot-panel', mode === 'panel');
+ pageEl.classList.toggle('ch-bot-compact', mode === 'compact');
+ pageEl.classList.toggle('ch-bot-chat', mode === 'panel' && !robotPane);
+ if (tabs && tabs.querySelectorAll) {
+ for (const b of tabs.querySelectorAll('[data-bot-tab]')) b.setAttribute('aria-selected', String((b.dataset.botTab === 'robot') === robotPane));
+ }
+ }
+ function setTab(tab) {
+ _applyBotEmbed.tab = tab === 'chat' ? 'chat' : 'robot';
+ pageClasses(_applyBotEmbed.mode);
+ // A folded chat comes back with its tab.
+ if (pageEl && pageEl.classList && pageEl.classList.contains('chat-collapsed') && typeof toggleChannelChat === 'function') toggleChannelChat(false);
+ }
+ _applyBotEmbed.setTab = setTab;
function closeMenu() {
function closeMenu() {
if (!menu) return;
if (!menu) return;
menu.hidden = true;
menu.hidden = true;
@@ -3012,12 +3121,13 @@ function _applyBotEmbed(botEmbed, username) {
@@ -3012,12 +3121,13 @@ function _applyBotEmbed(botEmbed, username) {
}
}
function render() {
function render() {
if (typeof BotEmbed === 'undefined') return;
if (typeof BotEmbed === 'undefined') return;
- const mode = BotEmbed.effectiveMode(botEmbed, BotEmbed.readChoice(window, username, robotId));
+ const mode = BotEmbed.effectiveMode(botEmbed, BotEmbed.readChoice(window, username, robotId), { phone: !!(phoneMq && phoneMq.matches) });
_applyBotEmbed.mode = mode;
_applyBotEmbed.mode = mode;
_applyBotEmbed.frame = null;
_applyBotEmbed.frame = null;
_applyBotEmbed.botEmbed = botEmbed;
_applyBotEmbed.botEmbed = botEmbed;
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 (mode === 'overlay') {
if (mode === 'overlay') {
if (slot) {
if (slot) {
slot.hidden = false;
slot.hidden = false;
@@ -3025,27 +3135,27 @@ function _applyBotEmbed(botEmbed, username) {
@@ -3025,27 +3135,27 @@ function _applyBotEmbed(botEmbed, username) {
}
}
clear(box);
clear(box);
box.hidden = true;
box.hidden = true;
- if (line) line.hidden = false;
if (section) section.hidden = false;
if (section) section.hidden = false;
- } else if (mode === 'panel') {
+ } else if (mode === 'panel' || mode === 'compact') {
if (slot) { clear(slot); slot.hidden = true; }
if (slot) { clear(slot); slot.hidden = true; }
- if (line) line.hidden = true;
box.hidden = false;
box.hidden = false;
if (section) section.hidden = false;
if (section) section.hidden = false;
- _applyBotEmbed.frame = mountIn(box, 'panel', true);
+ _applyBotEmbed.frame = mountIn(box, mode, true);
} else {
} else {
+ // Hidden: no frame, and the head row stays so the controls can come back from where they went.
stopFrame();
stopFrame();
clear(box); clear(slot);
clear(box); clear(slot);
box.hidden = true;
box.hidden = true;
if (slot) slot.hidden = true;
if (slot) slot.hidden = true;
- if (line) line.hidden = true;
- if (section) section.hidden = true;
+ if (section) section.hidden = !robotId;
}
}
+ pageClasses(mode);
sync(mode);
sync(mode);
}
}
function choose(mode, persist = true) {
function choose(mode, persist = true) {
if (typeof BotEmbed === 'undefined' || !BotEmbed.validView(mode)) return;
if (typeof BotEmbed === 'undefined' || !BotEmbed.validView(mode)) return;
if (persist && robotId && BotEmbed.writeChoice) BotEmbed.writeChoice(window, username, robotId, mode);
if (persist && robotId && BotEmbed.writeChoice) BotEmbed.writeChoice(window, username, robotId, mode);
+ if (mode === 'panel') setTab('robot'); // asked for the panel: show it, on a phone too
closeMenu();
closeMenu();
render();
render();
}
}
@@ -3081,12 +3191,27 @@ function _applyBotEmbed(botEmbed, username) {
@@ -3081,12 +3191,27 @@ function _applyBotEmbed(botEmbed, username) {
if (b) _applyBotEmbed.choose(b.dataset.botMode);
if (b) _applyBotEmbed.choose(b.dataset.botMode);
});
});
}
}
- if (line && line.dataset.botBound !== '1') {
- line.dataset.botBound = '1';
- line.addEventListener('click', (e) => {
- if (e.target && e.target.closest && e.target.closest('[data-bot-show-panel]')) _applyBotEmbed.choose('panel');
+ if (tabs && tabs.dataset && tabs.dataset.botBound !== '1') {
+ tabs.dataset.botBound = '1';
+ tabs.addEventListener('click', (e) => {
+ const b = e.target && e.target.closest ? e.target.closest('[data-bot-tab]') : null;
+ if (!b) return;
+ // Robot asks for the full panel when the controls are somewhere else (on the video, a row, hidden).
+ if (b.dataset.botTab === 'robot' && _applyBotEmbed.mode !== 'panel') _applyBotEmbed.choose('panel');
+ else _applyBotEmbed.setTab(b.dataset.botTab);
+ });
+ }
+ // Turning the phone (or a window crossing the phone width) changes the default; a viewer's own choice stays.
+ if (phoneMq && typeof phoneMq.addEventListener === 'function' && !_applyBotEmbed.mqBound) {
+ _applyBotEmbed.mqBound = true;
+ phoneMq.addEventListener('change', () => {
+ const be = _applyBotEmbed.botEmbed;
+ if (!be || typeof BotEmbed === 'undefined' || !_applyBotEmbed.render) return;
+ if (BotEmbed.readChoice(window, _applyBotEmbed.username, be.robot_id)) return;
+ _applyBotEmbed.render();
});
});
}
}
+ _applyBotEmbed.username = username;
// The pointer on the page (outside the clipped controls) wakes the overlay's idle fade.
// The pointer on the page (outside the clipped controls) wakes the overlay's idle fade.
if (player && player.dataset.botActivity !== '1') {
if (player && player.dataset.botActivity !== '1') {
player.dataset.botActivity = '1';
player.dataset.botActivity = '1';
@@ -3113,6 +3238,7 @@ function _applyBotEmbed(botEmbed, username) {
@@ -3113,6 +3238,7 @@ function _applyBotEmbed(botEmbed, username) {
if (window.ov) ov.scope().onDispose(() => {
if (window.ov) ov.scope().onDispose(() => {
stopFrame();
stopFrame();
clear(slot);
clear(slot);
+ pageClasses(null);
if (typeof BotEmbed !== 'undefined') BotEmbed.mount(box, null);
if (typeof BotEmbed !== 'undefined') BotEmbed.mount(box, null);
else clear(box);
else clear(box);
if (_applyBotEmbed.barObserver) { _applyBotEmbed.barObserver.disconnect(); _applyBotEmbed.barObserver = null; }
if (_applyBotEmbed.barObserver) { _applyBotEmbed.barObserver.disconnect(); _applyBotEmbed.barObserver = null; }
@@ -36,7 +36,8 @@
@@ -36,7 +36,8 @@
const SANDBOX = 'allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox allow-forms';
const SANDBOX = 'allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox allow-forms';
const PANEL_MODES = ['panel', 'overlay', 'compact'];
const PANEL_MODES = ['panel', 'overlay', 'compact'];
- const VIEW_MODES = ['overlay', 'panel', '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'];
const CHOICE_KEY = 'ov:live:bot-controls';
const CHOICE_KEY = 'ov:live:bot-controls';
function embedSrc(botEmbed, mode = null) {
function embedSrc(botEmbed, mode = null) {
@@ -99,9 +100,16 @@
@@ -99,9 +100,16 @@
return `${CHOICE_KEY}:${String(channel || '')}:${String(robotId || '')}`;
return `${CHOICE_KEY}:${String(channel || '')}:${String(robotId || '')}`;
}
}
function validView(mode) { return VIEW_MODES.includes(mode) ? mode : null; }
function validView(mode) { return VIEW_MODES.includes(mode) ? mode : null; }
- function defaultMode(botEmbed) { return botEmbed && botEmbed.controls_mode === 'overlay' ? 'overlay' : 'panel'; }
- function effectiveMode(botEmbed, stored) {
- return validView(stored) || defaultMode(botEmbed);
+ // The streamer's default, except that on a phone held upright the full panel starts on the video (ask 353,
+ // 2026-11-11: the panel under the player took the whole screen and the chat could not be reached; Maticus, 11-10:
+ // the stick and the head slider over the stream "would be great for phone"). A streamer's row stays a row.
+ function defaultMode(botEmbed, { phone = false } = {}) {
+ const m = botEmbed && botEmbed.controls_mode;
+ if (m === 'overlay' || m === 'compact') return m;
+ return phone ? 'overlay' : 'panel';
+ }
+ function effectiveMode(botEmbed, stored, opts) {
+ return validView(stored) || defaultMode(botEmbed, opts);
}
}
function readChoice(win, channel, robotId) {
function readChoice(win, channel, robotId) {
try {
try {
@@ -161,6 +169,7 @@
@@ -161,6 +169,7 @@
// computed style. Plain colours only on the Bot side; --color-scheme is 'dark' or 'light'.
// computed style. Plain colours only on the Bot side; --color-scheme is 'dark' or 'light'.
const THEME_TOKENS = ['--bg-primary', '--bg-card', '--text-primary', '--text-secondary', '--accent', '--accent-strong', '--on-accent-strong', '--border'];
const THEME_TOKENS = ['--bg-primary', '--bg-card', '--text-primary', '--text-secondary', '--accent', '--accent-strong', '--on-accent-strong', '--border'];
const MIN_HEIGHT = 160;
const MIN_HEIGHT = 160;
+ const COMPACT_MIN = 96;
const MAX_HEIGHT = 720;
const MAX_HEIGHT = 720;
function pageTheme(win) {
function pageTheme(win) {
@@ -196,10 +205,11 @@
@@ -196,10 +205,11 @@
// The panel's height: it measures its content (a ResizeObserver on its end, at most 5×/s) and the page sets
// The panel's height: it measures its content (a ResizeObserver on its end, at most 5×/s) and the page sets
// the card around the frame to it, between MIN_HEIGHT and MAX_HEIGHT. Once it has spoken the card is no
// the card around the frame to it, between MIN_HEIGHT and MAX_HEIGHT. Once it has spoken the card is no
// longer hand-resizable (#ch-bot-container's resize handle in public/css/features/channel.css).
// longer hand-resizable (#ch-bot-container's resize handle in public/css/features/channel.css).
- const setHeight = (value) => {
+ const setHeight = (value, mode) => {
const height = Number(value);
const height = Number(value);
if (!Number.isFinite(height) || height <= 0) return;
if (!Number.isFinite(height) || height <= 0) return;
- const clamped = Math.max(MIN_HEIGHT, Math.min(MAX_HEIGHT, Math.round(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)));
try {
try {
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;
@@ -301,7 +311,7 @@
@@ -301,7 +311,7 @@
if (d.robot_id == null || d.robot_id === robotId) {
if (d.robot_id == null || d.robot_id === robotId) {
// An overlay fills the player; its clip keeps the presses outside the controls going through.
// An overlay fills the player; its clip keeps the presses outside the controls going through.
if (d.mode === 'overlay') applyClip(frame, d);
if (d.mode === 'overlay') applyClip(frame, d);
- else { applyClip(frame, null); setHeight(d.height); }
+ else { applyClip(frame, null); setHeight(d.height, d.mode); }
if (typeof onSize === 'function') onSize(d);
if (typeof onSize === 'function') onSize(d);
}
}
return;
return;
@@ -6,6 +6,10 @@
@@ -6,6 +6,10 @@
sheet 1180px and narrower, portrait: the bottom sheet; the grip on its top edge sets the height, and
sheet 1180px and narrower, portrait: the bottom sheet; the grip on its top edge sets the height, and
letting go most of the way down closes the sheet
letting go most of the way down closes the sheet
+ A phone held upright while a stream is live (ask 353, 2026-11-11): the chat is docked under the player and starts
+ all the way up to it; its grip sets how tall it is (the channel line shows in the room it leaves), all the way up
+ again is the default, and letting go most of the way down folds it to the round chat button.
+
Double-click or double-tap a grip (or press Enter on it) for the default size; arrow keys move it 24px.
Double-click or double-tap a grip (or press Enter on it) for the default size; arrow keys move it 24px.
Each layout remembers its own size (localStorage), applied as CSS variables on #page-channel, which
Each layout remembers its own size (localStorage), applied as CSS variables on #page-channel, which
public/css/features/channel.css reads: --chat-w, --chat-ow, --chat-sheet-h.
public/css/features/channel.css reads: --chat-w, --chat-ow, --chat-sheet-h.
@@ -14,12 +18,29 @@
@@ -14,12 +18,29 @@
'use strict';
'use strict';
const WIDE = window.matchMedia('(min-width: 1181px)');
const WIDE = window.matchMedia('(min-width: 1181px)');
const LAND = window.matchMedia('(orientation: landscape)');
const LAND = window.matchMedia('(orientation: landscape)');
+ const PHONE = window.matchMedia('(max-width: 768px) and (orientation: portrait)');
+ const page = () => document.getElementById('page-channel');
+ // Docked: a phone upright, live, and not a portrait stream (that one keeps its chat over the picture).
+ const docked = () => {
+ const p = page();
+ return PHONE.matches && !!p && p.classList.contains('ch-is-live') && !p.querySelector('.video-container.is-vertical');
+ };
+ // The docked chat's room: from the player's bottom (or the robot's row under it) to the column's end.
+ function dockRoom() {
+ const p = page();
+ const layout = p && p.querySelector('.stream-layout');
+ const v = p && p.querySelector('#video-container');
+ if (!layout || !v) return window.innerHeight * 0.6;
+ let top = v.getBoundingClientRect().bottom;
+ const row = p.classList.contains('ch-bot-compact') ? p.querySelector('#ch-bot-section') : null;
+ if (row) top += row.getBoundingClientRect().height;
+ return Math.max(180, layout.getBoundingClientRect().bottom - top);
+ }
const MODES = {
const MODES = {
side: { key: 'openvibe_chat_width', prop: '--chat-w', axis: 'x', min: 260, max: () => Math.min(window.innerWidth * 0.5, 760) },
side: { key: 'openvibe_chat_width', prop: '--chat-w', axis: 'x', min: 260, max: () => Math.min(window.innerWidth * 0.5, 760) },
overlay: { key: 'openvibe_chat_overlay_w', prop: '--chat-ow', axis: 'x', min: 260, max: () => window.innerWidth * 0.7 },
overlay: { key: 'openvibe_chat_overlay_w', prop: '--chat-ow', axis: 'x', min: 260, max: () => window.innerWidth * 0.7 },
- sheet: { key: 'openvibe_chat_sheet_h', prop: '--chat-sheet-h', axis: 'y', min: 180, max: () => window.innerHeight * 0.92 },
+ sheet: { key: 'openvibe_chat_sheet_h', prop: '--chat-sheet-h', axis: 'y', min: 180, max: () => (docked() ? dockRoom() : window.innerHeight * 0.92) },
};
};
- const page = () => document.getElementById('page-channel');
const modeOf = () => (WIDE.matches ? 'side' : LAND.matches ? 'overlay' : 'sheet');
const modeOf = () => (WIDE.matches ? 'side' : LAND.matches ? 'overlay' : 'sheet');
const clamp = (v, a, b) => Math.round(Math.max(a, Math.min(b, v)));
const clamp = (v, a, b) => Math.round(Math.max(a, Math.min(b, v)));
const read = (k) => { try { return parseInt(localStorage.getItem(k), 10) || 0; } catch { return 0; } };
const read = (k) => { try { return parseInt(localStorage.getItem(k), 10) || 0; } catch { return 0; } };
@@ -29,6 +50,8 @@
@@ -29,6 +50,8 @@
const p = page(); const m = MODES[mode];
const p = page(); const m = MODES[mode];
if (!p) return;
if (!p) return;
if (v) p.style.setProperty(m.prop, `${clamp(v, m.min, m.max())}px`); else p.style.removeProperty(m.prop);
if (v) p.style.setProperty(m.prop, `${clamp(v, m.min, m.max())}px`); else p.style.removeProperty(m.prop);
+ // A sheet with a height of its own leaves the rest to the channel line; without one it fills (channel.css).
+ if (mode === 'sheet') p.classList.toggle('ch-chat-sized', !!v);
}
}
function applySaved() { for (const mode of Object.keys(MODES)) setSize(mode, read(MODES[mode].key)); }
function applySaved() { for (const mode of Object.keys(MODES)) setSize(mode, read(MODES[mode].key)); }
function settle() { window.dispatchEvent(new Event('resize')); } // the player and the diagrams re-measure
function settle() { window.dispatchEvent(new Event('resize')); } // the player and the diagrams re-measure
@@ -78,7 +101,7 @@
@@ -78,7 +101,7 @@
if (Math.abs(delta) > 3) drag.moved = true;
if (Math.abs(delta) > 3) drag.moved = true;
// The sheet may go below its minimum while dragging: letting go there closes it.
// The sheet may go below its minimum while dragging: letting go there closes it.
drag.cur = Math.round(Math.max(drag.mode === 'sheet' ? 60 : m.min, Math.min(m.max(), drag.from + delta)));
drag.cur = Math.round(Math.max(drag.mode === 'sheet' ? 60 : m.min, Math.min(m.max(), drag.from + delta)));
- const p = page(); if (p) p.style.setProperty(m.prop, `${drag.cur}px`);
+ const p = page(); if (p) { p.style.setProperty(m.prop, `${drag.cur}px`); if (drag.mode === 'sheet') p.classList.add('ch-chat-sized'); }
sync(g, sidebar);
sync(g, sidebar);
});
});
const end = (e) => {
const end = (e) => {
@@ -93,6 +116,16 @@
@@ -93,6 +116,16 @@
return;
return;
}
}
const m = MODES[d.mode];
const m = MODES[d.mode];
+ if (d.mode === 'sheet' && docked()) {
+ // Docked: most of the way down folds the chat to its round button; all the way up is "fill" again.
+ if (d.cur < m.min) {
+ setSize('sheet', read(m.key));
+ if (typeof window.toggleChannelChat === 'function') window.toggleChannelChat(true);
+ sync(g, sidebar);
+ return;
+ }
+ if (d.cur >= m.max() - 16) { reset('sheet'); sync(g, sidebar); return; }
+ }
if (d.mode === 'sheet' && d.cur < m.min) {
if (d.mode === 'sheet' && d.cur < m.min) {
setSize('sheet', read(m.key)); // keep the last real height for next time
setSize('sheet', read(m.key)); // keep the last real height for next time
const s = document.querySelector('#page-channel .chat-sidebar.mobile-chat-open');
const s = document.querySelector('#page-channel .chat-sidebar.mobile-chat-open');
@@ -115,6 +148,7 @@
@@ -115,6 +148,7 @@
e.preventDefault();
e.preventDefault();
const r = sidebar.getBoundingClientRect();
const r = sidebar.getBoundingClientRect();
const v = clamp((m.axis === 'x' ? r.width : r.height) + grow[e.key] * 24, m.min, m.max());
const v = clamp((m.axis === 'x' ? r.width : r.height) + grow[e.key] * 24, m.min, m.max());
+ if (mode === 'sheet' && docked() && v >= m.max()) { reset('sheet'); sync(g, sidebar); return; }
setSize(mode, v); write(m.key, v); settle();
setSize(mode, v); write(m.key, v); settle();
sync(g, sidebar);
sync(g, sidebar);
});
});
@@ -776,7 +776,7 @@ async function loadBotEmbedSetting() {
@@ -776,7 +776,7 @@ async function loadBotEmbedSetting() {
if (!be || !be.enabled) { card.style.display = 'none'; return; }
if (!be || !be.enabled) { card.style.display = 'none'; return; }
document.getElementById('dash-bot-robot-id').value = be.robot_id || '';
document.getElementById('dash-bot-robot-id').value = be.robot_id || '';
const mode = document.getElementById('dash-bot-controls-mode');
const mode = document.getElementById('dash-bot-controls-mode');
- if (mode) mode.value = be.controls_mode === 'overlay' ? 'overlay' : 'panel';
+ if (mode) mode.value = ['overlay', 'compact'].includes(be.controls_mode) ? be.controls_mode : 'panel';
card.style.display = '';
card.style.display = '';
} catch { card.style.display = 'none'; }
} catch { card.style.display = 'none'; }
}
}
@@ -788,7 +788,7 @@ async function saveBotEmbedSetting() {
@@ -788,7 +788,7 @@ async function saveBotEmbedSetting() {
const id = input.value.trim();
const id = input.value.trim();
try {
try {
const body = { robot_id: id || null };
const body = { robot_id: id || null };
- if (mode && (mode.value === 'overlay' || mode.value === 'panel')) body.controls_mode = mode.value;
+ if (mode && ['overlay', 'compact', 'panel'].includes(mode.value)) body.controls_mode = mode.value;
const data = await api(`/streams/channel/${encodeURIComponent(currentUser.username)}/bot`, { method: 'PUT', body });
const data = await api(`/streams/channel/${encodeURIComponent(currentUser.username)}/bot`, { method: 'PUT', body });
const bound = data && data.bot_embed && data.bot_embed.robot_id;
const bound = data && data.bot_embed && data.bot_embed.robot_id;
if (status) status.textContent = bound ? `Bound to ${bound}` : 'No robot bound';
if (status) status.textContent = bound ? `Bound to ${bound}` : 'No robot bound';
@@ -49,8 +49,8 @@ function embedUrl(robotId) {
@@ -49,8 +49,8 @@ function embedUrl(robotId) {
return `${botOrigin()}/panel/${encodeURIComponent(robotId)}/embed`;
return `${botOrigin()}/panel/${encodeURIComponent(robotId)}/embed`;
}
}
-/** The two places a streamer may put the controls by default; a viewer may still hide them or move them. */
-const CONTROL_MODES = ['overlay', 'panel'];
+/** The places a streamer may put the controls by default (a row since ask 353); a viewer may still hide or move them. */
+const CONTROL_MODES = ['overlay', 'compact', 'panel'];
const controlMode = (value) => (CONTROL_MODES.includes(value) ? value : 'panel');
const controlMode = (value) => (CONTROL_MODES.includes(value) ? value : 'panel');
/** The channel page's `bot_embed` value: undefined with the flag off, else { enabled, robot_id, url, controls_mode }. */
/** The channel page's `bot_embed` value: undefined with the flag off, else { enabled, robot_id, url, controls_mode }. */
@@ -43,7 +43,7 @@ router.put('/channel/:username/bot', (req, res, next) => (embed.enabled() ? next
@@ -43,7 +43,7 @@ router.put('/channel/:username/bot', (req, res, next) => (embed.enabled() ? next
let mode = embed.controlMode(channel.bot_controls_mode);
let mode = embed.controlMode(channel.bot_controls_mode);
if (hasMode) {
if (hasMode) {
if (!embed.CONTROL_MODES.includes(body.controls_mode)) {
if (!embed.CONTROL_MODES.includes(body.controls_mode)) {
- return res.status(400).json({ error: "controls_mode must be 'overlay' or 'panel'" });
+ return res.status(400).json({ error: "controls_mode must be 'overlay', 'compact' or 'panel'" });
}
}
mode = body.controls_mode;
mode = body.controls_mode;
}
}
@@ -974,8 +974,12 @@ function render(meta, urlPath) {
@@ -974,8 +974,12 @@ function render(meta, urlPath) {
const contentEnd = html.indexOf('</div>', contentStart);
const contentEnd = html.indexOf('</div>', contentStart);
if (contentEnd !== -1) html = html.slice(0, contentStart) + cards + html.slice(contentEnd);
if (contentEnd !== -1) html = html.slice(0, contentStart) + cards + html.slice(contentEnd);
}
}
- for (const [section, id] of [['videos', 'ch-vods-all'], ['clips', 'ch-clips-of-all'], ['clips-taken', 'ch-clips-taken-all'], ['pastes', 'ch-pastes-all']]) {
- html = html.replace(`id="${id}" href="#">View all`, `id="${id}" href="/@${esc(meta.channelUsername)}?section=${section}">View all (${Number(meta.railCounts[section]) || 0})`);
+ // Each section's View all is an icon button with its words for readers and crawlers; the count sits beside the
+ // heading (ask 355, 2026-11-11).
+ for (const [section, id, countId] of [['videos', 'ch-vods-all', 'ch-vods-n'], ['clips', 'ch-clips-of-all', 'ch-clips-of-n'], ['clips-taken', 'ch-clips-taken-all', 'ch-clips-taken-n'], ['pastes', 'ch-pastes-all', 'ch-pastes-n']]) {
+ const count = Number(meta.railCounts[section]) || 0;
+ html = html.replace(`id="${id}" href="#"`, `id="${id}" href="/@${esc(meta.channelUsername)}?section=${section}"`);
+ if (count > 0) html = html.replace(`<span class="ch-sec-n" id="${countId}" hidden></span>`, `<span class="ch-sec-n" id="${countId}">${count > 999 ? `${Math.floor(count / 100) / 10}k` : count}</span>`);
}
}
}
}
@@ -138,6 +138,11 @@ console.warn = (...a) => { warnings.push(a.join(' ')); };
@@ -138,6 +138,11 @@ console.warn = (...a) => { warnings.push(a.join(' ')); };
assert.strictEqual(mode.status, 200, mode.text);
assert.strictEqual(mode.status, 200, mode.text);
assert.deepStrictEqual(mode.body.bot_embed, { enabled: true, robot_id: 'rob_sim123', url: 'https://openvibe.bot/panel/rob_sim123/embed', controls_mode: 'overlay' });
assert.deepStrictEqual(mode.body.bot_embed, { enabled: true, robot_id: 'rob_sim123', url: 'https://openvibe.bot/panel/rob_sim123/embed', controls_mode: 'overlay' });
assert.strictEqual((await channelGet()).body.bot_embed.controls_mode, 'overlay', 'the channel serializer carries it to the page');
assert.strictEqual((await channelGet()).body.bot_embed.controls_mode, 'overlay', 'the channel serializer carries it to the page');
+ // A row under the video (ask 353): the database takes it (migration 0039) and the page gets it.
+ const row = await call('PUT', '/api/streams/channel/rover/bot', { user: 1, body: { controls_mode: 'compact' } });
+ assert.strictEqual(row.status, 200, row.text);
+ assert.strictEqual((await channelGet()).body.bot_embed.controls_mode, 'compact');
+ assert.strictEqual((await db.getChannelByUserId(1)).bot_controls_mode, 'compact');
// Both together, then an invalid value changes nothing.
// Both together, then an invalid value changes nothing.
const both = await call('PUT', '/api/streams/channel/rover/bot', { user: 1, body: { robot_id: 'rob_sim123', controls_mode: 'panel' } });
const both = await call('PUT', '/api/streams/channel/rover/bot', { user: 1, body: { robot_id: 'rob_sim123', controls_mode: 'panel' } });
assert.strictEqual(both.status, 200);
assert.strictEqual(both.status, 200);
@@ -125,6 +125,14 @@ async function check(name, fn) {
@@ -125,6 +125,14 @@ async function check(name, fn) {
assert.strictEqual(BotEmbed.effectiveMode(overlayDefault, 'hidden'), 'hidden', 'or hide them');
assert.strictEqual(BotEmbed.effectiveMode(overlayDefault, 'hidden'), 'hidden', 'or hide them');
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.
+ assert.deepStrictEqual(BotEmbed.VIEW_MODES, ['overlay', 'compact', 'panel', 'hidden']);
+ 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');
+ assert.strictEqual(BotEmbed.effectiveMode({ ...SIM, controls_mode: 'compact' }, null), 'compact', 'a streamer may start everyone with a row');
+ assert.strictEqual(BotEmbed.effectiveMode(SIM, 'compact'), 'compact', 'and a viewer may choose it');
+ assert.strictEqual(BotEmbed.effectiveMode(SIM, 'panel', { phone: true }), 'panel', 'a viewer\'s own choice beats the phone default');
assert.strictEqual(BotEmbed.iframeAttrs(SIM, { mode: 'overlay' }).src, 'https://openvibe.bot/panel/rob_sim123/embed?mode=overlay');
assert.strictEqual(BotEmbed.iframeAttrs(SIM, { mode: 'overlay' }).src, 'https://openvibe.bot/panel/rob_sim123/embed?mode=overlay');
assert.strictEqual(BotEmbed.iframeAttrs(SIM, { mode: 'compact' }).src, 'https://openvibe.bot/panel/rob_sim123/embed?mode=compact');
assert.strictEqual(BotEmbed.iframeAttrs(SIM, { mode: 'compact' }).src, 'https://openvibe.bot/panel/rob_sim123/embed?mode=compact');
assert.strictEqual(BotEmbed.iframeAttrs(SIM, { mode: 'bogus' }), null, 'only the three views');
assert.strictEqual(BotEmbed.iframeAttrs(SIM, { mode: 'bogus' }), null, 'only the three views');
@@ -39,10 +39,22 @@ for (const [name, text] of [['channel.css', css]]) {
@@ -39,10 +39,22 @@ for (const [name, text] of [['channel.css', css]]) {
assert.match(String(mediaAt(text, i)), /max-width:\s*1180px\)\s*and\s*\(orientation:\s*landscape/, `${name}: --chat-ow outside the landscape overlay`);
assert.match(String(mediaAt(text, i)), /max-width:\s*1180px\)\s*and\s*\(orientation:\s*landscape/, `${name}: --chat-ow outside the landscape overlay`);
}
}
for (const i of uses(text, '--chat-sheet-h')) {
for (const i of uses(text, '--chat-sheet-h')) {
- assert.match(String(mediaAt(text, i)), /max-width:\s*1180px\)\s*and\s*\(orientation:\s*portrait/, `${name}: --chat-sheet-h outside the portrait sheet`);
+ // The phone's docked chat (768px and narrower, upright; ask 353) is the same sheet on a smaller screen.
+ assert.match(String(mediaAt(text, i)), /max-width:\s*(1180|768)px\)\s*and\s*\(orientation:\s*portrait/, `${name}: --chat-sheet-h outside the portrait sheet`);
}
}
}
}
assert.ok(uses(css, '--chat-w').length === 1, 'the side width is set in one place');
assert.ok(uses(css, '--chat-w').length === 1, 'the side width is set in one place');
// The sheet itself is the full width of the screen.
// The sheet itself is the full width of the screen.
assert.match(css, /@media \(max-width: 1180px\) and \(orientation: portrait\) \{ #page-channel \.stream-layout > \.chat-sidebar \{ width: 100%;/);
assert.match(css, /@media \(max-width: 1180px\) and \(orientation: portrait\) \{ #page-channel \.stream-layout > \.chat-sidebar \{ width: 100%;/);
+// The phone's docked chat (ask 353, 2026-11-11): it fills up to the player until it has a height of its own
+// (ch-chat-sized, set by public/js/channel-chat-resize.js with --chat-sheet-h), its grip is shown, and letting go low
+// folds it to the round chat button rather than the tablet sheet's close.
+const resize = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'channel-chat-resize.js'), 'utf8');
+assert.match(resize, /if \(mode === 'sheet'\) p\.classList\.toggle\('ch-chat-sized', !!v\);/, 'a saved height marks the page');
+assert.match(resize, /max: \(\) => \(docked\(\) \? dockRoom\(\) : window\.innerHeight \* 0\.92\)/, 'docked, the room under the player is the most');
+assert.match(resize, /if \(d\.cur < m\.min\) \{\s*setSize\('sheet', read\(m\.key\)\);\s*if \(typeof window\.toggleChannelChat === 'function'\) window\.toggleChannelChat\(true\);/, 'low folds the chat');
+assert.match(resize, /if \(d\.cur >= m\.max\(\) - 16\) \{ reset\('sheet'\);/, 'all the way up is the default again');
+assert.match(css, /#page-channel\.ch-is-live:not\(\.ch-chat-sized\):not\(\.chat-collapsed\) [^{]*:is\(\.ch-info-bar, \.ch-weather-widget\) \{ display: none !important; \}/, 'full: the chat reaches the player');
+assert.match(css, /#page-channel\.ch-is-live\.ch-chat-sized\.page [^{]*> \.chat-sidebar \{ flex: 0 1 var\(--chat-sheet-h, 60%\); min-height: 120px; \}/, 'sized: the chat keeps its height');
+assert.match(css, /> \.chat-sidebar > \.chat-grip \{ display: block !important;/, 'the grip shows on the docked chat');
console.log('chat-sheet-width: each saved chat size applies only in its own layout');
console.log('chat-sheet-width: each saved chat size applies only in its own layout');