openvibers/openvibe.live Commits
A phone watching a live stream gets a watch screen, and the studio drops the app bar (#468)
Changes
@@@ -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'sreload 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;@@@ -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; }++}@@@ -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, legacySessionIdocument.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).@@@ -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 anonconst 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 stateif (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();@@@ -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 screenif (st.glow) st.glow.check();}nnew file mode 100644@@@ -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');
@@ -2531,7 +2531,11 @@ body.bc-studio-on #bc-stats-overlay.bc-stats-overlay {
@@ -2531,7 +2531,11 @@ body.bc-studio-on #bc-stats-overlay.bc-stats-overlay {
body.bc-studio-on .messenger-toggle,
body.bc-studio-on .messenger-toggle,
body.bc-studio-on .bc-mobile-chat-fab,
body.bc-studio-on .bc-mobile-chat-fab,
body.bc-studio-on #navbar-mount,
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. */
/* 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 {
body.bc-studio-on #bc-chat-sidebar {
@@ -2559,7 +2563,8 @@ body.bc-studio-on #bc-chat-messages .chat-msg {
@@ -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
/* 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). */
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-sidebar .chat-input-area { background: none; border: 0; padding: 4px 10px 10px; }
body.bc-studio-on #bc-chat-input {
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;
background: rgba(0,0,0,0.45); color: #fff; border: 1px solid rgba(255,255,255,0.2); border-radius: 22px;
@@ -1630,3 +1630,104 @@ button.viewer-badge:focus-visible { outline: 2px solid #93c5fd; outline-offset:
@@ -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-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-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); }
.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; }
+}
@@ -846,6 +846,7 @@ async function loadChannelPage(username, managedStreamRef = null, legacySessionI
@@ -846,6 +846,7 @@ async function loadChannelPage(username, managedStreamRef = null, legacySessionI
// ── LIVE STATE ──
// ── LIVE STATE ──
document.getElementById('ch-live-area').style.display = '';
document.getElementById('ch-live-area').style.display = '';
document.getElementById('ch-offline-area').style.display = 'none';
document.getElementById('ch-offline-area').style.display = 'none';
+ _setChannelLive(true);
_channelFooter(true);
_channelFooter(true);
_placeBelowFold();
_placeBelowFold();
@@ -928,6 +929,7 @@ async function loadChannelPage(username, managedStreamRef = null, legacySessionI
@@ -928,6 +929,7 @@ async function loadChannelPage(username, managedStreamRef = null, legacySessionI
document.getElementById('ch-live-area').style.display = 'none';
document.getElementById('ch-live-area').style.display = 'none';
document.getElementById('ch-offline-area').style.display = '';
document.getElementById('ch-offline-area').style.display = '';
_channelFooter(false);
_channelFooter(false);
+ _setChannelLive(false);
_wireCamButton();
_wireCamButton();
_updateCamButton(null);
_updateCamButton(null);
_placeBelowFold();
_placeBelowFold();
@@ -2459,6 +2461,28 @@ async function loadChannelAiTimeline(username) {
@@ -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
// 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
// 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).
// stream is live and is released when the stream ends or the page is left (public/js/footer-live.js keeps channels full).
@@ -1647,7 +1647,7 @@ function initChat(streamId, channelUserId = null) {
@@ -1647,7 +1647,7 @@ function initChat(streamId, channelUserId = null) {
}));
}));
ws._authToken = token || null;
ws._authToken = token || null;
_chatReconnectDelay = CHAT_RECONNECT_BASE; // reset backoff on success
_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
// 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.
// before the gap. Pull what it missed instead of waiting for someone to type.
_fcwRefreshIfRelevant('connect');
_fcwRefreshIfRelevant('connect');
@@ -2173,14 +2173,14 @@ function handleChatMessage(msg) {
@@ -2173,14 +2173,14 @@ function handleChatMessage(msg) {
const _quietAuth = _suppressNextAuthNotice;
const _quietAuth = _suppressNextAuthNotice;
_suppressNextAuthNotice = false;
_suppressNextAuthNotice = false;
if (msg.authenticated) {
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 {
} else {
// Server didn't authenticate us — show as anon
// Server didn't authenticate us — show as anon
const hasToken = !!localStorage.getItem('token');
const hasToken = !!localStorage.getItem('token');
if (hasToken) {
if (hasToken) {
// We think we're logged in but the server rejected the token —
// We think we're logged in but the server rejected the token —
// token may be expired/invalid. Reconnect chat after loadUser refreshes auth.
// 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');
console.warn('[Chat] Token rejected by server — will attempt re-auth');
// Re-validate token; if still valid, reconnect chat with fresh state
// Re-validate token; if still valid, reconnect chat with fresh state
if (typeof loadUser === 'function') {
if (typeof loadUser === 'function') {
@@ -2199,7 +2199,7 @@ function handleChatMessage(msg) {
@@ -2199,7 +2199,7 @@ function handleChatMessage(msg) {
}).catch(() => { });
}).catch(() => { });
}
}
} else {
} 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
// Sync slow mode state from server on join
@@ -2977,6 +2977,9 @@ function addRichSystemMessage(html, style = 'info') {
@@ -2977,6 +2977,9 @@ function addRichSystemMessage(html, style = 'info') {
el.className = 'chat-msg system';
el.className = 'chat-msg system';
if (style === 'warning') el.style.color = 'var(--warning, #f59e0b)';
if (style === 'warning') el.style.color = 'var(--warning, #f59e0b)';
else if (style === 'update') el.style.color = 'var(--accent)';
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;
el.innerHTML = html;
container.appendChild(el);
container.appendChild(el);
scrollChat();
scrollChat();
@@ -52,6 +52,7 @@
@@ -52,6 +52,7 @@
box.style.setProperty('--video-ar', ratio.toFixed(4));
box.style.setProperty('--video-ar', ratio.toFixed(4));
box.dataset.shape = ratio < 0.95 ? 'portrait' : ratio > 1.05 ? 'landscape' : 'square';
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-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();
if (st.glow) st.glow.check();
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,49 @@
@@ -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');