openvibers/openvibe.live Commits

Channel on a phone held upright: the video on top, the chat filling the rest of the screen with its message box in view, the stream info after it (the docked chat grew with its history: 19,000 px, its box a long scroll away); the emote sheet opens above the message box, not over it (#177)

OP OpenVibers committed 27 days ago · 5f38afd533 · 4 files, +70 −13

Changes

ddiff --git a/public/css/features/channel.css b/public/css/features/channel.css
iindex e21efe81..bae471f5 100644
---- a/public/css/features/channel.css
++++ b/public/css/features/channel.css
@@@ -1486,16 +1486,27 @@
/* Tabs that run past the edge fade out there, so it reads as "scroll for more" (class set by app-channel.js). */
.ch-tabs.is-clipped { -webkit-mask-image: linear-gradient(to right, #000 calc(100% - 36px), transparent); mask-image: linear-gradient(to right, #000 calc(100% - 36px), transparent); }
--/* A live channel on a phone held upright: the chat sits under the stream info and fills the screen below the video,
-- the way phone streaming apps do. The stream area was sized to the whole screen while the chat waited behind a
-- button, which left about a third of the screen empty (seen 2026-10-16). */
++/* A live channel on a phone held upright, the way phone streaming apps lay it out: the video on top, the chat right
++ under it filling the rest of the screen (its messages scroll inside it, the message box at its foot), and the
++ stream info after the chat. The stream area used to fill the screen while the chat waited behind a button
++ (2026-10-16); docked under the info with `height: auto`, the chat then grew with its history (19,000 px on a busy
++ channel, 2026-10-17) and its message box was a long scroll away. `display: contents` lets the video and the info,
++ children of .stream-main, take their own places around the chat. */
@media (max-width: 768px) and (orientation: portrait) {
-- #page-channel #ch-live-area .stream-main { flex: none; overflow: visible; }
++ #page-channel #ch-live-area .stream-main { display: contents; }
++ #page-channel #ch-live-area .stream-main > * { order: 5; }
++ #page-channel #ch-live-area .stream-main > #video-container { order: 1; }
++ #page-channel #ch-live-area .stream-main > #ch-bot-section { order: 2; }
++ #page-channel #ch-live-area .stream-main > #ch-captions { order: 3; }
#page-channel #ch-live-area .stream-layout > .chat-sidebar {
-- position: relative; inset: auto; transform: none; transition: none; z-index: auto;
-- flex: 1 1 auto; width: 100%; height: auto; min-height: 220px; max-height: none;
++ order: 4; position: relative; inset: auto; transform: none; transition: none; z-index: auto;
++ flex: none; width: 100%; min-height: 0; max-height: none;
++ /* The screen under the navbar, less a 16:9 video (56.25vw); a tall video leaves the chat its 260 px. */
++ height: max(260px, calc(100dvh - var(--navbar-h) - 56.25vw));
border-radius: 0; box-shadow: none; border-top: 1px solid var(--border);
}
++ #page-channel #ch-live-area .stream-layout:has(.video-container.is-vertical) > .chat-sidebar { height: max(260px, calc(100dvh - var(--navbar-h) - 75dvh)); }
++ #page-channel #ch-live-area .stream-layout > .chat-sidebar .chat-messages { flex: 1 1 0; min-height: 0; overflow-y: auto; }
#page-channel #ch-live-area .stream-layout > .chat-sidebar .mobile-chat-close,
#page-channel #ch-live-area .stream-layout > .chat-sidebar > .chat-grip { display: none; }
/* Docked, the chat's messages get the room: a slim header, the tools beside nothing else. */
ddiff --git a/public/js/emotes.js b/public/js/emotes.js
iindex 4b3f34ad..fbdab72d 100644
---- a/public/js/emotes.js
++++ b/public/js/emotes.js
@@@ -297,6 +297,7 @@ function toggleEmotePicker(inputId) {
_emotePickerTarget = inputId;
picker.style.display = _emotePickerOpen ? 'flex' : 'none';
picker.dataset.targetInput = inputId || 'chat-input';
++ if (_emotePickerOpen) _placeEmotePicker(picker);
if (_emotePickerOpen) {
_emoteMenuMode = 'emotes';
@@@ -312,6 +313,28 @@ function toggleEmotePicker(inputId) {
}
}
++/**
++ * On phones the picker is a fixed sheet (style.css, max-width 768px). Pinned to the bottom it covered the message box and
++ * the buttons under it; it now sits just above the box, so what you type stays in view. Desktop (absolute, above the box
++ * already) and a box that is off-screen keep the stylesheet's placement.
++ */
++function _placeEmotePicker(picker) {
++ picker.style.bottom = ''; picker.style.maxHeight = '';
++ if (getComputedStyle(picker).position !== 'fixed') return;
++ const area = picker.closest('.chat-input-area');
++ const row = area && area.querySelector('.chat-input-row');
++ if (!row) return;
++ const top = row.getBoundingClientRect().top;
++ if (!(top > 120 && top < innerHeight)) return;
++ picker.style.bottom = `${Math.round(innerHeight - top + 4)}px`;
++ picker.style.maxHeight = `${Math.round(Math.min(top - 64, innerHeight * 0.5))}px`;
++}
++window.addEventListener('resize', () => {
++ if (!_emotePickerOpen) return;
++ const { picker } = _getPickerEls(_emotePickerTarget);
++ if (picker) _placeEmotePicker(picker);
++});
++
function renderEmotePicker(mode) {
// Accept legacy tab names ('all'/'channel'/…) → treat as the emotes list.
if (mode !== 'gif') mode = 'emotes';
ddiff --git a/test/channel-phone-chat.test.js b/test/channel-phone-chat.test.js
iindex f906cc18..af961d1a 100644
---- a/test/channel-phone-chat.test.js
++++ b/test/channel-phone-chat.test.js
@@@ -1,8 +1,9 @@
/**
-- * A live channel on a phone held upright shows its chat under the stream info, filling the screen below the video
-- * (public/css/features/channel.css). Before, the stream area was sized to the whole screen while the chat waited
-- * behind a floating button, which left about a third of the screen empty (seen 2026-10-16 at phone width). Docked,
-- * the chat's button and the DM bubble (which covered the chat's emoji button) step aside.
++ * A live channel on a phone held upright: the video on top, the chat under it filling the rest of the screen, the stream
++ * info after the chat (public/css/features/channel.css). Before, the stream area filled the screen while the chat waited
++ * behind a floating button (2026-10-16); then the docked chat had `height: auto` and grew with its history, 19,000 px on
++ * a busy channel, so its message box was a long scroll away (2026-10-17). Docked, the chat's button and the DM bubble
++ * (which covered the chat's emoji button) step aside.
*
* A real browser pass at phone width is in the PR (local Live, a live test stream, a signed-out phone viewer).
*
@@@ -20,8 +21,13 @@ let depth = 0, end = css.indexOf('{', at);
for (; end < css.length; end++) { if (css[end] === '{') depth++; else if (css[end] === '}' && --depth === 0) break; }
const block = css.slice(at, end + 1);
--assert.match(block, /#ch-live-area \.stream-main \{ flex: none; overflow: visible; \}/, 'the stream area keeps its own height');
--assert.match(block, /\.stream-layout > \.chat-sidebar \{[^}]*position: relative;[^}]*transform: none;[^}]*flex: 1 1 auto;[^}]*min-height: 220px;/, 'the chat docks below and fills the rest');
++assert.match(block, /#ch-live-area \.stream-main \{ display: contents; \}/, 'the video and the info take their own places');
++assert.match(block, /> #video-container \{ order: 1; \}/, 'the video first');
++assert.match(block, /\.stream-layout > \.chat-sidebar \{[^}]*order: 4;[^}]*position: relative;[^}]*transform: none;[^}]*flex: none;/, 'the chat under it');
++assert.match(block, /height: max\(260px, calc\(100dvh - var\(--navbar-h\) - 56\.25vw\)\);/, 'sized to the rest of the screen, never by its history');
++assert.match(block, /\.stream-main > \* \{ order: 5; \}/, 'the stream info after the chat');
++assert.match(block, /\.chat-sidebar \.chat-messages \{ flex: 1 1 0; min-height: 0; overflow-y: auto; \}/, 'the messages scroll inside the chat');
++assert.ok(!/height: auto;/.test(block.slice(block.indexOf('.chat-sidebar {'), block.indexOf('}', block.indexOf('.chat-sidebar {')))), 'no growing height');
assert.match(block, /\.chat-sidebar > \.chat-grip \{ display: none; \}|> \.chat-grip \{ display: none; \}/, 'no bottom-sheet grip on the docked chat');
assert.match(block, /\.mobile-chat-toggle,\s*body:has\([^)]*\) #messenger-toggle \{ display: none !important; \}/, 'its button and the DM bubble step aside');
console.log('channel phone chat: all checks passed');
ddiff --git a/test/emote-picker-close.test.js b/test/emote-picker-close.test.js
iindex a843f44d..f015f6a5 100644
---- a/test/emote-picker-close.test.js
++++ b/test/emote-picker-close.test.js
@@@ -39,7 +39,8 @@ const ctx = {
getElementById: (id) => els[id] || null,
querySelector: (sel) => (sel === '#bc-emote-picker .emote-search' ? picker.search : null),
},
-- window: {}, console, setTimeout, Promise, Map, Set,
++ window: { addEventListener: (type, fn) => { listeners[`window:${type}`] = fn; } }, console, setTimeout, Promise, Map, Set,
++ innerHeight: 800, getComputedStyle: (node) => ({ position: node.fixedSheet ? 'fixed' : 'absolute' }),
api: async () => { loads++; return { defaults: [{ id: 'd1', code: 'LUL', url: 'https://x/1.png' }] }; },
};
vm.createContext(ctx);
@@@ -76,6 +77,22 @@ vm.runInContext(`${src}\nthis.toggleEmotePicker = toggleEmotePicker; this.closeE
assert.strictEqual(picker.style.display, 'none');
assert.strictEqual(ctx.state().open, false);
++ // On a phone the picker is a fixed sheet: it opens just above the message box instead of over it.
++ const row = { getBoundingClientRect: () => ({ top: 600 }) };
++ picker.fixedSheet = true;
++ picker.closest = (sel) => (sel === '.chat-input-area' ? { querySelector: (q) => (q === '.chat-input-row' ? row : null) } : null);
++ ctx.toggleEmotePicker('bc-chat-input');
++ assert.strictEqual(picker.style.bottom, '204px', 'its bottom edge sits 4 px above the message box');
++ assert.strictEqual(picker.style.maxHeight, '400px', 'at most half the screen');
++ row.getBoundingClientRect = () => ({ top: 500 });
++ listeners['window:resize']();
++ assert.strictEqual(picker.style.bottom, '304px', 'and follows the box when the screen changes (the keyboard)');
++ ctx.closeEmotePicker();
++ picker.fixedSheet = false;
++ ctx.toggleEmotePicker('bc-chat-input');
++ assert.strictEqual(picker.style.bottom, '', 'desktop keeps the stylesheet placement');
++ ctx.closeEmotePicker();
++
const studio = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'broadcast-studio.js'), 'utf8');
assert.match(studio.slice(studio.indexOf('function mount()'), studio.indexOf('function unmount()')), /closeEmotePicker\(\)/, 'the studio closes a picker when it mounts');
// The GIF picker the chat's GIF button opens: the same rules (a ×, any tap outside, its own button left to toggle it).