openvibers/openvibe.live Commits
Fixes from the review of today's Live commits (an independent DeepSeek review).
Changes
@@@ -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; }@@@ -119,6 +119,9 @@"/js/channel-socials.js","/js/channel-chat-resize.js","/js/app-channel.js"++ ],++ "stubs": [++ "toggleChannelChat"]},"vodPage": {@@@ -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>@@@ -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',@@@ -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 textconst 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 timelocalStorage.setItem('bc-last-title', title);localStorage.setItem('bc-last-description', description);-- localStorage.setItem('bc-last-category', category);// Restore existing-streams panel visibility for next timeconst 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,@@@ -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 itif ('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);});}@@@ -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. */@@@ -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;}@@@ -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]);@@@ -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) {@@@ -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);
@@ -1212,7 +1212,9 @@
@@ -1212,7 +1212,9 @@
.ch-panel-bar > .sel-all-btn,
.ch-panel-bar > .sel-all-btn,
.ch-panel-bar > .ch-analytics-period-btns { margin-left: auto; }
.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-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 {
.ai-tl-overview-card {
margin: 6px 0 14px; padding: 16px 18px; border-radius: 14px;
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));
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; }
@@ -1463,7 +1465,7 @@ body.controls-docked .messenger-toggle { z-index: 1100; }
/* ── Hide / show the chat (desktop) ─────────────────────────── */
/* ── Hide / show the chat (desktop) ─────────────────────────── */
.chat-header .ch-chat-collapse { margin-right: 2px; }
.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-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); }
#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; }
@@ -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:hover { color: var(--text-primary); border-color: var(--accent); padding-right: 16px; }
.ch-chat-reopen .fa-comments { color: var(--accent); }
.ch-chat-reopen .fa-comments { color: var(--accent); }
.ch-chat-reopen[hidden] { display: none; }
.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; } }
@media (prefers-reduced-motion: reduce) { #page-channel.chat-revealing .stream-layout > .chat-sidebar { animation: none; } }
/* ── Resizing the chat (public/js/channel-chat-resize.js) ─────
/* ── 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). */
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: 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; } }
@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; }
#page-channel.chat-resizing, #page-channel.chat-resizing * { user-select: none; }
@@ -1501,8 +1505,8 @@ body.controls-docked .messenger-toggle { z-index: 1100; }
@@ -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); }
.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) {
@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); }
.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). */
/* 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; }
#page-channel .chat-sidebar .chat-header::before { display: none; }
@@ -119,6 +119,9 @@
@@ -119,6 +119,9 @@
"/js/channel-socials.js",
"/js/channel-socials.js",
"/js/channel-chat-resize.js",
"/js/channel-chat-resize.js",
"/js/app-channel.js"
"/js/app-channel.js"
+ ],
+ "stubs": [
+ "toggleChannelChat"
]
]
},
},
"vodPage": {
"vodPage": {
@@ -378,7 +378,7 @@
@@ -378,7 +378,7 @@
<!-- Clips (clips OF this streamer's streams) -->
<!-- Clips (clips OF this streamer's streams) -->
<div class="ch-tab-panel" id="ch-panel-clips" role="tabpanel">
<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 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>
<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). -->
<!-- What the AI cut from these streams, after what people made (roadmap 33.6). -->
@@ -391,7 +391,7 @@
@@ -391,7 +391,7 @@
<!-- Clips Taken (clips this streamer made of others) -->
<!-- Clips Taken (clips this streamer made of others) -->
<div class="ch-tab-panel" id="ch-panel-clips-taken" role="tabpanel">
<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="ch-panel-bar">
<div class="clips-taken-filters" id="clips-taken-filters" style="display:none"></div>
<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>
<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>
@@ -1936,7 +1936,6 @@ async function _wsSaveAll() {
@@ -1936,7 +1936,6 @@ async function _wsSaveAll() {
const streamFields = {
const streamFields = {
title: document.getElementById('bc-title')?.value.trim() || ms.title,
title: document.getElementById('bc-title')?.value.trim() || ms.title,
description: document.getElementById('bc-description')?.value.trim() || '',
description: document.getElementById('bc-description')?.value.trim() || '',
- category: document.getElementById('bc-category')?.value || '',
is_nsfw: document.getElementById('bc-nsfw')?.checked ? 1 : 0,
is_nsfw: document.getElementById('bc-nsfw')?.checked ? 1 : 0,
protocol: ms.protocol,
protocol: ms.protocol,
streaming_method: ms.streaming_method || 'browser',
streaming_method: ms.streaming_method || 'browser',
@@ -928,15 +928,12 @@ function showStreamManager() {
@@ -928,15 +928,12 @@ function showStreamManager() {
const info = document.getElementById('bc-info-bar'); if (info) info.style.display = 'none';
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() {
function _restoreLastBroadcastFields() {
const titleEl = document.getElementById('bc-title');
const titleEl = document.getElementById('bc-title');
if (titleEl && !titleEl.value) titleEl.value = localStorage.getItem('bc-last-title') || '';
if (titleEl && !titleEl.value) titleEl.value = localStorage.getItem('bc-last-title') || '';
const descEl = document.getElementById('bc-description');
const descEl = document.getElementById('bc-description');
if (descEl && !descEl.value) descEl.value = localStorage.getItem('bc-last-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
// Populate username in create reassurance text
const usernameEl = document.getElementById('bc-create-username');
const usernameEl = document.getElementById('bc-create-username');
if (usernameEl && currentUser?.username) usernameEl.textContent = currentUser.username;
if (usernameEl && currentUser?.username) usernameEl.textContent = currentUser.username;
@@ -1500,7 +1497,6 @@ async function loadBroadcastControlConfigs() {
@@ -1500,7 +1497,6 @@ async function loadBroadcastControlConfigs() {
async function createNewStream() {
async function createNewStream() {
const title = document.getElementById('bc-title')?.value.trim();
const title = document.getElementById('bc-title')?.value.trim();
const description = document.getElementById('bc-description')?.value.trim() || '';
const description = document.getElementById('bc-description')?.value.trim() || '';
- const category = document.getElementById('bc-category')?.value || '';
const method = broadcastState.selectedMethod;
const method = broadcastState.selectedMethod;
if (!title) return toast('Stream title is required', 'error');
if (!title) return toast('Stream title is required', 'error');
@@ -1522,7 +1518,6 @@ async function createNewStream() {
@@ -1522,7 +1518,6 @@ async function createNewStream() {
// Persist last-used values so they pre-fill next time
// Persist last-used values so they pre-fill next time
localStorage.setItem('bc-last-title', title);
localStorage.setItem('bc-last-title', title);
localStorage.setItem('bc-last-description', description);
localStorage.setItem('bc-last-description', description);
- localStorage.setItem('bc-last-category', category);
// Restore existing-streams panel visibility for next time
// Restore existing-streams panel visibility for next time
const existingEl = document.getElementById('bc-existing-streams');
const existingEl = document.getElementById('bc-existing-streams');
@@ -1572,7 +1567,6 @@ async function createNewStream() {
@@ -1572,7 +1567,6 @@ async function createNewStream() {
title,
title,
description,
description,
protocol: method,
protocol: method,
- category,
is_nsfw: document.getElementById('bc-nsfw')?.checked || false,
is_nsfw: document.getElementById('bc-nsfw')?.checked || false,
control_config_id: controlConfigId,
control_config_id: controlConfigId,
managed_stream_id: managedStreamId,
managed_stream_id: managedStreamId,
@@ -45,8 +45,9 @@
@@ -45,8 +45,9 @@
.then((r) => (r.ok ? r.json() : null)).then((p) => fill(card, links[i], p)).catch(() => {});
.then((r) => (r.ok ? r.json() : null)).then((p) => fill(card, links[i], p)).catch(() => {});
};
};
const all = host.querySelectorAll('.ch-social-card');
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) {
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));
all.forEach((c) => io.observe(c));
} else all.forEach(load);
} else all.forEach(load);
}
}
@@ -74,7 +75,9 @@
@@ -74,7 +75,9 @@
const f = document.createElement('iframe');
const f = document.createElement('iframe');
f.className = 'ch-social-embed';
f.className = 'ch-social-embed';
f.src = p.embed; f.loading = 'lazy'; f.title = `Posts by ${label(link)} on X`;
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);
btn.replaceWith(f);
});
});
}
}
@@ -80,6 +80,8 @@
@@ -80,6 +80,8 @@
if (a >= 2 && a >= b * 2) confidence = 0.9;
if (a >= 2 && a >= b * 2) confidence = 0.9;
else if (a >= 1 && b === 0 && words.length <= 4) confidence = 0.65;
else if (a >= 1 && b === 0 && words.length <= 4) confidence = 0.65;
else if (a > b) confidence = 0.55;
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 };
return { lang: best, confidence };
}
}
/** The language of `text` when it is confidently not `me` (a 2-letter code), else null. */
/** The language of `text` when it is confidently not `me` (a 2-letter code), else null. */
@@ -57,11 +57,14 @@ function detectLang(text) {
@@ -57,11 +57,14 @@ function detectLang(text) {
* detection says 'en'; per line, the Japanese line wins — which is the right answer for
* detection says 'en'; per line, the Japanese line wins — which is the right answer for
* "what language does this streamer live in".
* "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) {
function detectForeignInText(text) {
const lines = String(text || '').split(/[\n\r]+|(?<=[。!?.!?])\s+/);
const lines = String(text || '').split(/[\n\r]+|(?<=[。!?.!?])\s+/);
for (const line of lines) {
for (const line of lines) {
const l = detectLang(line);
const l = detectLang(line);
- if (l && l !== 'en') return l;
+ if (l && SCRIPT_LANGS.has(l)) return l;
}
}
return null;
return null;
}
}
@@ -96,7 +99,7 @@ function channelLanguage(userId) {
@@ -96,7 +99,7 @@ function channelLanguage(userId) {
// clearly in another script.
// clearly in another script.
const bioLang = detectForeignInText(u?.bio || '');
const bioLang = detectForeignInText(u?.bio || '');
const nameLang = detectLang(`${u?.display_name || ''} ${ch?.title || ''}`);
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'; }
} catch { lang = 'en'; }
_chanCache.set(id, { lang, at: Date.now() });
_chanCache.set(id, { lang, at: Date.now() });
@@ -256,12 +259,24 @@ async function translateMany(texts, { to = 'en', context = 'chat' } = {}) {
@@ -256,12 +259,24 @@ async function translateMany(texts, { to = 'en', context = 'chat' } = {}) {
need.push({ i, t, from });
need.push({ i, t, from });
});
});
if (!need.length || !available()) return out;
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;
return out;
}
}
@@ -189,9 +189,12 @@ async function reconcileOnce() {
@@ -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; }
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;
let s;
try { s = await client.getSession(r.session_id); } catch (err) {
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)
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') {
if (s.state === 'live' || s.state === 'starting') {
db.run("UPDATE openre_sessions SET confirmed_at = CURRENT_TIMESTAMP WHERE session_id = ?", [r.session_id]);
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]);
if (r.stream_id) db.run('UPDATE streams SET last_heartbeat = CURRENT_TIMESTAMP WHERE id = ? AND is_live = 1', [r.stream_id]);
@@ -57,6 +57,9 @@ function cleanLink(raw) {
@@ -57,6 +57,9 @@ function cleanLink(raw) {
let kind = String(raw.kind || '').toLowerCase();
let kind = String(raw.kind || '').toLowerCase();
let value = String(raw.url || raw.handle || '').trim().slice(0, 300);
let value = String(raw.url || raw.handle || '').trim().slice(0, 300);
if (!value) return null;
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)) {
if (!/^https?:\/\//i.test(value)) {
const p = PLATFORMS[kind];
const p = PLATFORMS[kind];
if (!p || !p.url) {
if (!p || !p.url) {
@@ -1743,8 +1743,12 @@ router.put('/managed/:id', requireAuth, (req, res) => {
@@ -1743,8 +1743,12 @@ router.put('/managed/:id', requireAuth, (req, res) => {
if (fields.description === null) return res.status(400).json({ error: 'Invalid description' });
if (fields.description === null) return res.status(400).json({ error: 'Invalid description' });
}
}
if (hasOwn(req.body, 'category')) {
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')) {
if (hasOwn(req.body, 'protocol')) {
fields.protocol = cleanProtocol(req.body.protocol);
fields.protocol = cleanProtocol(req.body.protocol);