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)

OP OpenVibers committed 2 days ago · 9178d5f687 · 6 files, +184 −14

- Sections (ask 367): at 900 px or narrower the jump bar is tabs and one section shows at a time (each loads the first
  time it is opened); the page's HTML still has every section for crawlers; a wide screen keeps them one after
  another. The tab names the section, so its heading steps aside and its tools lead.
- Pastes' order is an icon with the native picker (the "Sort Newest Oldest" pill covered the heading on a phone).
- Media requests: one field with its button, the price and the limit as chips, the keepers' line with icon tools,
  a counted "Up next", an empty queue that says what to do.
- The live sheet on a phone (ask 366): open, it is everything under the player; the sections and the footer wait until
  it is folded, so nothing peeks under it. Lowered, it stops just under the channel line (measured, saved as LINE), so
  the line is never cut in half; up is all the way up; low folds it. The grip lies over the header's edge, the header
  and the goal bar are slimmer. Folded, the robot's controls fold with the chat (the player's robot button still
  drives over the video).
- Compact view in the chat settings (kept with the person's chat settings): slimmer header, goal bar and composer.

Changes

ddiff --git a/public/css/features/channel.css b/public/css/features/channel.css
iindex 8baef3a6..f8492dcc 100644
---- a/public/css/features/channel.css
++++ b/public/css/features/channel.css
@@@ -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; }
++}
ddiff --git a/public/fragments/channel.html b/public/fragments/channel.html
iindex efa27403..b92bbfed 100644
---- a/public/fragments/channel.html
++++ b/public/fragments/channel.html
@@@ -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">
ddiff --git a/public/js/app-channel.js b/public/js/app-channel.js
iindex 115d390c..52f03403 100644
---- a/public/js/app-channel.js
++++ b/public/js/app-channel.js
@@@ -712,7 +712,11 @@ async function loadChannelPastes(username = currentChannelUsername, { append = f
const 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 top
function _channelSectionInView() {
_chSpyFrame = 0;
++ if (_chIsTabbed()) return; // one section at a time: the tab says which, not the scroll
if (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 &amp; 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 &amp; 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>'; }
}
ddiff --git a/public/js/channel-chat-resize.js b/public/js/channel-chat-resize.js
iindex ecf01dbb..9b259269 100644
---- a/public/js/channel-chat-resize.js
++++ b/public/js/channel-chat-resize.js
@@@ -7,8 +7,10 @@
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.
++ 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);
ddiff --git a/public/js/chat.js b/public/js/chat.js
iindex 753f1845..437f50f3 100644
---- a/public/js/chat.js
++++ b/public/js/chat.js
@@@ -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 open
syncSettingsPanelUI();
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)">
ddiff --git a/test/chat-sheet-width.test.js b/test/chat-sheet-width.test.js
iindex d7411999..4927bfda 100644
---- a/test/chat-sheet-width.test.js
++++ b/test/chat-sheet-width.test.js
@@@ -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, /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');