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)
Changes
@@@ -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. */@@@ -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';@@@ -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');@@@ -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.closeEassert.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).
@@ -1486,16 +1486,27 @@
@@ -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). */
/* 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); }
.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) {
@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 {
#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);
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 .mobile-chat-close,
#page-channel #ch-live-area .stream-layout > .chat-sidebar > .chat-grip { display: none; }
#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. */
/* Docked, the chat's messages get the room: a slim header, the tools beside nothing else. */
@@ -297,6 +297,7 @@ function toggleEmotePicker(inputId) {
@@ -297,6 +297,7 @@ function toggleEmotePicker(inputId) {
_emotePickerTarget = inputId;
_emotePickerTarget = inputId;
picker.style.display = _emotePickerOpen ? 'flex' : 'none';
picker.style.display = _emotePickerOpen ? 'flex' : 'none';
picker.dataset.targetInput = inputId || 'chat-input';
picker.dataset.targetInput = inputId || 'chat-input';
+ if (_emotePickerOpen) _placeEmotePicker(picker);
if (_emotePickerOpen) {
if (_emotePickerOpen) {
_emoteMenuMode = 'emotes';
_emoteMenuMode = 'emotes';
@@ -312,6 +313,28 @@ function toggleEmotePicker(inputId) {
@@ -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) {
function renderEmotePicker(mode) {
// Accept legacy tab names ('all'/'channel'/…) → treat as the emotes list.
// Accept legacy tab names ('all'/'channel'/…) → treat as the emotes list.
if (mode !== 'gif') mode = 'emotes';
if (mode !== 'gif') mode = 'emotes';
@@ -1,8 +1,9 @@
@@ -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).
* 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);
@@ -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; }
for (; end < css.length; end++) { if (css[end] === '{') depth++; else if (css[end] === '}' && --depth === 0) break; }
const block = css.slice(at, end + 1);
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, /\.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');
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');
console.log('channel phone chat: all checks passed');
@@ -39,7 +39,8 @@ const ctx = {
@@ -39,7 +39,8 @@ const ctx = {
getElementById: (id) => els[id] || null,
getElementById: (id) => els[id] || null,
querySelector: (sel) => (sel === '#bc-emote-picker .emote-search' ? picker.search : 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' }] }; },
api: async () => { loads++; return { defaults: [{ id: 'd1', code: 'LUL', url: 'https://x/1.png' }] }; },
};
};
vm.createContext(ctx);
vm.createContext(ctx);
@@ -76,6 +77,22 @@ vm.runInContext(`${src}\nthis.toggleEmotePicker = toggleEmotePicker; this.closeE
@@ -76,6 +77,22 @@ vm.runInContext(`${src}\nthis.toggleEmotePicker = toggleEmotePicker; this.closeE
assert.strictEqual(picker.style.display, 'none');
assert.strictEqual(picker.style.display, 'none');
assert.strictEqual(ctx.state().open, false);
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');
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');
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).
// The GIF picker the chat's GIF button opens: the same rules (a ×, any tap outside, its own button left to toggle it).