openvibers/openvibe.live Commits

A phone watching a live stream gets a watch screen, and the studio drops the app bar (#468)

OP OpenVibers committed 13 days ago · 86d9a88e74 · 6 files, +189 −6

The owner, watching moosely on a phone (2026-10-31): the app's bottom bar covered the stream's
round chat button, the bar's own "Chat" opened the network inbox instead of this stream's chat,
and the phone studio's control rail ran under the same bar.

On a phone held upright, while a channel is live:
- the app bar steps aside, and so do the floating buttons (as a native app's tab bar does on a
  watch screen);
- the stream's chat is docked under the player, always open: no button to find;
- a portrait stream fills the screen (a 9:16 picture edge to edge), with the channel line over
  its top and the chat over its lower part;
- the channel line is one row (who, the title, Follow); a tap opens the rest (the AI line, the
  description, the totals).

The phone studio hides the app bar too, and chat over a picture (the studio, a portrait watch
screen) leaves out the "Connected to chat" and "Chatting as …" notices.

Changes

ddiff --git a/public/css/features/broadcast.css b/public/css/features/broadcast.css
iindex 329d301b..b20511c9 100644
---- a/public/css/features/broadcast.css
++++ b/public/css/features/broadcast.css
@@@ -2531,7 +2531,11 @@ body.bc-studio-on #bc-stats-overlay.bc-stats-overlay {
body.bc-studio-on .messenger-toggle,
body.bc-studio-on .bc-mobile-chat-fab,
body.bc-studio-on #navbar-mount,
--body.bc-studio-on .navbar { display: none !important; }
++body.bc-studio-on .navbar,
++body.bc-studio-on .ovnav-appbar { display: none !important; }
++/* The app's bottom bar (openvibe-shared appbar.js) sat over the studio's rail and cut off its last buttons (moosely,
++ 2026-10-31): the studio is a full screen of its own, as a camera app is. */
++html:has(body.bc-studio-on) { --ovnav-appbar-h: 0px; }
/* The stream chat over the picture: newest at the bottom, fading out upward, input at the very bottom. */
body.bc-studio-on #bc-chat-sidebar {
@@@ -2559,7 +2563,8 @@ body.bc-studio-on #bc-chat-messages .chat-msg {
}
/* Site update cards and chat connection lines are for viewers; a streamer on the camera gets neither (the card's
reload button would end the stream, and the top bar already shows the connection). */
--body.bc-studio-on #bc-chat-messages .chat-deploy { display: none !important; }
++body.bc-studio-on #bc-chat-messages .chat-deploy,
++body.bc-studio-on #bc-chat-messages .chat-conn-note { display: none !important; }
body.bc-studio-on #bc-chat-sidebar .chat-input-area { background: none; border: 0; padding: 4px 10px 10px; }
body.bc-studio-on #bc-chat-input {
background: rgba(0,0,0,0.45); color: #fff; border: 1px solid rgba(255,255,255,0.2); border-radius: 22px;
ddiff --git a/public/css/features/channel.css b/public/css/features/channel.css
iindex 7e2e312f..ce2fc183 100644
---- a/public/css/features/channel.css
++++ b/public/css/features/channel.css
@@@ -1630,3 +1630,104 @@ button.viewer-badge:focus-visible { outline: 2px solid #93c5fd; outline-offset:
.ch-next-t { min-width: 0; color: var(--text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%; }
.ch-next-a, .ch-next-more { margin-left: auto; font-weight: 700; text-decoration: none; color: var(--accent-light, var(--accent)); white-space: nowrap; }
.ch-next-more { margin-left: 0; font-weight: 600; color: var(--text-muted); }
++
++/* ── Watching on a phone (2026-10-31) ─────────────────────────────────────────────────────────────────────────────
++ The owner, watching moosely on a phone: the app's bottom bar covered the stream's round chat button, its own "Chat"
++ opened the network inbox instead of this stream's chat, and a portrait stream sat in a box of blurred bands. So on a
++ phone held upright, while a stream is live:
++ - the app bar steps aside (as a native app's tab bar does on a watch screen), and so do the floating buttons;
++ - the stream's chat is docked under the player, always open, with no button to find. Nothing floats;
++ - a portrait stream fills the screen and the chat runs over its lower part, with the channel line over its top.
++ Turning the phone on its side keeps the full-screen player above (channel.css, 2026-10-25). */
++@media (max-width: 768px) and (orientation: portrait) {
++ html:has(#page-channel.active.ch-is-live) { --ovnav-appbar-h: 0px; }
++ html:has(#page-channel.active.ch-is-live) .ovnav-appbar,
++ body:has(#page-channel.active.ch-is-live) :is(.mobile-chat-toggle, #messenger-toggle, #floating-chat-fab) { display: none !important; }
++
++ #page-channel.ch-is-live .stream-layout {
++ display: flex; flex-direction: column; overflow: hidden;
++ height: calc(100dvh - var(--navbar-h) - var(--live-tabs-h, 0px));
++ }
++ #page-channel.ch-is-live.has-live-tabs .stream-layout { --live-tabs-h: 40px; }
++ #page-channel.ch-is-live .stream-main { flex: 0 0 auto; overflow: visible; min-height: 0; }
++
++ /* The chat: in the page, under the player, taking what is left. */
++ #page-channel.ch-is-live.page .stream-layout > .chat-sidebar {
++ position: relative; inset: auto; z-index: auto;
++ width: 100%; height: auto; max-height: none; flex: 1 1 0; min-height: 0;
++ transform: none; transition: none; border-radius: 0; box-shadow: none;
++ border: 0; border-top: 1px solid var(--border);
++ }
++ #page-channel.ch-is-live .chat-sidebar > .chat-grip,
++ #page-channel.ch-is-live .chat-sidebar .mobile-chat-close,
++ #page-channel.ch-is-live .chat-sidebar .chat-header::before { display: none !important; }
++ #page-channel.ch-is-live .chat-sidebar .chat-header { position: static; padding: 4px 8px 4px 12px; min-height: 0; font-size: .72rem; }
++ #page-channel.ch-is-live .chat-sidebar .chat-header .chat-settings-btn { min-width: 36px; min-height: 36px; }
++ #page-channel.ch-is-live .chat-sidebar .chat-header .chat-settings-btn[onclick^="popoutStreamChat"] { display: none; }
++ #page-channel.ch-is-live .chat-sidebar .chat-messages { padding: 6px 12px; }
++ #page-channel.ch-is-live .chat-sidebar .chat-input-area { padding: 6px 8px calc(6px + env(safe-area-inset-bottom, 0px)); }
++
++ /* The channel line: one row (who, the title, follow). Tapping it opens the rest (the AI line, the description,
++ the totals); app-channel.js toggles .ch-info-open. */
++ #page-channel.ch-is-live .ch-info-bar { flex-wrap: nowrap; align-items: center; gap: 10px; padding: 8px 12px; }
++ #page-channel.ch-is-live .ch-info-bar-left { flex: 1 1 auto; min-width: 0; cursor: pointer; }
++ #page-channel.ch-is-live .ch-info-bar-text { min-width: 0; }
++ #page-channel.ch-is-live .ch-info-bar-top { flex-wrap: nowrap; min-width: 0; overflow: hidden; }
++ #page-channel.ch-is-live .ch-info-bar-top h2 { font-size: .95rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
++ #page-channel.ch-is-live .ch-info-bar-top :is(.ch-username, #ch-protocol-badge) { display: none; }
++ #page-channel.ch-is-live .ch-stream-title { margin: 1px 0 0; font-size: .8rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
++ #page-channel.ch-is-live .ch-info-bar-right { width: auto; flex: 0 0 auto; gap: 6px; }
++ #page-channel.ch-is-live .ch-info-bar-right :is(.ch-follower-count, #btn-cam, .ch-game-badge) { display: none; }
++ #page-channel.ch-is-live .ch-info-bar:not(.ch-info-open) :is(.ch-stream-description, .ch-stream-ai, .ch-cumulative-viewers) { display: none !important; }
++ #page-channel.ch-is-live .ch-info-bar:not(.ch-info-open) ~ .ch-weather-widget { display: none !important; }
++ #page-channel.ch-is-live .ch-info-bar.ch-info-open { flex-wrap: wrap; }
++ #page-channel.ch-is-live .ch-info-bar.ch-info-open .ch-stream-title { white-space: normal; }
++ #page-channel.ch-is-live .ch-info-bar.ch-info-open + .ch-weather-widget,
++ #page-channel.ch-is-live .ch-info-bar.ch-info-open .ch-info-bar-text { max-height: 40dvh; overflow-y: auto; }
++
++ /* ── A portrait stream: the picture is the screen ── */
++ #page-channel.ch-is-live .stream-layout:has(.video-container.is-vertical) { position: relative; background: #000; }
++ #page-channel.ch-is-live .stream-layout:has(.video-container.is-vertical) > .stream-main { position: static; }
++ #page-channel.ch-is-live .stream-layout:has(.video-container.is-vertical) .video-container.is-vertical {
++ position: absolute; inset: 0; height: auto; max-height: none; aspect-ratio: auto; border-radius: 0; box-shadow: none;
++ }
++ /* A phone-shaped picture (9:16 or taller) fills the screen edge to edge, as phone video apps show it; a squarer
++ portrait one keeps its whole picture. */
++ #page-channel.ch-is-live .video-container.is-tall :is(#video-element, #video-canvas) { object-fit: cover; }
++ /* The channel line over the top of the picture, on a shade so it reads on any picture and in either theme. */
++ #page-channel.ch-is-live .stream-layout:has(.video-container.is-vertical) .ch-info-bar {
++ position: absolute; top: 0; left: 0; right: 0; z-index: 8;
++ padding: 10px 12px 22px; border: 0;
++ background: linear-gradient(to bottom, rgba(0, 0, 0, .62), rgba(0, 0, 0, .3) 65%, transparent);
++ color: #fff; text-shadow: 0 1px 2px rgba(0, 0, 0, .55);
++ }
++ #page-channel.ch-is-live .stream-layout:has(.video-container.is-vertical) .ch-info-bar :is(h2, .ch-stream-title, .ch-stream-description, .ch-cumulative-viewers) { color: #fff; }
++ #page-channel.ch-is-live .stream-layout:has(.video-container.is-vertical) .ch-info-bar.ch-info-open { background: rgba(0, 0, 0, .72); }
++ #page-channel.ch-is-live .stream-layout:has(.video-container.is-vertical) .ch-weather-widget { display: none !important; }
++ /* The player's own controls move under the channel line: the chat holds the bottom. */
++ #page-channel.ch-is-live .stream-layout:has(.video-container.is-vertical) .video-overlay {
++ top: 58px; bottom: auto; background: linear-gradient(to bottom, rgba(0, 0, 0, .45), transparent);
++ }
++ #page-channel.ch-is-live .stream-layout:has(.video-container.is-vertical) .dvr-progress-wrap { order: 2; }
++ /* The chat over the lower part of the picture: no box, the messages fade out upwards, and the input sits on a
++ shade at the bottom edge. The right side stays clear so the picture is not covered edge to edge. */
++ #page-channel.ch-is-live.page .stream-layout:has(.video-container.is-vertical) > .chat-sidebar {
++ position: absolute; left: 0; right: 0; bottom: 0; z-index: 7; flex: none;
++ height: var(--watch-chat-h, 46%); border: 0;
++ background: linear-gradient(to top, rgba(0, 0, 0, .66), rgba(0, 0, 0, .34) 55%, transparent);
++ color: #fff;
++ }
++ #page-channel.ch-is-live .stream-layout:has(.video-container.is-vertical) > .chat-sidebar .chat-header { display: none; }
++ #page-channel.ch-is-live .stream-layout:has(.video-container.is-vertical) > .chat-sidebar .chat-messages {
++ margin-right: 16%; padding-top: 28px; scrollbar-width: none;
++ -webkit-mask-image: linear-gradient(to bottom, transparent, #000 34%); mask-image: linear-gradient(to bottom, transparent, #000 34%);
++ }
++ #page-channel.ch-is-live .stream-layout:has(.video-container.is-vertical) > .chat-sidebar .chat-messages::-webkit-scrollbar { display: none; }
++ #page-channel.ch-is-live .stream-layout:has(.video-container.is-vertical) > .chat-sidebar .chat-msg { color: #fff; text-shadow: 0 1px 2px rgba(0, 0, 0, .8); }
++ #page-channel.ch-is-live .stream-layout:has(.video-container.is-vertical) > .chat-sidebar .chat-conn-note { display: none; }
++ #page-channel.ch-is-live .stream-layout:has(.video-container.is-vertical) > .chat-sidebar .chat-input-area { border-top: 0; background: transparent; }
++ #page-channel.ch-is-live .stream-layout:has(.video-container.is-vertical) > .chat-sidebar :is(.chat-input, .chat-input-row input, .chat-input-row textarea) {
++ background: rgba(0, 0, 0, .45); color: #fff; border-color: rgba(255, 255, 255, .22);
++ }
++ #page-channel.ch-is-live .stream-layout:has(.video-container.is-vertical) > .chat-sidebar :is(.emote-picker-btn, .chat-tts-toggle) { color: #fff; }
++}
ddiff --git a/public/js/app-channel.js b/public/js/app-channel.js
iindex 35be9830..d2bc3265 100644
---- a/public/js/app-channel.js
++++ b/public/js/app-channel.js
@@@ -846,6 +846,7 @@ async function loadChannelPage(username, managedStreamRef = null, legacySessionI
// ── LIVE STATE ──
document.getElementById('ch-live-area').style.display = '';
document.getElementById('ch-offline-area').style.display = 'none';
++ _setChannelLive(true);
_channelFooter(true);
_placeBelowFold();
@@@ -928,6 +929,7 @@ async function loadChannelPage(username, managedStreamRef = null, legacySessionI
document.getElementById('ch-live-area').style.display = 'none';
document.getElementById('ch-offline-area').style.display = '';
_channelFooter(false);
++ _setChannelLive(false);
_wireCamButton();
_updateCamButton(null);
_placeBelowFold();
@@@ -2459,6 +2461,28 @@ async function loadChannelAiTimeline(username) {
}
}
++/** The page is showing a live stream. On a phone held upright this is the watch screen (channel.css "Watching on a
++ * phone"): the app bar steps aside, the chat is docked, and the channel line opens on a tap. */
++function _setChannelLive(live) {
++ const page = document.getElementById('page-channel');
++ if (!page) return;
++ page.classList.toggle('ch-is-live', !!live);
++ const bar = document.getElementById('ch-info-bar');
++ if (!bar) return;
++ bar.classList.remove('ch-info-open');
++ const left = bar.querySelector('.ch-info-bar-left');
++ if (!left || left._ovInfoTap) return;
++ left._ovInfoTap = true;
++ // A tap on the channel line shows or hides the rest of it (the AI line, the description, the totals). Only where
++ // the line is folded: on a wide screen it is always open, so the tap does nothing there.
++ left.addEventListener('click', (e) => {
++ if (e.target.closest('a, button, input, select, textarea')) return;
++ if (!page.classList.contains('ch-is-live') || !matchMedia('(max-width: 768px) and (orientation: portrait)').matches) return;
++ const open = bar.classList.toggle('ch-info-open');
++ left.setAttribute('aria-expanded', String(open));
++ });
++}
++
// The footer while the stream plays is the quiet one-line footer; an offline channel gets the whole footer, where someone
// looks for where to go next (owner, 2026-10-27). The shared footer's claim (openvibe-shared 4.15): it holds while the
// stream is live and is released when the stream ends or the page is left (public/js/footer-live.js keeps channels full).
ddiff --git a/public/js/chat.js b/public/js/chat.js
iindex 12bafb56..0257bd30 100644
---- a/public/js/chat.js
++++ b/public/js/chat.js
@@@ -1647,7 +1647,7 @@ function initChat(streamId, channelUserId = null) {
}));
ws._authToken = token || null;
_chatReconnectDelay = CHAT_RECONNECT_BASE; // reset backoff on success
-- addRichSystemMessage('<i class="fa-solid fa-plug-circle-check" style="margin-right:5px;opacity:0.8"></i> Connected to chat');
++ addRichSystemMessage('<i class="fa-solid fa-plug-circle-check" style="margin-right:5px;opacity:0.8"></i> Connected to chat', 'conn');
// A fresh socket after the router tore the last one down: the widget may hold rows from
// before the gap. Pull what it missed instead of waiting for someone to type.
_fcwRefreshIfRelevant('connect');
@@@ -2173,14 +2173,14 @@ function handleChatMessage(msg) {
const _quietAuth = _suppressNextAuthNotice;
_suppressNextAuthNotice = false;
if (msg.authenticated) {
-- if (!_quietAuth) addRichSystemMessage(`<i class="fa-solid fa-user-check" style="margin-right:5px;opacity:0.8"></i> Chatting as ${esc(msg.username)}`);
++ if (!_quietAuth) addRichSystemMessage(`<i class="fa-solid fa-user-check" style="margin-right:5px;opacity:0.8"></i> Chatting as ${esc(msg.username)}`, 'conn');
} else {
// Server didn't authenticate us — show as anon
const hasToken = !!localStorage.getItem('token');
if (hasToken) {
// We think we're logged in but the server rejected the token —
// token may be expired/invalid. Reconnect chat after loadUser refreshes auth.
-- if (!_quietAuth) addRichSystemMessage(`<i class="fa-solid fa-user" style="margin-right:5px;opacity:0.8"></i> Chatting as ${esc(msg.username)} (not logged in)`);
++ if (!_quietAuth) addRichSystemMessage(`<i class="fa-solid fa-user" style="margin-right:5px;opacity:0.8"></i> Chatting as ${esc(msg.username)} (not logged in)`, 'conn');
console.warn('[Chat] Token rejected by server — will attempt re-auth');
// Re-validate token; if still valid, reconnect chat with fresh state
if (typeof loadUser === 'function') {
@@@ -2199,7 +2199,7 @@ function handleChatMessage(msg) {
}).catch(() => { });
}
} else {
-- if (!_quietAuth) addRichSystemMessage(`<i class="fa-solid fa-user-check" style="margin-right:5px;opacity:0.8"></i> Chatting as ${esc(msg.username)}`);
++ if (!_quietAuth) addRichSystemMessage(`<i class="fa-solid fa-user-check" style="margin-right:5px;opacity:0.8"></i> Chatting as ${esc(msg.username)}`, 'conn');
}
}
// Sync slow mode state from server on join
@@@ -2977,6 +2977,9 @@ function addRichSystemMessage(html, style = 'info') {
el.className = 'chat-msg system';
if (style === 'warning') el.style.color = 'var(--warning, #f59e0b)';
else if (style === 'update') el.style.color = 'var(--accent)';
++ // "Connected to chat" and "Chatting as …": chat over a picture (the phone studio, a phone watching a portrait
++ // stream) leaves them out.
++ else if (style === 'conn') el.classList.add('chat-conn-note');
el.innerHTML = html;
container.appendChild(el);
scrollChat();
ddiff --git a/public/js/player-shape.js b/public/js/player-shape.js
iindex c47d883b..85aa7237 100644
---- a/public/js/player-shape.js
++++ b/public/js/player-shape.js
@@@ -52,6 +52,7 @@
box.style.setProperty('--video-ar', ratio.toFixed(4));
box.dataset.shape = ratio < 0.95 ? 'portrait' : ratio > 1.05 ? 'landscape' : 'square';
box.classList.toggle('is-vertical', ratio < 0.95); // the chat sidebar's width on a desktop keys on it
++ box.classList.toggle('is-tall', ratio < 0.66); // a phone's own 9:16 or taller: it may fill a phone screen
if (st.glow) st.glow.check();
}
ddiff --git a/test/channel-phone-watch.test.js b/test/channel-phone-watch.test.js
nnew file mode 100644
iindex 00000000..fdf2ff5b
---- /dev/null
++++ b/test/channel-phone-watch.test.js
@@@ -0,0 +1,49 @@
++'use strict';
++// Watching on a phone (owner, 2026-10-31): the app's bottom bar covered the stream's chat button and its own "Chat" went
++// to the inbox, and the phone studio's rail ran under the same bar. On a phone held upright, a live channel is a watch
++// screen: no app bar, no floating buttons, the stream's chat docked under the player (over a portrait picture), and
++// the studio is a full screen of its own.
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const read = (f) => fs.readFileSync(path.join(__dirname, '..', f), 'utf8');
++
++const css = read('public/css/features/channel.css');
++const block = css.slice(css.indexOf('Watching on a phone (2026-10-31)'));
++assert.ok(block.length > 200, 'the watch block is in channel.css');
++const phone = block.slice(block.indexOf('@media (max-width: 768px) and (orientation: portrait)'));
++assert.ok(phone.startsWith('@media (max-width: 768px) and (orientation: portrait)'), 'only on a phone held upright');
++
++// The app bar and every floating button step aside while a stream is live on the open channel page.
++assert.match(phone, /html:has\(#page-channel\.active\.ch-is-live\) \{ --ovnav-appbar-h: 0px; \}/, 'the page keeps no room for the bar');
++assert.match(phone, /html:has\(#page-channel\.active\.ch-is-live\) \.ovnav-appbar,/, 'the bar is hidden');
++assert.match(phone, /:is\(\.mobile-chat-toggle, #messenger-toggle, #floating-chat-fab\) \{ display: none !important; \}/, 'no floating buttons');
++
++// The chat is in the page under the player, taking what is left: not a sheet behind a button.
++assert.match(phone, /#page-channel\.ch-is-live\.page \.stream-layout > \.chat-sidebar \{[^}]*position: relative;[^}]*flex: 1 1 0;[^}]*transform: none;/, 'the chat is docked');
++// A portrait stream fills the screen; the chat runs over its lower part and the channel line over its top.
++assert.match(phone, /\.stream-layout:has\(\.video-container\.is-vertical\) \.video-container\.is-vertical \{[^}]*position: absolute; inset: 0;/, 'a portrait picture fills the screen');
++assert.match(phone, /\.stream-layout:has\(\.video-container\.is-vertical\) > \.chat-sidebar \{[^}]*position: absolute;[^}]*bottom: 0;/, 'the chat over its lower part');
++assert.match(phone, /\.video-container\.is-tall :is\(#video-element, #video-canvas\) \{ object-fit: cover; \}/, 'a 9:16 picture is not letterboxed');
++
++// The live state is marked on the page in both directions, and the channel line opens on a tap.
++const app = read('public/js/app-channel.js');
++assert.match(app, /\/\/ ── LIVE STATE ──[\s\S]{0,300}_setChannelLive\(true\);/, 'live marks the page');
++assert.match(app, /getElementById\('ch-offline-area'\)\.style\.display = '';\n\s*_channelFooter\(false\);\n\s*_setChannelLive\(false\);/, 'offline unmarks it');
++assert.match(app, /page\.classList\.toggle\('ch-is-live', !!live\)/);
++assert.match(app, /bar\.classList\.toggle\('ch-info-open'\)/, 'a tap opens the channel line');
++
++// The player says when a picture is phone-shaped.
++assert.match(read('public/js/player-shape.js'), /box\.classList\.toggle\('is-tall', ratio < 0\.66\)/);
++
++// The studio: no app bar over its rail, and no connection notices over the camera.
++const bc = read('public/css/features/broadcast.css');
++assert.match(bc, /body\.bc-studio-on \.ovnav-appbar \{ display: none !important; \}/, 'the studio hides the app bar');
++assert.match(bc, /html:has\(body\.bc-studio-on\) \{ --ovnav-appbar-h: 0px; \}/);
++assert.match(bc, /body\.bc-studio-on #bc-chat-messages \.chat-conn-note \{ display: none !important; \}/);
++const chat = read('public/js/chat.js');
++assert.match(chat, /else if \(style === 'conn'\) el\.classList\.add\('chat-conn-note'\);/);
++assert.strictEqual((chat.match(/Chatting as \$\{esc\(msg\.username\)\}[^`]*`, 'conn'\)/g) || []).length, 3, 'every "Chatting as" notice is a connection note');
++assert.match(chat, /Connected to chat', 'conn'\)/);
++
++console.log('channel: a phone watching a live stream gets a watch screen (no app bar, chat docked), and the studio is full screen');