openvibers/openvibe.live Commits

Fixes from the review of today's Live commits (an independent DeepSeek review).

OP OpenVibers committed 29 Sept 2026 · a98aae4806 · 11 files, +59 −29

- Go Live: saving a stream slot failed with "Invalid category" for everyone: the page still sent category '' after the picker went. It no longer sends a category, and the API treats an empty or null category as Auto (the AI decides). The dead last-category restore is gone.
- Social links: the X timeline frame loses allow-same-origin (it is served from this origin, so that flag handed X's script our cookies, storage and DOM); a Mastodon handle (@user@instance) becomes a profile link instead of being dropped silently; one IntersectionObserver per links block instead of a new one per render.
- Translation: auto-translate batches are split so no joined request exceeds translate()'s 1,200-character input cap (a busy chat's batch came back untranslated); a channel's language is inferred only from non-Latin scripts (one French line in an English bio no longer made every English line a model call; a Latin-script channel language is the explicit chat_language setting); one Latin word is never confidently foreign.
- OpenRe mirror: a session OpenRe no longer knows (the SDK answers null for a 404) is marked failed instead of throwing, so Live takes its restream and recording back.
- Channel chat: vertical streams keep their wider chat column; hide/show chat exists only where the chat is a column (1181px+), not beside the tablet overlay; the bottom-sheet grip covers the handle only, not the header buttons; toggleChannelChat is a stub of the channel feature (the fragment's buttons work before its script runs).
- Clip tabs: the intro lines are sentences again (as a flex row the Clips Taken text split into columns on phones) and read plainly: "Moments viewers clipped from these streams." / "Clips this streamer made from other channels' streams."

npm test 151/152, 1 skipped.

Changes

ddiff --git a/public/css/features/channel.css b/public/css/features/channel.css
iindex 906f01e4..dc77abac 100644
---- a/public/css/features/channel.css
++++ b/public/css/features/channel.css
@@@ -1212,7 +1212,9 @@
.ch-panel-bar > .sel-all-btn,
.ch-panel-bar > .ch-analytics-period-btns { margin-left: auto; }
.ch-panel-bar:not(:has(> :not([style*="display:none"]):not([style*="display: none"]))) { margin: 0; }
--.ch-panel-hint { margin: 14px 0 12px; color: var(--text-muted); font-size: 0.85rem; display: flex; align-items: center; gap: 7px; line-height: 1.4; }
++/* A sentence, not a flex row: as flex items its words and <b> wrapped into separate columns on phones. */
++.ch-panel-hint { margin: 12px 0; color: var(--text-muted); font-size: 0.85rem; line-height: 1.45; }
++.ch-panel-hint > i:first-child { margin-right: 6px; }
.ai-tl-overview-card {
margin: 6px 0 14px; padding: 16px 18px; border-radius: 14px;
background: linear-gradient(135deg, color-mix(in srgb, var(--accent, #8b5cf6) 14%, transparent), color-mix(in srgb, var(--accent, #8b5cf6) 4%, transparent));
@@@ -1463,7 +1465,7 @@ body.controls-docked .messenger-toggle { z-index: 1100; }
/* ── Hide / show the chat (desktop) ─────────────────────────── */
.chat-header .ch-chat-collapse { margin-right: 2px; }
--@media (min-width: 769px) {
++@media (min-width: 1181px) {
#page-channel.chat-collapsed .stream-layout > .chat-sidebar { display: none !important; }
#page-channel.chat-revealing .stream-layout > .chat-sidebar { animation: chChatReveal .32s cubic-bezier(.22,1,.36,1); }
}
@@@ -1479,12 +1481,14 @@ body.controls-docked .messenger-toggle { z-index: 1100; }
.ch-chat-reopen:hover { color: var(--text-primary); border-color: var(--accent); padding-right: 16px; }
.ch-chat-reopen .fa-comments { color: var(--accent); }
.ch-chat-reopen[hidden] { display: none; }
--@media (max-width: 768px) { .ch-chat-collapse, .ch-chat-reopen { display: none !important; } }
++/* Below 1181px the chat is an overlay or a bottom sheet with its own open/close button, so no hide/show here. */
++@media (max-width: 1180px) { .ch-chat-collapse, .ch-chat-reopen { display: none !important; } }
@media (prefers-reduced-motion: reduce) { #page-channel.chat-revealing .stream-layout > .chat-sidebar { animation: none; } }
/* ── Resizing the chat (public/js/channel-chat-resize.js) ─────
One grip per chat: on its left edge beside the stream (width), on its top edge as a bottom sheet (height). */
--#page-channel .stream-layout > .chat-sidebar { width: var(--chat-w); }
++/* Not for vertical (9:16) streams: their wider chat column (clamp(340px, 35vw, 500px), above) must win. */
++#page-channel .stream-layout:not(:has(.video-container.is-vertical)) > .chat-sidebar { width: var(--chat-w); }
@media (max-width: 1180px) and (orientation: landscape) { #page-channel .stream-layout > .chat-sidebar { width: var(--chat-ow, min(420px, 44vw)); } }
@media (max-width: 1180px) and (orientation: portrait) { #page-channel .stream-layout > .chat-sidebar { width: 100%; height: var(--chat-sheet-h, 55dvh); max-height: 92dvh; } }
#page-channel.chat-resizing, #page-channel.chat-resizing * { user-select: none; }
@@@ -1501,8 +1505,8 @@ body.controls-docked .messenger-toggle { z-index: 1100; }
.chat-grip:hover::after, .chat-grip.dragging::after, .chat-grip:focus-visible::after { opacity: 1; background: var(--accent); }
}
@media (max-width: 1180px) and (orientation: portrait) {
-- .chat-grip { top: 0; left: 0; right: 0; height: 24px; cursor: row-resize; }
-- .chat-grip::after { left: 50%; top: 8px; width: 44px; height: 5px; margin-left: -22px; border-radius: 5px; background: color-mix(in srgb, var(--text-muted) 60%, transparent); }
++ .chat-grip { top: 0; left: 50%; width: 120px; margin-left: -60px; height: 18px; cursor: row-resize; } /* the handle only: the header buttons stay tappable */
++ .chat-grip::after { left: 50%; top: 7px; width: 44px; height: 5px; margin-left: -22px; border-radius: 5px; background: color-mix(in srgb, var(--text-muted) 60%, transparent); }
.chat-grip:hover::after, .chat-grip.dragging::after, .chat-grip:focus-visible::after { background: var(--accent); }
/* The grip draws the sheet's handle now (the header's own pill was only a picture of one). */
#page-channel .chat-sidebar .chat-header::before { display: none; }
ddiff --git a/public/features.json b/public/features.json
iindex 496b09e8..29ce051a 100644
---- a/public/features.json
++++ b/public/features.json
@@@ -119,6 +119,9 @@
"/js/channel-socials.js",
"/js/channel-chat-resize.js",
"/js/app-channel.js"
++ ],
++ "stubs": [
++ "toggleChannelChat"
]
},
"vodPage": {
ddiff --git a/public/fragments/channel.html b/public/fragments/channel.html
iindex 67d84b78..654f51b9 100644
---- a/public/fragments/channel.html
++++ b/public/fragments/channel.html
@@@ -378,7 +378,7 @@
<!-- Clips (clips OF this streamer's streams) -->
<div class="ch-tab-panel" id="ch-panel-clips" role="tabpanel">
-- <p class="ch-panel-hint" id="ch-clips-of-header"><i class="fa-solid fa-scissors"></i> Clips people took <b>of</b> this streamer's streams.</p>
++ <p class="ch-panel-hint" id="ch-clips-of-header"><i class="fa-solid fa-scissors"></i> Moments viewers clipped from these streams.</p>
<div class="stream-grid" id="ch-clips-of-grid"><p class="muted">No clips yet</p></div>
<div id="ch-clips-of-pagination" class="pastes-pagination" style="display:none"></div>
<!-- What the AI cut from these streams, after what people made (roadmap 33.6). -->
@@@ -391,7 +391,7 @@
<!-- Clips Taken (clips this streamer made of others) -->
<div class="ch-tab-panel" id="ch-panel-clips-taken" role="tabpanel">
-- <p class="ch-panel-hint"><i class="fa-solid fa-film"></i> Clips <b>this streamer</b> has taken — clipped from other streamers' content.</p>
++ <p class="ch-panel-hint"><i class="fa-solid fa-film"></i> Clips this streamer made from other channels' streams.</p>
<div class="ch-panel-bar">
<div class="clips-taken-filters" id="clips-taken-filters" style="display:none"></div>
<button class="btn btn-small btn-outline sel-all-btn" data-sel-container="ch-clips-grid" style="display:none" onclick="_selAllToggle(this.dataset.selContainer)"><i class="fa-solid fa-check-double"></i> <span class="sel-all-label">Select all</span></button>
ddiff --git a/public/js/broadcast-workspace.js b/public/js/broadcast-workspace.js
iindex 5dbca000..ec7c76f1 100644
---- a/public/js/broadcast-workspace.js
++++ b/public/js/broadcast-workspace.js
@@@ -1936,7 +1936,6 @@ async function _wsSaveAll() {
const streamFields = {
title: document.getElementById('bc-title')?.value.trim() || ms.title,
description: document.getElementById('bc-description')?.value.trim() || '',
-- category: document.getElementById('bc-category')?.value || '',
is_nsfw: document.getElementById('bc-nsfw')?.checked ? 1 : 0,
protocol: ms.protocol,
streaming_method: ms.streaming_method || 'browser',
ddiff --git a/public/js/broadcast.js b/public/js/broadcast.js
iindex 5db4f0ad..e96220f7 100644
---- a/public/js/broadcast.js
++++ b/public/js/broadcast.js
@@@ -928,15 +928,12 @@ function showStreamManager() {
const info = document.getElementById('bc-info-bar'); if (info) info.style.display = 'none';
}
--/** Pre-fill broadcast form with last-used title/description/category from localStorage */
++/** Pre-fill broadcast form with last-used title/description from localStorage */
function _restoreLastBroadcastFields() {
const titleEl = document.getElementById('bc-title');
if (titleEl && !titleEl.value) titleEl.value = localStorage.getItem('bc-last-title') || '';
const descEl = document.getElementById('bc-description');
if (descEl && !descEl.value) descEl.value = localStorage.getItem('bc-last-description') || '';
-- const catEl = document.getElementById('bc-category');
-- const lastCat = localStorage.getItem('bc-last-category');
-- if (catEl && lastCat) catEl.value = lastCat;
// Populate username in create reassurance text
const usernameEl = document.getElementById('bc-create-username');
if (usernameEl && currentUser?.username) usernameEl.textContent = currentUser.username;
@@@ -1500,7 +1497,6 @@ async function loadBroadcastControlConfigs() {
async function createNewStream() {
const title = document.getElementById('bc-title')?.value.trim();
const description = document.getElementById('bc-description')?.value.trim() || '';
-- const category = document.getElementById('bc-category')?.value || '';
const method = broadcastState.selectedMethod;
if (!title) return toast('Stream title is required', 'error');
@@@ -1522,7 +1518,6 @@ async function createNewStream() {
// Persist last-used values so they pre-fill next time
localStorage.setItem('bc-last-title', title);
localStorage.setItem('bc-last-description', description);
-- localStorage.setItem('bc-last-category', category);
// Restore existing-streams panel visibility for next time
const existingEl = document.getElementById('bc-existing-streams');
@@@ -1572,7 +1567,6 @@ async function createNewStream() {
title,
description,
protocol: method,
-- category,
is_nsfw: document.getElementById('bc-nsfw')?.checked || false,
control_config_id: controlConfigId,
managed_stream_id: managedStreamId,
ddiff --git a/public/js/channel-socials.js b/public/js/channel-socials.js
iindex 7554d06d..0c8e3083 100644
---- a/public/js/channel-socials.js
++++ b/public/js/channel-socials.js
@@@ -45,8 +45,9 @@
.then((r) => (r.ok ? r.json() : null)).then((p) => fill(card, links[i], p)).catch(() => {});
};
const all = host.querySelectorAll('.ch-social-card');
++ if (host._ovIo) host._ovIo.disconnect(); // one observer per host: a re-render replaces it
if ('IntersectionObserver' in window) {
-- const io = new IntersectionObserver((ents) => { for (const e of ents) if (e.isIntersecting) { io.unobserve(e.target); load(e.target); } }, { rootMargin: '300px' });
++ const io = host._ovIo = new IntersectionObserver((ents) => { for (const e of ents) if (e.isIntersecting) { io.unobserve(e.target); load(e.target); } }, { rootMargin: '300px' });
all.forEach((c) => io.observe(c));
} else all.forEach(load);
}
@@@ -74,7 +75,9 @@
const f = document.createElement('iframe');
f.className = 'ch-social-embed';
f.src = p.embed; f.loading = 'lazy'; f.title = `Posts by ${label(link)} on X`;
-- f.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox');
++ // No allow-same-origin: the frame is served from this origin, so that flag would hand X's script our
++ // cookies, storage and DOM. Scripts and popups are all the public timeline needs.
++ f.setAttribute('sandbox', 'allow-scripts allow-popups allow-popups-to-escape-sandbox');
btn.replaceWith(f);
});
}
ddiff --git a/public/js/lang-detect.js b/public/js/lang-detect.js
iindex 70c734e1..02936357 100644
---- a/public/js/lang-detect.js
++++ b/public/js/lang-detect.js
@@@ -80,6 +80,8 @@
if (a >= 2 && a >= b * 2) confidence = 0.9;
else if (a >= 1 && b === 0 && words.length <= 4) confidence = 0.65;
else if (a > b) confidence = 0.55;
++ // One Latin word ("gracias", "merci") is too little to call a language: never above the 0.6 threshold.
++ if (words.length === 1) confidence = Math.min(confidence, 0.5);
return { lang: best, confidence };
}
/** The language of `text` when it is confidently not `me` (a 2-letter code), else null. */
ddiff --git a/server/i18n/translate.js b/server/i18n/translate.js
iindex 22e0b311..3dd38aa1 100644
---- a/server/i18n/translate.js
++++ b/server/i18n/translate.js
@@@ -57,11 +57,14 @@ function detectLang(text) {
* detection says 'en'; per line, the Japanese line wins — which is the right answer for
* "what language does this streamer live in".
*/
++// A channel's language is inferred only from non-Latin scripts: one French line in an English bio must not make every
++// English chat line a model call. A Latin-script channel language is the streamer's explicit chat_language setting.
++const SCRIPT_LANGS = new Set(['ja', 'ko', 'zh', 'ru', 'uk', 'ar', 'th', 'he', 'el', 'hi']);
function detectForeignInText(text) {
const lines = String(text || '').split(/[\n\r]+|(?<=[。!?.!?])\s+/);
for (const line of lines) {
const l = detectLang(line);
-- if (l && l !== 'en') return l;
++ if (l && SCRIPT_LANGS.has(l)) return l;
}
return null;
}
@@@ -96,7 +99,7 @@ function channelLanguage(userId) {
// clearly in another script.
const bioLang = detectForeignInText(u?.bio || '');
const nameLang = detectLang(`${u?.display_name || ''} ${ch?.title || ''}`);
-- lang = bioLang || ((nameLang && nameLang !== 'en') ? nameLang : 'en');
++ lang = bioLang || ((nameLang && SCRIPT_LANGS.has(nameLang)) ? nameLang : 'en');
}
} catch { lang = 'en'; }
_chanCache.set(id, { lang, at: Date.now() });
@@@ -256,12 +259,24 @@ async function translateMany(texts, { to = 'en', context = 'chat' } = {}) {
need.push({ i, t, from });
});
if (!need.length || !available()) return out;
-- const lines = await translateLines(need.map((n) => n.t), { from: 'auto', to, context });
-- need.forEach((n, k) => {
-- if (!lines[k]) return;
-- out[n.i] = { from: n.from, text: lines[k] };
-- remember(n.t, n.from, to, lines[k]);
-- });
++ // One model call per group whose joined text stays under the translate() input cap (1200 chars): a busy chat's
++ // batch of 20 lines used to exceed it, and the whole batch came back untranslated.
++ const groups = [];
++ let cur = [], len = 0;
++ for (const n of need) {
++ const l = n.t.length + 1;
++ if (cur.length && len + l > 1000) { groups.push(cur); cur = []; len = 0; }
++ cur.push(n); len += l;
++ }
++ if (cur.length) groups.push(cur);
++ for (const g of groups) {
++ const lines = await translateLines(g.map((n) => n.t), { from: 'auto', to, context });
++ g.forEach((n, k) => {
++ if (!lines[k]) return;
++ out[n.i] = { from: n.from, text: lines[k] };
++ remember(n.t, n.from, to, lines[k]);
++ });
++ }
return out;
}
ddiff --git a/server/openre/mirror.js b/server/openre/mirror.js
iindex bfee9876..21c74cea 100644
---- a/server/openre/mirror.js
++++ b/server/openre/mirror.js
@@@ -189,9 +189,12 @@ async function reconcileOnce() {
if (row && !row.is_live) { db.run("UPDATE openre_sessions SET state = 'detached', updated_at = CURRENT_TIMESTAMP WHERE session_id = ?", [r.session_id]); continue; }
let s;
try { s = await client.getSession(r.session_id); } catch (err) {
-- if (err.status === 404) s = { state: 'failed', revision: r.revision + 1, ended_at: null };
++ if (err.status === 404) s = null;
else continue; // OpenRe unreachable: keep the last known state (bounded by CONFIRM_WINDOW_MIN)
}
++ // A session OpenRe no longer knows (the SDK answers null for a 404; older clients threw) has failed: without this
++ // the row stayed 'live', ownsStream() stayed true and Live never took its own restream/recording back.
++ if (!s) s = { state: 'failed', revision: r.revision + 1, ended_at: null };
if (s.state === 'live' || s.state === 'starting') {
db.run("UPDATE openre_sessions SET confirmed_at = CURRENT_TIMESTAMP WHERE session_id = ?", [r.session_id]);
if (r.stream_id) db.run('UPDATE streams SET last_heartbeat = CURRENT_TIMESTAMP WHERE id = ? AND is_live = 1', [r.stream_id]);
ddiff --git a/server/social/links.js b/server/social/links.js
iindex 658c2724..e6f2862b 100644
---- a/server/social/links.js
++++ b/server/social/links.js
@@@ -57,6 +57,9 @@ function cleanLink(raw) {
let kind = String(raw.kind || '').toLowerCase();
let value = String(raw.url || raw.handle || '').trim().slice(0, 300);
if (!value) return null;
++ // A federated handle (@[email protected]) is a Mastodon profile wherever it is typed.
++ const fed = /^@?([A-Za-z0-9_]{1,30})@([A-Za-z0-9.-]+\.[A-Za-z]{2,})$/.exec(value);
++ if (fed && (kind === 'mastodon' || kind === 'custom' || kind === 'website' || !kind)) { kind = 'mastodon'; value = `https://${fed[2].toLowerCase()}/@${fed[1]}`; }
if (!/^https?:\/\//i.test(value)) {
const p = PLATFORMS[kind];
if (!p || !p.url) {
ddiff --git a/server/streaming/routes.js b/server/streaming/routes.js
iindex b3396a13..eccd8739 100644
---- a/server/streaming/routes.js
++++ b/server/streaming/routes.js
@@@ -1743,8 +1743,12 @@ router.put('/managed/:id', requireAuth, (req, res) => {
if (fields.description === null) return res.status(400).json({ error: 'Invalid description' });
}
if (hasOwn(req.body, 'category')) {
-- fields.category = cleanText(req.body.category, { maxLength: MAX_CATEGORY_LENGTH });
-- if (fields.category === null) return res.status(400).json({ error: 'Invalid category' });
++ // Empty or null = Auto (the AI decides from the stream; the Go Live page has no picker any more).
++ if (req.body.category == null || String(req.body.category).trim() === '') fields.category = null;
++ else {
++ fields.category = cleanText(req.body.category, { maxLength: MAX_CATEGORY_LENGTH });
++ if (fields.category === null) return res.status(400).json({ error: 'Invalid category' });
++ }
}
if (hasOwn(req.body, 'protocol')) {
fields.protocol = cleanProtocol(req.body.protocol);