openvibers/openvibe.live Commits
A channel on a phone: one section at a time, the chat sheet fills the screen, folding it folds the robot too (#807)
Changes
@@@ -2177,3 +2177,70 @@ html[data-ov-motion="off"] #page-channel .ch-rec-dot { animation: none; }.ch-bot-win { height: 100%; min-height: 200px; }.ch-bot-win iframe { display: block; width: 100%; height: 100%; border: 0; background: transparent; }@media (max-width: 768px) { #page-channel [data-bot-mode="window"] { display: none !important; } }++++/* ── One section at a time on a phone or a tablet (ask 367, 2026-11-11: "consolidate these into tabs, self-contained++ tabs … at least on mobile"). The page's HTML keeps every section; at 900 px or narrower app-channel.js marks the++ page .ch-tabbed and the chosen one .is-current. The tab names the section, so its own heading steps aside and its++ tools lead its first row; folds open by themselves. ─── */++#page-channel.ch-tabbed .ch-tab-panel:not(.is-current) { display: none; }++#page-channel.ch-tabbed .ch-tab-panel { margin: 0 0 18px; min-height: 42vh; }++#page-channel.ch-tabbed .ch-tab-panel > .ch-sec-h, #page-channel.ch-tabbed .ch-tab-panel .ch-fold > summary { display: none; }++#page-channel.ch-tabbed :is(#ch-panel-videos, #ch-panel-clips, #ch-panel-clips-taken, #ch-panel-pastes) > .ch-panel-bar { grid-column: 1 / -1; grid-row: 1; justify-content: flex-start; }++#page-channel.ch-tabbed :is(#ch-panel-videos, #ch-panel-clips, #ch-panel-clips-taken, #ch-panel-pastes) > .ch-panel-bar:not(:has(> :not([style*="display:none"]):not([style*="display: none"]):not(:empty))) { display: none; }++#page-channel.ch-tabbed .ch-tab-panel.is-entering { animation: chPanelIn var(--ov-dur-2, .22s) var(--ov-ease-out, cubic-bezier(.22, 1, .36, 1)) both; }++@keyframes chPanelIn { from { opacity: 0; transform: translateY(6px); } }++html[data-ov-motion="off"] #page-channel.ch-tabbed .ch-tab-panel.is-entering { animation: none; }++/* The tabs: a full-width strip under the navbar, the chosen one lit and underlined. */++#page-channel.ch-tabbed .ch-tabs-wrap { margin-bottom: 12px; border-bottom: 1px solid var(--border); }++#page-channel.ch-tabbed #ch-tabs .ch-tab { min-height: 44px; }++#page-channel.ch-tabbed #ch-tabs .ch-tab.active { color: var(--text-primary); }++++/* ── Media requests (ask 367: "polish"): one field with its button, the price and the limit as chips, the keepers'++ line with icon tools, a counted "Up next", and an empty queue that says what to do. ─── */++.ch-media-req { align-items: stretch; gap: 0; border: 1px solid var(--border); border-radius: 12px; background: var(--bg-card); overflow: hidden; transition: border-color var(--ov-dur-2, .15s), box-shadow var(--ov-dur-2, .15s); }++.ch-media-req:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent); }++.ch-media-field { flex: 1; min-width: 0; display: flex; align-items: center; gap: 8px; padding: 0 12px; color: var(--text-muted); cursor: text; }++.ch-media-field input { flex: 1; min-width: 0; height: 46px; border: 0; outline: 0; background: transparent; color: var(--text-primary); font: inherit; font-size: 16px; }++.ch-media-req .ch-media-go { flex: none; display: inline-flex; align-items: center; gap: 7px; margin: 4px; border-radius: 9px; white-space: nowrap; }++.ch-media-hint { display: flex; flex-wrap: wrap; gap: 6px; margin-top: -4px; }++.ch-media-chip { display: inline-flex; align-items: center; gap: 6px; height: 28px; padding: 0 10px; border-radius: 999px; font-size: .76rem; font-weight: 600; color: var(--text-secondary); background: color-mix(in srgb, var(--text-primary, #fff) 6%, transparent); }++.ch-media-chip i { color: var(--accent-light, var(--accent)); }++.ch-media-chip.is-lock i { color: var(--warning, #f59e0b); }++.ch-media-mod-bar { justify-content: space-between; padding: 6px 6px 6px 12px; }++.ch-media-mod-say { display: inline-flex; align-items: center; gap: 7px; color: var(--text-secondary); font-weight: 600; }++.ch-media-mod-say i { color: var(--accent-light, var(--accent)); }++.ch-media-mod-acts { display: inline-flex; gap: 6px; }++.ch-media-section-label .ch-sec-n { display: inline-flex; align-items: center; height: 22px; padding: 0 8px; border-radius: 999px; font-size: .74rem; font-weight: 700; color: var(--text-muted); background: color-mix(in srgb, var(--text-primary, #fff) 7%, transparent); }++.ch-media-empty { display: flex; align-items: center; gap: 12px; padding: 16px; border: 1px dashed var(--border); border-radius: 12px; color: var(--text-secondary); font-size: .88rem; }++.ch-media-empty i { display: grid; place-items: center; flex: none; width: 38px; height: 38px; border-radius: 11px; color: var(--accent-light, var(--accent)); background: color-mix(in srgb, var(--accent) 14%, transparent); }++@media (max-width: 420px) { .ch-media-req .ch-media-go span { display: none; } .ch-media-req .ch-media-go { width: 44px; justify-content: center; padding: 0; } }++++/* ── A phone watching live, the sheet (ask 366, 2026-11-11: "a gap in between the top of the chat and the top of the++ player … the top bar a little more compact … the tabs under the stream … the full space of the chat … if you collapse++ the chat … collapse the controls … the same button"). Open, the sheet is everything under the player: the sections++ wait until it is folded, so nothing peeks under it and the page does not scroll away from it. The grip lies over the++ header's top edge instead of taking a row. Folded, the robot's controls fold with it (the player's robot button still++ drives over the video); the round button brings both back. A lowered sheet stops at the channel line's own height++ (channel-chat-resize.js), so the line is never cut in half. ─── */++@media (max-width: 768px) and (orientation: portrait) {++ #page-channel.ch-is-live:not(.chat-collapsed) .channel-below-fold { display: none; }++ body:has(#page-channel.active.ch-is-live:not(.chat-collapsed)) #ov-footer { display: none; }++ #page-channel.ch-is-live.chat-collapsed #ch-bot-section { display: none !important; }++ #page-channel.ch-is-live .stream-layout:not(:has(.video-container.is-vertical)) > .chat-sidebar > .chat-grip { position: absolute; top: 0; left: 0; right: 0; height: 14px; z-index: 4; }++ #page-channel.ch-is-live .stream-layout:not(:has(.video-container.is-vertical)) > .chat-sidebar .chat-header { padding-top: 8px; min-height: 46px; }++ #page-channel.ch-is-live .chat-sidebar .ch-goal-widget { padding: 3px 6px 0; }++ #page-channel.ch-is-live .chat-sidebar .cgw-goal { height: 30px; }++ #page-channel.ch-is-live .chat-sidebar .cgw-track .cgw-goal { margin-bottom: 3px; }++ /* The channel line above a lowered sheet: its own height, never a sliver. */++ #page-channel.ch-is-live.ch-chat-sized:not(.chat-collapsed) .stream-layout:not(:has(.video-container.is-vertical)) > .stream-main > .ch-info-bar { overflow: hidden; }++}++++/* ── Compact view (chat settings → Compact view, kept with the person's chat settings; ask 366): the sheet's header,++ the goal bar and the composer's padding take less, so the messages get the room. ─── */++@media (max-width: 768px) and (orientation: portrait) {++ html.chat-compact #page-channel.ch-is-live .stream-layout:not(:has(.video-container.is-vertical)) > .chat-sidebar .chat-header { padding-top: 6px; padding-bottom: 2px; min-height: 40px; }++ html.chat-compact #page-channel.ch-is-live .chat-sidebar .ch-bot-tabs button { min-height: 32px; }++ html.chat-compact #page-channel.ch-is-live .chat-sidebar .cgw-goal { height: 22px; }++ html.chat-compact #page-channel.ch-is-live .chat-sidebar .cgw-goal * { font-size: .66rem; }++ html.chat-compact #page-channel.ch-is-live .chat-sidebar .chat-input-area { padding-top: 4px; }++}@@@ -400,8 +400,9 @@</div><!-- /.stream-layout --></div>-- <!-- Channel content (below stream): every section on the page, never behind a tab (owner 10-31: content is never-- hidden to shorten a page). The row is a jump bar with counts; AI timeline and Analytics are counted folds. -->++ <!-- Channel content (below stream): every section is in the page's HTML. A wide screen shows them one after++ another with this row as a jump bar; at 900 px or narrower the row is tabs and one section shows at a time++ (ask 367, 2026-11-11: "self-contained tabs, so the page isn't so freaking huge"; app-channel.js _chShowPanel). --><div class="channel-below-fold"><div class="container"><nav class="ch-tabs ch-jump" id="ch-tabs" aria-label="Jump to a section">@@@ -712,7 +712,11 @@ async function loadChannelPastes(username = currentChannelUsername, { append = fconst sortHost = document.getElementById('ch-pastes-sort');if (!grid) return;const sort = channelPastesSortByUser[username] || 'newest';-- if (sortHost) sortHost.innerHTML = (typeof sortToggleHTML === 'function') ? sortToggleHTML(sort, 'setChannelPastesSort') : '';++ if (sortHost) {++ const name = sort === 'oldest' ? 'Oldest first' : 'Newest first';++ sortHost.innerHTML = `<label class="ch-ib ch-ib-pick" data-order="${sort}" title="${name}"><i class="fa-solid ${sort === 'oldest' ? 'fa-arrow-down-short-wide' : 'fa-arrow-down-wide-short'}" aria-hidden="true"></i><span class="ch-ib-t">${name}</span><i class="fa-solid fa-chevron-down ch-ib-caret" aria-hidden="true"></i>`++ + `<select class="ch-vods-filter-select" data-ov-native aria-label="Order" onchange="setChannelPastesSort(this.value)"><option value="newest"${sort === 'oldest' ? '' : ' selected'}>Newest first</option><option value="oldest"${sort === 'oldest' ? ' selected' : ''}>Oldest first</option></select></label>`;++ }try {const oldCards = append ? [...grid.children].filter((el) => !el.classList.contains('ch-rail-skeleton')) : [];const offset = append ? oldCards.length : 0;@@@ -2271,6 +2275,7 @@ function switchChannelTab(tab, btn, jump) {_channelTab = tab;_markChannelJump(tab, !!jump);_ensureChannelSection(tab);++ if (_chIsTabbed()) { _chShowPanel(tab, !!jump); return; }if (!jump) return;const panel = document.getElementById('ch-panel-' + tab);if (!panel || panel.hidden) return;@@@ -2281,6 +2286,51 @@ function switchChannelTab(tab, btn, jump) {panel.scrollIntoView({ behavior: reduce ? 'auto' : 'smooth', block: 'start' });setTimeout(_queueChannelSectionSpy, reduce ? 200 : 1450);}++/*++ * One section at a time on a phone or a tablet (ask 367, 2026-11-11, the owner: "consolidate these into tabs,++ * self-contained tabs, so the page isn't so freaking huge … at least on mobile"). The page's HTML still carries every++ * section (crawlers, readers without scripts, the server's own render); a person at 900 px or narrower sees the one++ * whose tab they chose, the others wait (and load the first time they are opened). A wide screen keeps the sections++ * one after another with the jump bar.++ */++const _chTabbedMQ = typeof window.matchMedia === 'function' ? window.matchMedia('(max-width: 900px)') : null;++function _chIsTabbed() { return !!(_chTabbedMQ && _chTabbedMQ.matches); }++function _chFirstTab() {++ const b = Array.from(document.querySelectorAll('#ch-tabs .ch-tab[data-tab]')).find((x) => x.style.display !== 'none');++ return b ? b.dataset.tab : 'videos';++}++function _chShowPanel(tab, fromTap) {++ let panel = document.getElementById('ch-panel-' + tab);++ if (!panel || panel.hidden) { tab = _chFirstTab(); panel = document.getElementById('ch-panel-' + tab); }++ if (!panel) return;++ const was = document.querySelector('#page-channel .ch-tab-panel.is-current');++ document.querySelectorAll('#page-channel .ch-tab-panel').forEach((p) => p.classList.toggle('is-current', p === panel));++ if (was !== panel) {++ panel.classList.remove('is-entering'); void panel.offsetWidth; panel.classList.add('is-entering');++ setTimeout(() => panel.classList.remove('is-entering'), 260);++ }++ _channelTab = tab;++ _markChannelJump(tab, !!fromTap);++ const fold = panel.querySelector('details.ch-fold');++ if (fold && !fold.open) fold.open = true;++ _ensureChannelSection(tab);++ // A tab chosen while the page is scrolled into a long section starts the new one at its top, under the bar.++ if (fromTap) {++ const bar = document.getElementById('ch-tabs');++ const under = bar ? bar.getBoundingClientRect().bottom : 0;++ const top = panel.getBoundingClientRect().top;++ if (top < under - 2) window.scrollTo({ top: window.scrollY + top - under - 8, behavior: 'auto' });++ }++}++function _chApplyTabbed() {++ const page = document.getElementById('page-channel');++ if (!page) return;++ const on = _chIsTabbed();++ page.classList.toggle('ch-tabbed', on);++ if (on) _chShowPanel(_channelTab || _chFirstTab(), false);++ else document.querySelectorAll('#page-channel .ch-tab-panel.is-current').forEach((p) => p.classList.remove('is-current'));++}++if (_chTabbedMQ && typeof _chTabbedMQ.addEventListener === 'function') _chTabbedMQ.addEventListener('change', () => { _chApplyTabbed(); _queueChannelSectionSpy(); });function _channelReduceMotion() {return document.documentElement.dataset.ovMotion === 'off';}@@@ -2378,12 +2428,18 @@ function _syncChannelSections() {if (panel) panel.hidden = b.style.display === 'none';});_updateChannelTabArrows();++ // Tabbed: the section shown went away (no About after all): the first one there is.++ if (_chIsTabbed()) {++ const cur = document.querySelector('#page-channel .ch-tab-panel.is-current');++ if (!cur || cur.hidden) _chShowPanel(_chFirstTab(), false);++ }}let _chSecObs = null, _chSpyObs = null, _chTabsObs = null;let _chSpyFrame = 0;let _chHashMark = ''; // a section a live link named: its jump link stays marked while the page is still at the topfunction _channelSectionInView() {_chSpyFrame = 0;++ if (_chIsTabbed()) return; // one section at a time: the tab says which, not the scrollif (Date.now() < _chJumpUntil) return;if (_chHashMark) {if (window.scrollY <= 120) return;@@@ -2408,6 +2464,7 @@ function _queueChannelSectionSpy() {if (!_chSpyFrame) _chSpyFrame = requestAnimationFrame(_channelSectionInView);}function _watchChannelSections() {++ _chApplyTabbed();_syncChannelSections();const tabs = document.getElementById('ch-tabs');_initChannelTabArrows();@@@ -3690,16 +3747,16 @@ async function loadChannelMedia(username = currentChannelUsername) {host.innerHTML = `<div class="ch-media"><form class="ch-media-req" onsubmit="return submitMediaRequest(event)">-- <input id="ch-media-input" type="text" placeholder="Paste a ${esc(sources || 'media')} URL to request…" ${loggedIn ? '' : 'disabled'} oninput="_clearMediaQuote()">-- <button class="btn btn-primary" ${loggedIn ? '' : 'disabled'}><i class="fa-solid fa-magnifying-glass"></i> Check price</button>++ <label class="ch-media-field"><i class="fa-solid fa-link" aria-hidden="true"></i><input id="ch-media-input" type="url" inputmode="url" autocomplete="off" aria-label="A link to request" placeholder="Paste a ${esc(sources || 'media')} link" ${loggedIn ? '' : 'disabled'} oninput="_clearMediaQuote()"></label>++ <button class="btn btn-primary ch-media-go" ${loggedIn ? '' : 'disabled'}><i class="fa-solid fa-magnifying-glass" aria-hidden="true"></i><span>Check price</span></button></form>-- <div class="ch-media-hint muted">${loggedIn ? '' : '<i class="fa-solid fa-lock"></i> Log in to request. '}<i class="${_mediaCurrencyIcon(p && p.currency)}"></i> ${esc(_mediaPriceLabel(p))}${maxMin ? ` Max ${maxMin} min.` : ''}</div>++ <div class="ch-media-hint">${loggedIn ? '' : '<span class="ch-media-chip is-lock"><i class="fa-solid fa-lock" aria-hidden="true"></i> Sign in to request</span>'}<span class="ch-media-chip"><i class="${_mediaCurrencyIcon(p && p.currency)}" aria-hidden="true"></i> ${esc(_mediaPriceLabel(p).replace(/\.$/, ''))}</span>${maxMin ? `<span class="ch-media-chip"><i class="fa-solid fa-clock" aria-hidden="true"></i> Up to ${maxMin} min</span>` : ''}</div><div id="ch-media-quote"></div><div id="ch-media-status" class="ch-media-status"></div>-- ${_mediaCanManage ? `<div class="ch-media-mod-bar"><span class="muted"><i class="fa-solid fa-shield-halved"></i> You can manage this queue</span><button class="btn btn-sm btn-outline" onclick="_mediaAdvance('played')"><i class="fa-solid fa-forward-step"></i> Next</button><button class="btn btn-sm btn-outline" onclick="_mediaAdvance('skipped')"><i class="fa-solid fa-ban"></i> Skip & refund</button></div>` : ''}++ ${_mediaCanManage ? `<div class="ch-media-mod-bar"><span class="ch-media-mod-say"><i class="fa-solid fa-shield-halved" aria-hidden="true"></i> You run this queue</span><span class="ch-media-mod-acts"><button class="ch-ib" type="button" title="Play the next request" aria-label="Play the next request" onclick="_mediaAdvance('played')"><i class="fa-solid fa-forward-step" aria-hidden="true"></i><span class="ch-ib-t">Next</span></button><button class="ch-ib" type="button" title="Skip this one and refund it" aria-label="Skip this one and refund it" onclick="_mediaAdvance('skipped')"><i class="fa-solid fa-ban" aria-hidden="true"></i><span class="ch-ib-t">Skip & refund</span></button></span></div>` : ''}${np ? `<div class="ch-media-now"><div class="ch-media-section-label"><i class="fa-solid fa-play"></i> Now Playing</div>${_mediaItemHTML(np)}</div>` : ''}-- <div class="ch-media-section-label"><i class="fa-solid fa-list-ol"></i> Up Next (${queue.length})</div>-- <div class="ch-media-queue">${queue.length ? queue.map((q, i) => _mediaItemHTML(q, i + 1)).join('') : '<div class="muted" style="padding:12px">Queue is empty — be the first to request something!</div>'}</div>++ <div class="ch-media-section-label"><i class="fa-solid fa-list-ol" aria-hidden="true"></i> Up next <span class="ch-sec-n">${queue.length}</span></div>++ <div class="ch-media-queue">${queue.length ? queue.map((q, i) => _mediaItemHTML(q, i + 1)).join('') : '<div class="ch-media-empty"><i class="fa-solid fa-music" aria-hidden="true"></i><span>Nothing queued yet. Paste a link above to be first.</span></div>'}</div></div>`;} catch { host.innerHTML = '<div class="ch-about-empty">Failed to load the media queue.</div>'; }}@@@ -7,8 +7,10 @@letting go most of the way down closes the sheetA 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.++ all the way up to it; letting go most of the way down folds it to the round chat button. Between those it has one++ other height (ask 366: the channel line showed cut in half above a sheet of any height): the channel line's own,++ so the sheet is all the way up, just under the line, or folded. That height is saved as LINE (-1) and measured++ on the page each time, so it fits whatever the line holds on this phone.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@@@ -36,6 +38,14 @@if (row) top += row.getBoundingClientRect().height;return Math.max(180, layout.getBoundingClientRect().bottom - top);}++ const LINE = -1; // the docked sheet just under the channel line++ /** The docked sheet's height with the channel line shown whole above it. */++ function lineSize() {++ const p = page();++ const info = p && p.querySelector('.stream-main > .ch-info-bar');++ const h = info && info.offsetParent !== null ? info.scrollHeight : 0;++ return Math.max(180, Math.round(dockRoom() - (h || 150)));++ }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 },@@@ -49,6 +59,15 @@function setSize(mode, v) {const p = page(); const m = MODES[mode];if (!p) return;++ // Docked, a height saved before (any number of pixels) is the line height now.++ if (mode === 'sheet' && v > 0 && docked()) v = LINE;++ if (mode === 'sheet' && v === LINE) {++ // Shown first, so the line can be measured; a sheet that is not docked has no line height: the default.++ if (!docked()) { p.style.removeProperty(m.prop); p.classList.remove('ch-chat-sized'); return; }++ p.classList.add('ch-chat-sized');++ p.style.setProperty(m.prop, `${lineSize()}px`);++ 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);@@@ -124,7 +143,11 @@sync(g, sidebar);return;}-- if (d.cur >= m.max() - 16) { reset('sheet'); sync(g, sidebar); return; }++ // Otherwise the nearer of its two heights: all the way up, or just under the channel line.++ const line = lineSize();++ if (Math.abs(d.cur - m.max()) <= Math.abs(d.cur - line)) { reset('sheet'); sync(g, sidebar); return; }++ write(m.key, LINE); setSize('sheet', LINE); settle(); sync(g, sidebar);++ return;}if (d.mode === 'sheet' && d.cur < m.min) {setSize('sheet', read(m.key)); // keep the last real height for next time@@@ -147,8 +170,15 @@if (!(e.key in grow)) return;e.preventDefault();const r = sidebar.getBoundingClientRect();++ if (mode === 'sheet' && docked()) {++ // Up: all the way up. Down: under the channel line, then folded.++ if (grow[e.key] > 0) reset('sheet');++ else if (read(m.key) === LINE) { if (typeof window.toggleChannelChat === 'function') window.toggleChannelChat(true); }++ else { write(m.key, LINE); setSize('sheet', LINE); settle(); }++ sync(g, sidebar);++ return;++ }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);});@@@ -158,6 +188,14 @@applySaved();document.querySelectorAll('#page-channel .stream-layout > .chat-sidebar').forEach(bind);syncAll();++ // The page goes live (or stops) after this runs: the docked sheet's saved height is measured again then.++ const p = page();++ if (p && !p._ovSheetWatch && window.MutationObserver) {++ p._ovSheetWatch = true;++ let was = p.classList.contains('ch-is-live');++ new MutationObserver(() => { const now = p.classList.contains('ch-is-live'); if (now !== was) { was = now; applySaved(); syncAll(); } })++ .observe(p, { attributes: true, attributeFilter: ['class'] });++ }}init();document.addEventListener('ov:fragment', init);@@@ -481,6 +481,8 @@ function applyChatSettings() {document.querySelectorAll('.chat-messages, .global-chat-messages, #pc-msgs').forEach(el => {el.classList.toggle('compact', !!chatSettings.compactMode);});++ // ... and the room around it: the sheet's header, the goal bar and the channel line take less (ask 366).++ document.documentElement.classList.toggle('chat-compact', !!chatSettings.compactMode);// Sync all settings panel checkboxes/selects if any are opensyncSettingsPanelUI();updateFullscreenChatOverlayState();@@@ -5713,6 +5715,10 @@ function buildSettingsPanelHTML() {<option value="large">Large</option></select></label>++ <label class="csp-row">++ <span>Compact view</span>++ <input type="checkbox" data-setting="compactMode" onchange="onChatSettingChange(this)">++ </label><label class="csp-row"><span>Show Avatars</span><input type="checkbox" data-setting="showAvatars" onchange="onChatSettingChange(this)">@@@ -53,7 +53,8 @@ const resize = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'channassert.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(resize, /if \(Math\.abs\(d\.cur - m\.max\(\)\) <= Math\.abs\(d\.cur - line\)\) \{ reset\('sheet'\);/, 'nearer the top than the line: all the way up, the default');++assert.match(resize, /write\(m\.key, LINE\); setSize\('sheet', LINE\);/, 'otherwise just under the channel line, never a line cut in half (ask 366)');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');
@@ -2177,3 +2177,70 @@ html[data-ov-motion="off"] #page-channel .ch-rec-dot { animation: none; }
@@ -2177,3 +2177,70 @@ html[data-ov-motion="off"] #page-channel .ch-rec-dot { animation: none; }
.ch-bot-win { height: 100%; min-height: 200px; }
.ch-bot-win { height: 100%; min-height: 200px; }
.ch-bot-win iframe { display: block; width: 100%; height: 100%; border: 0; background: transparent; }
.ch-bot-win iframe { display: block; width: 100%; height: 100%; border: 0; background: transparent; }
@media (max-width: 768px) { #page-channel [data-bot-mode="window"] { display: none !important; } }
@media (max-width: 768px) { #page-channel [data-bot-mode="window"] { display: none !important; } }
+
+/* ── One section at a time on a phone or a tablet (ask 367, 2026-11-11: "consolidate these into tabs, self-contained
+ tabs … at least on mobile"). The page's HTML keeps every section; at 900 px or narrower app-channel.js marks the
+ page .ch-tabbed and the chosen one .is-current. The tab names the section, so its own heading steps aside and its
+ tools lead its first row; folds open by themselves. ─── */
+#page-channel.ch-tabbed .ch-tab-panel:not(.is-current) { display: none; }
+#page-channel.ch-tabbed .ch-tab-panel { margin: 0 0 18px; min-height: 42vh; }
+#page-channel.ch-tabbed .ch-tab-panel > .ch-sec-h, #page-channel.ch-tabbed .ch-tab-panel .ch-fold > summary { display: none; }
+#page-channel.ch-tabbed :is(#ch-panel-videos, #ch-panel-clips, #ch-panel-clips-taken, #ch-panel-pastes) > .ch-panel-bar { grid-column: 1 / -1; grid-row: 1; justify-content: flex-start; }
+#page-channel.ch-tabbed :is(#ch-panel-videos, #ch-panel-clips, #ch-panel-clips-taken, #ch-panel-pastes) > .ch-panel-bar:not(:has(> :not([style*="display:none"]):not([style*="display: none"]):not(:empty))) { display: none; }
+#page-channel.ch-tabbed .ch-tab-panel.is-entering { animation: chPanelIn var(--ov-dur-2, .22s) var(--ov-ease-out, cubic-bezier(.22, 1, .36, 1)) both; }
+@keyframes chPanelIn { from { opacity: 0; transform: translateY(6px); } }
+html[data-ov-motion="off"] #page-channel.ch-tabbed .ch-tab-panel.is-entering { animation: none; }
+/* The tabs: a full-width strip under the navbar, the chosen one lit and underlined. */
+#page-channel.ch-tabbed .ch-tabs-wrap { margin-bottom: 12px; border-bottom: 1px solid var(--border); }
+#page-channel.ch-tabbed #ch-tabs .ch-tab { min-height: 44px; }
+#page-channel.ch-tabbed #ch-tabs .ch-tab.active { color: var(--text-primary); }
+
+/* ── Media requests (ask 367: "polish"): one field with its button, the price and the limit as chips, the keepers'
+ line with icon tools, a counted "Up next", and an empty queue that says what to do. ─── */
+.ch-media-req { align-items: stretch; gap: 0; border: 1px solid var(--border); border-radius: 12px; background: var(--bg-card); overflow: hidden; transition: border-color var(--ov-dur-2, .15s), box-shadow var(--ov-dur-2, .15s); }
+.ch-media-req:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent); }
+.ch-media-field { flex: 1; min-width: 0; display: flex; align-items: center; gap: 8px; padding: 0 12px; color: var(--text-muted); cursor: text; }
+.ch-media-field input { flex: 1; min-width: 0; height: 46px; border: 0; outline: 0; background: transparent; color: var(--text-primary); font: inherit; font-size: 16px; }
+.ch-media-req .ch-media-go { flex: none; display: inline-flex; align-items: center; gap: 7px; margin: 4px; border-radius: 9px; white-space: nowrap; }
+.ch-media-hint { display: flex; flex-wrap: wrap; gap: 6px; margin-top: -4px; }
+.ch-media-chip { display: inline-flex; align-items: center; gap: 6px; height: 28px; padding: 0 10px; border-radius: 999px; font-size: .76rem; font-weight: 600; color: var(--text-secondary); background: color-mix(in srgb, var(--text-primary, #fff) 6%, transparent); }
+.ch-media-chip i { color: var(--accent-light, var(--accent)); }
+.ch-media-chip.is-lock i { color: var(--warning, #f59e0b); }
+.ch-media-mod-bar { justify-content: space-between; padding: 6px 6px 6px 12px; }
+.ch-media-mod-say { display: inline-flex; align-items: center; gap: 7px; color: var(--text-secondary); font-weight: 600; }
+.ch-media-mod-say i { color: var(--accent-light, var(--accent)); }
+.ch-media-mod-acts { display: inline-flex; gap: 6px; }
+.ch-media-section-label .ch-sec-n { display: inline-flex; align-items: center; height: 22px; padding: 0 8px; border-radius: 999px; font-size: .74rem; font-weight: 700; color: var(--text-muted); background: color-mix(in srgb, var(--text-primary, #fff) 7%, transparent); }
+.ch-media-empty { display: flex; align-items: center; gap: 12px; padding: 16px; border: 1px dashed var(--border); border-radius: 12px; color: var(--text-secondary); font-size: .88rem; }
+.ch-media-empty i { display: grid; place-items: center; flex: none; width: 38px; height: 38px; border-radius: 11px; color: var(--accent-light, var(--accent)); background: color-mix(in srgb, var(--accent) 14%, transparent); }
+@media (max-width: 420px) { .ch-media-req .ch-media-go span { display: none; } .ch-media-req .ch-media-go { width: 44px; justify-content: center; padding: 0; } }
+
+/* ── A phone watching live, the sheet (ask 366, 2026-11-11: "a gap in between the top of the chat and the top of the
+ player … the top bar a little more compact … the tabs under the stream … the full space of the chat … if you collapse
+ the chat … collapse the controls … the same button"). Open, the sheet is everything under the player: the sections
+ wait until it is folded, so nothing peeks under it and the page does not scroll away from it. The grip lies over the
+ header's top edge instead of taking a row. Folded, the robot's controls fold with it (the player's robot button still
+ drives over the video); the round button brings both back. A lowered sheet stops at the channel line's own height
+ (channel-chat-resize.js), so the line is never cut in half. ─── */
+@media (max-width: 768px) and (orientation: portrait) {
+ #page-channel.ch-is-live:not(.chat-collapsed) .channel-below-fold { display: none; }
+ body:has(#page-channel.active.ch-is-live:not(.chat-collapsed)) #ov-footer { display: none; }
+ #page-channel.ch-is-live.chat-collapsed #ch-bot-section { display: none !important; }
+ #page-channel.ch-is-live .stream-layout:not(:has(.video-container.is-vertical)) > .chat-sidebar > .chat-grip { position: absolute; top: 0; left: 0; right: 0; height: 14px; z-index: 4; }
+ #page-channel.ch-is-live .stream-layout:not(:has(.video-container.is-vertical)) > .chat-sidebar .chat-header { padding-top: 8px; min-height: 46px; }
+ #page-channel.ch-is-live .chat-sidebar .ch-goal-widget { padding: 3px 6px 0; }
+ #page-channel.ch-is-live .chat-sidebar .cgw-goal { height: 30px; }
+ #page-channel.ch-is-live .chat-sidebar .cgw-track .cgw-goal { margin-bottom: 3px; }
+ /* The channel line above a lowered sheet: its own height, never a sliver. */
+ #page-channel.ch-is-live.ch-chat-sized:not(.chat-collapsed) .stream-layout:not(:has(.video-container.is-vertical)) > .stream-main > .ch-info-bar { overflow: hidden; }
+}
+
+/* ── Compact view (chat settings → Compact view, kept with the person's chat settings; ask 366): the sheet's header,
+ the goal bar and the composer's padding take less, so the messages get the room. ─── */
+@media (max-width: 768px) and (orientation: portrait) {
+ html.chat-compact #page-channel.ch-is-live .stream-layout:not(:has(.video-container.is-vertical)) > .chat-sidebar .chat-header { padding-top: 6px; padding-bottom: 2px; min-height: 40px; }
+ html.chat-compact #page-channel.ch-is-live .chat-sidebar .ch-bot-tabs button { min-height: 32px; }
+ html.chat-compact #page-channel.ch-is-live .chat-sidebar .cgw-goal { height: 22px; }
+ html.chat-compact #page-channel.ch-is-live .chat-sidebar .cgw-goal * { font-size: .66rem; }
+ html.chat-compact #page-channel.ch-is-live .chat-sidebar .chat-input-area { padding-top: 4px; }
+}
@@ -400,8 +400,9 @@
@@ -400,8 +400,9 @@
</div><!-- /.stream-layout -->
</div><!-- /.stream-layout -->
</div>
</div>
- <!-- Channel content (below stream): every section on the page, never behind a tab (owner 10-31: content is never
- hidden to shorten a page). The row is a jump bar with counts; AI timeline and Analytics are counted folds. -->
+ <!-- Channel content (below stream): every section is in the page's HTML. A wide screen shows them one after
+ another with this row as a jump bar; at 900 px or narrower the row is tabs and one section shows at a time
+ (ask 367, 2026-11-11: "self-contained tabs, so the page isn't so freaking huge"; app-channel.js _chShowPanel). -->
<div class="channel-below-fold">
<div class="channel-below-fold">
<div class="container">
<div class="container">
<nav class="ch-tabs ch-jump" id="ch-tabs" aria-label="Jump to a section">
<nav class="ch-tabs ch-jump" id="ch-tabs" aria-label="Jump to a section">
@@ -712,7 +712,11 @@ async function loadChannelPastes(username = currentChannelUsername, { append = f
@@ -712,7 +712,11 @@ async function loadChannelPastes(username = currentChannelUsername, { append = f
const sortHost = document.getElementById('ch-pastes-sort');
const sortHost = document.getElementById('ch-pastes-sort');
if (!grid) return;
if (!grid) return;
const sort = channelPastesSortByUser[username] || 'newest';
const sort = channelPastesSortByUser[username] || 'newest';
- if (sortHost) sortHost.innerHTML = (typeof sortToggleHTML === 'function') ? sortToggleHTML(sort, 'setChannelPastesSort') : '';
+ if (sortHost) {
+ const name = sort === 'oldest' ? 'Oldest first' : 'Newest first';
+ sortHost.innerHTML = `<label class="ch-ib ch-ib-pick" data-order="${sort}" title="${name}"><i class="fa-solid ${sort === 'oldest' ? 'fa-arrow-down-short-wide' : 'fa-arrow-down-wide-short'}" aria-hidden="true"></i><span class="ch-ib-t">${name}</span><i class="fa-solid fa-chevron-down ch-ib-caret" aria-hidden="true"></i>`
+ + `<select class="ch-vods-filter-select" data-ov-native aria-label="Order" onchange="setChannelPastesSort(this.value)"><option value="newest"${sort === 'oldest' ? '' : ' selected'}>Newest first</option><option value="oldest"${sort === 'oldest' ? ' selected' : ''}>Oldest first</option></select></label>`;
+ }
try {
try {
const oldCards = append ? [...grid.children].filter((el) => !el.classList.contains('ch-rail-skeleton')) : [];
const oldCards = append ? [...grid.children].filter((el) => !el.classList.contains('ch-rail-skeleton')) : [];
const offset = append ? oldCards.length : 0;
const offset = append ? oldCards.length : 0;
@@ -2271,6 +2275,7 @@ function switchChannelTab(tab, btn, jump) {
@@ -2271,6 +2275,7 @@ function switchChannelTab(tab, btn, jump) {
_channelTab = tab;
_channelTab = tab;
_markChannelJump(tab, !!jump);
_markChannelJump(tab, !!jump);
_ensureChannelSection(tab);
_ensureChannelSection(tab);
+ if (_chIsTabbed()) { _chShowPanel(tab, !!jump); return; }
if (!jump) return;
if (!jump) return;
const panel = document.getElementById('ch-panel-' + tab);
const panel = document.getElementById('ch-panel-' + tab);
if (!panel || panel.hidden) return;
if (!panel || panel.hidden) return;
@@ -2281,6 +2286,51 @@ function switchChannelTab(tab, btn, jump) {
@@ -2281,6 +2286,51 @@ function switchChannelTab(tab, btn, jump) {
panel.scrollIntoView({ behavior: reduce ? 'auto' : 'smooth', block: 'start' });
panel.scrollIntoView({ behavior: reduce ? 'auto' : 'smooth', block: 'start' });
setTimeout(_queueChannelSectionSpy, reduce ? 200 : 1450);
setTimeout(_queueChannelSectionSpy, reduce ? 200 : 1450);
}
}
+/*
+ * One section at a time on a phone or a tablet (ask 367, 2026-11-11, the owner: "consolidate these into tabs,
+ * self-contained tabs, so the page isn't so freaking huge … at least on mobile"). The page's HTML still carries every
+ * section (crawlers, readers without scripts, the server's own render); a person at 900 px or narrower sees the one
+ * whose tab they chose, the others wait (and load the first time they are opened). A wide screen keeps the sections
+ * one after another with the jump bar.
+ */
+const _chTabbedMQ = typeof window.matchMedia === 'function' ? window.matchMedia('(max-width: 900px)') : null;
+function _chIsTabbed() { return !!(_chTabbedMQ && _chTabbedMQ.matches); }
+function _chFirstTab() {
+ const b = Array.from(document.querySelectorAll('#ch-tabs .ch-tab[data-tab]')).find((x) => x.style.display !== 'none');
+ return b ? b.dataset.tab : 'videos';
+}
+function _chShowPanel(tab, fromTap) {
+ let panel = document.getElementById('ch-panel-' + tab);
+ if (!panel || panel.hidden) { tab = _chFirstTab(); panel = document.getElementById('ch-panel-' + tab); }
+ if (!panel) return;
+ const was = document.querySelector('#page-channel .ch-tab-panel.is-current');
+ document.querySelectorAll('#page-channel .ch-tab-panel').forEach((p) => p.classList.toggle('is-current', p === panel));
+ if (was !== panel) {
+ panel.classList.remove('is-entering'); void panel.offsetWidth; panel.classList.add('is-entering');
+ setTimeout(() => panel.classList.remove('is-entering'), 260);
+ }
+ _channelTab = tab;
+ _markChannelJump(tab, !!fromTap);
+ const fold = panel.querySelector('details.ch-fold');
+ if (fold && !fold.open) fold.open = true;
+ _ensureChannelSection(tab);
+ // A tab chosen while the page is scrolled into a long section starts the new one at its top, under the bar.
+ if (fromTap) {
+ const bar = document.getElementById('ch-tabs');
+ const under = bar ? bar.getBoundingClientRect().bottom : 0;
+ const top = panel.getBoundingClientRect().top;
+ if (top < under - 2) window.scrollTo({ top: window.scrollY + top - under - 8, behavior: 'auto' });
+ }
+}
+function _chApplyTabbed() {
+ const page = document.getElementById('page-channel');
+ if (!page) return;
+ const on = _chIsTabbed();
+ page.classList.toggle('ch-tabbed', on);
+ if (on) _chShowPanel(_channelTab || _chFirstTab(), false);
+ else document.querySelectorAll('#page-channel .ch-tab-panel.is-current').forEach((p) => p.classList.remove('is-current'));
+}
+if (_chTabbedMQ && typeof _chTabbedMQ.addEventListener === 'function') _chTabbedMQ.addEventListener('change', () => { _chApplyTabbed(); _queueChannelSectionSpy(); });
function _channelReduceMotion() {
function _channelReduceMotion() {
return document.documentElement.dataset.ovMotion === 'off';
return document.documentElement.dataset.ovMotion === 'off';
}
}
@@ -2378,12 +2428,18 @@ function _syncChannelSections() {
@@ -2378,12 +2428,18 @@ function _syncChannelSections() {
if (panel) panel.hidden = b.style.display === 'none';
if (panel) panel.hidden = b.style.display === 'none';
});
});
_updateChannelTabArrows();
_updateChannelTabArrows();
+ // Tabbed: the section shown went away (no About after all): the first one there is.
+ if (_chIsTabbed()) {
+ const cur = document.querySelector('#page-channel .ch-tab-panel.is-current');
+ if (!cur || cur.hidden) _chShowPanel(_chFirstTab(), false);
+ }
}
}
let _chSecObs = null, _chSpyObs = null, _chTabsObs = null;
let _chSecObs = null, _chSpyObs = null, _chTabsObs = null;
let _chSpyFrame = 0;
let _chSpyFrame = 0;
let _chHashMark = ''; // a section a live link named: its jump link stays marked while the page is still at the top
let _chHashMark = ''; // a section a live link named: its jump link stays marked while the page is still at the top
function _channelSectionInView() {
function _channelSectionInView() {
_chSpyFrame = 0;
_chSpyFrame = 0;
+ if (_chIsTabbed()) return; // one section at a time: the tab says which, not the scroll
if (Date.now() < _chJumpUntil) return;
if (Date.now() < _chJumpUntil) return;
if (_chHashMark) {
if (_chHashMark) {
if (window.scrollY <= 120) return;
if (window.scrollY <= 120) return;
@@ -2408,6 +2464,7 @@ function _queueChannelSectionSpy() {
@@ -2408,6 +2464,7 @@ function _queueChannelSectionSpy() {
if (!_chSpyFrame) _chSpyFrame = requestAnimationFrame(_channelSectionInView);
if (!_chSpyFrame) _chSpyFrame = requestAnimationFrame(_channelSectionInView);
}
}
function _watchChannelSections() {
function _watchChannelSections() {
+ _chApplyTabbed();
_syncChannelSections();
_syncChannelSections();
const tabs = document.getElementById('ch-tabs');
const tabs = document.getElementById('ch-tabs');
_initChannelTabArrows();
_initChannelTabArrows();
@@ -3690,16 +3747,16 @@ async function loadChannelMedia(username = currentChannelUsername) {
@@ -3690,16 +3747,16 @@ async function loadChannelMedia(username = currentChannelUsername) {
host.innerHTML = `
host.innerHTML = `
<div class="ch-media">
<div class="ch-media">
<form class="ch-media-req" onsubmit="return submitMediaRequest(event)">
<form class="ch-media-req" onsubmit="return submitMediaRequest(event)">
- <input id="ch-media-input" type="text" placeholder="Paste a ${esc(sources || 'media')} URL to request…" ${loggedIn ? '' : 'disabled'} oninput="_clearMediaQuote()">
- <button class="btn btn-primary" ${loggedIn ? '' : 'disabled'}><i class="fa-solid fa-magnifying-glass"></i> Check price</button>
+ <label class="ch-media-field"><i class="fa-solid fa-link" aria-hidden="true"></i><input id="ch-media-input" type="url" inputmode="url" autocomplete="off" aria-label="A link to request" placeholder="Paste a ${esc(sources || 'media')} link" ${loggedIn ? '' : 'disabled'} oninput="_clearMediaQuote()"></label>
+ <button class="btn btn-primary ch-media-go" ${loggedIn ? '' : 'disabled'}><i class="fa-solid fa-magnifying-glass" aria-hidden="true"></i><span>Check price</span></button>
</form>
</form>
- <div class="ch-media-hint muted">${loggedIn ? '' : '<i class="fa-solid fa-lock"></i> Log in to request. '}<i class="${_mediaCurrencyIcon(p && p.currency)}"></i> ${esc(_mediaPriceLabel(p))}${maxMin ? ` Max ${maxMin} min.` : ''}</div>
+ <div class="ch-media-hint">${loggedIn ? '' : '<span class="ch-media-chip is-lock"><i class="fa-solid fa-lock" aria-hidden="true"></i> Sign in to request</span>'}<span class="ch-media-chip"><i class="${_mediaCurrencyIcon(p && p.currency)}" aria-hidden="true"></i> ${esc(_mediaPriceLabel(p).replace(/\.$/, ''))}</span>${maxMin ? `<span class="ch-media-chip"><i class="fa-solid fa-clock" aria-hidden="true"></i> Up to ${maxMin} min</span>` : ''}</div>
<div id="ch-media-quote"></div>
<div id="ch-media-quote"></div>
<div id="ch-media-status" class="ch-media-status"></div>
<div id="ch-media-status" class="ch-media-status"></div>
- ${_mediaCanManage ? `<div class="ch-media-mod-bar"><span class="muted"><i class="fa-solid fa-shield-halved"></i> You can manage this queue</span><button class="btn btn-sm btn-outline" onclick="_mediaAdvance('played')"><i class="fa-solid fa-forward-step"></i> Next</button><button class="btn btn-sm btn-outline" onclick="_mediaAdvance('skipped')"><i class="fa-solid fa-ban"></i> Skip & refund</button></div>` : ''}
+ ${_mediaCanManage ? `<div class="ch-media-mod-bar"><span class="ch-media-mod-say"><i class="fa-solid fa-shield-halved" aria-hidden="true"></i> You run this queue</span><span class="ch-media-mod-acts"><button class="ch-ib" type="button" title="Play the next request" aria-label="Play the next request" onclick="_mediaAdvance('played')"><i class="fa-solid fa-forward-step" aria-hidden="true"></i><span class="ch-ib-t">Next</span></button><button class="ch-ib" type="button" title="Skip this one and refund it" aria-label="Skip this one and refund it" onclick="_mediaAdvance('skipped')"><i class="fa-solid fa-ban" aria-hidden="true"></i><span class="ch-ib-t">Skip & refund</span></button></span></div>` : ''}
${np ? `<div class="ch-media-now"><div class="ch-media-section-label"><i class="fa-solid fa-play"></i> Now Playing</div>${_mediaItemHTML(np)}</div>` : ''}
${np ? `<div class="ch-media-now"><div class="ch-media-section-label"><i class="fa-solid fa-play"></i> Now Playing</div>${_mediaItemHTML(np)}</div>` : ''}
- <div class="ch-media-section-label"><i class="fa-solid fa-list-ol"></i> Up Next (${queue.length})</div>
- <div class="ch-media-queue">${queue.length ? queue.map((q, i) => _mediaItemHTML(q, i + 1)).join('') : '<div class="muted" style="padding:12px">Queue is empty — be the first to request something!</div>'}</div>
+ <div class="ch-media-section-label"><i class="fa-solid fa-list-ol" aria-hidden="true"></i> Up next <span class="ch-sec-n">${queue.length}</span></div>
+ <div class="ch-media-queue">${queue.length ? queue.map((q, i) => _mediaItemHTML(q, i + 1)).join('') : '<div class="ch-media-empty"><i class="fa-solid fa-music" aria-hidden="true"></i><span>Nothing queued yet. Paste a link above to be first.</span></div>'}</div>
</div>`;
</div>`;
} catch { host.innerHTML = '<div class="ch-about-empty">Failed to load the media queue.</div>'; }
} catch { host.innerHTML = '<div class="ch-about-empty">Failed to load the media queue.</div>'; }
}
}
@@ -7,8 +7,10 @@
@@ -7,8 +7,10 @@
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
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.
+ all the way up to it; letting go most of the way down folds it to the round chat button. Between those it has one
+ other height (ask 366: the channel line showed cut in half above a sheet of any height): the channel line's own,
+ so the sheet is all the way up, just under the line, or folded. That height is saved as LINE (-1) and measured
+ on the page each time, so it fits whatever the line holds on this phone.
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
@@ -36,6 +38,14 @@
@@ -36,6 +38,14 @@
if (row) top += row.getBoundingClientRect().height;
if (row) top += row.getBoundingClientRect().height;
return Math.max(180, layout.getBoundingClientRect().bottom - top);
return Math.max(180, layout.getBoundingClientRect().bottom - top);
}
}
+ const LINE = -1; // the docked sheet just under the channel line
+ /** The docked sheet's height with the channel line shown whole above it. */
+ function lineSize() {
+ const p = page();
+ const info = p && p.querySelector('.stream-main > .ch-info-bar');
+ const h = info && info.offsetParent !== null ? info.scrollHeight : 0;
+ return Math.max(180, Math.round(dockRoom() - (h || 150)));
+ }
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 },
@@ -49,6 +59,15 @@
@@ -49,6 +59,15 @@
function setSize(mode, v) {
function setSize(mode, v) {
const p = page(); const m = MODES[mode];
const p = page(); const m = MODES[mode];
if (!p) return;
if (!p) return;
+ // Docked, a height saved before (any number of pixels) is the line height now.
+ if (mode === 'sheet' && v > 0 && docked()) v = LINE;
+ if (mode === 'sheet' && v === LINE) {
+ // Shown first, so the line can be measured; a sheet that is not docked has no line height: the default.
+ if (!docked()) { p.style.removeProperty(m.prop); p.classList.remove('ch-chat-sized'); return; }
+ p.classList.add('ch-chat-sized');
+ p.style.setProperty(m.prop, `${lineSize()}px`);
+ 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).
// 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);
if (mode === 'sheet') p.classList.toggle('ch-chat-sized', !!v);
@@ -124,7 +143,11 @@
@@ -124,7 +143,11 @@
sync(g, sidebar);
sync(g, sidebar);
return;
return;
}
}
- if (d.cur >= m.max() - 16) { reset('sheet'); sync(g, sidebar); return; }
+ // Otherwise the nearer of its two heights: all the way up, or just under the channel line.
+ const line = lineSize();
+ if (Math.abs(d.cur - m.max()) <= Math.abs(d.cur - line)) { reset('sheet'); sync(g, sidebar); return; }
+ write(m.key, LINE); setSize('sheet', LINE); settle(); 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
@@ -147,8 +170,15 @@
@@ -147,8 +170,15 @@
if (!(e.key in grow)) return;
if (!(e.key in grow)) return;
e.preventDefault();
e.preventDefault();
const r = sidebar.getBoundingClientRect();
const r = sidebar.getBoundingClientRect();
+ if (mode === 'sheet' && docked()) {
+ // Up: all the way up. Down: under the channel line, then folded.
+ if (grow[e.key] > 0) reset('sheet');
+ else if (read(m.key) === LINE) { if (typeof window.toggleChannelChat === 'function') window.toggleChannelChat(true); }
+ else { write(m.key, LINE); setSize('sheet', LINE); settle(); }
+ sync(g, sidebar);
+ return;
+ }
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);
});
});
@@ -158,6 +188,14 @@
@@ -158,6 +188,14 @@
applySaved();
applySaved();
document.querySelectorAll('#page-channel .stream-layout > .chat-sidebar').forEach(bind);
document.querySelectorAll('#page-channel .stream-layout > .chat-sidebar').forEach(bind);
syncAll();
syncAll();
+ // The page goes live (or stops) after this runs: the docked sheet's saved height is measured again then.
+ const p = page();
+ if (p && !p._ovSheetWatch && window.MutationObserver) {
+ p._ovSheetWatch = true;
+ let was = p.classList.contains('ch-is-live');
+ new MutationObserver(() => { const now = p.classList.contains('ch-is-live'); if (now !== was) { was = now; applySaved(); syncAll(); } })
+ .observe(p, { attributes: true, attributeFilter: ['class'] });
+ }
}
}
init();
init();
document.addEventListener('ov:fragment', init);
document.addEventListener('ov:fragment', init);
@@ -481,6 +481,8 @@ function applyChatSettings() {
@@ -481,6 +481,8 @@ function applyChatSettings() {
document.querySelectorAll('.chat-messages, .global-chat-messages, #pc-msgs').forEach(el => {
document.querySelectorAll('.chat-messages, .global-chat-messages, #pc-msgs').forEach(el => {
el.classList.toggle('compact', !!chatSettings.compactMode);
el.classList.toggle('compact', !!chatSettings.compactMode);
});
});
+ // ... and the room around it: the sheet's header, the goal bar and the channel line take less (ask 366).
+ document.documentElement.classList.toggle('chat-compact', !!chatSettings.compactMode);
// Sync all settings panel checkboxes/selects if any are open
// Sync all settings panel checkboxes/selects if any are open
syncSettingsPanelUI();
syncSettingsPanelUI();
updateFullscreenChatOverlayState();
updateFullscreenChatOverlayState();
@@ -5713,6 +5715,10 @@ function buildSettingsPanelHTML() {
@@ -5713,6 +5715,10 @@ function buildSettingsPanelHTML() {
<option value="large">Large</option>
<option value="large">Large</option>
</select>
</select>
</label>
</label>
+ <label class="csp-row">
+ <span>Compact view</span>
+ <input type="checkbox" data-setting="compactMode" onchange="onChatSettingChange(this)">
+ </label>
<label class="csp-row">
<label class="csp-row">
<span>Show Avatars</span>
<span>Show Avatars</span>
<input type="checkbox" data-setting="showAvatars" onchange="onChatSettingChange(this)">
<input type="checkbox" data-setting="showAvatars" onchange="onChatSettingChange(this)">
@@ -53,7 +53,8 @@ const resize = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'chann
@@ -53,7 +53,8 @@ const resize = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'chann
assert.match(resize, /if \(mode === 'sheet'\) p\.classList\.toggle\('ch-chat-sized', !!v\);/, 'a saved height marks the page');
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, /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\.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(resize, /if \(Math\.abs\(d\.cur - m\.max\(\)\) <= Math\.abs\(d\.cur - line\)\) \{ reset\('sheet'\);/, 'nearer the top than the line: all the way up, the default');
+assert.match(resize, /write\(m\.key, LINE\); setSize\('sheet', LINE\);/, 'otherwise just under the channel line, never a line cut in half (ask 366)');
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: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, /#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');
assert.match(css, /> \.chat-sidebar > \.chat-grip \{ display: block !important;/, 'the grip shows on the docked chat');