openvibers/openvibe.live Commits

No text arrows: drawn glyphs (Design rule 169) (#880)

OP OpenVibers committed 4 h ago · c38e9a6181 · 44 files, +166 −157

The two decorative arrows are now OpenVibe.Design SVG glyphs; navigation paths, logs and errors say then/to/at. Comments are unchanged except inline-script comments the HTML scanner reads as text. The Rundown message parser accepts the old separator so old rows still render.

Changes

ddiff --git a/public/banned.html b/public/banned.html
iindex a54940d0..24d0064e 100644
---- a/public/banned.html
++++ b/public/banned.html
@@@ -66,7 +66,8 @@
.go:hover { transform: translateY(-2px) scale(1.03); }
.go:active { transform: scale(0.97); }
.go:disabled { cursor: default; animation: none; opacity: 0.85; }
-- .go .arrow { display: inline-block; transition: transform var(--ov-dur-2, 0.2s); }
++ .go .arrow { display: inline-block; margin-left: 5px; vertical-align: -0.18em; transition: transform var(--ov-dur-2, 0.2s); }
++ .go .arrow svg { display: block; width: 15px; height: 15px; }
.go:hover .arrow { transform: translateX(4px); }
.go .rip { position: absolute; border-radius: 50%; background: rgba(255,255,255,0.5); transform: scale(0); animation: rip var(--ov-dur-4, 0.7s) var(--ov-ease-out, ease-out) forwards; pointer-events: none; }
@keyframes sheen { to { background-position: -200% 0; } }
@@@ -126,7 +127,7 @@
<div class="sorry rv" style="--d:8.2s">
<h3 class="sorry-head">Sorry I'm being a bitch about this whole thing.</h3>
<p class="sorry-body">I don't actually want to lock you out. Press continue and the ban is gone — on every network and every device. Just be decent to me.</p>
-- <button class="go" id="go" type="button">♥ Continue <span class="arrow">→</span></button>
++ <button class="go" id="go" type="button">♥ Continue<span class="arrow"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M3.2 12h16.7M13.7 5.7l6.3 6.3l-6.3 6.3"/></svg></span></button>
<span class="go-hint">One press. That's it.</span>
</div>
<p class="fine rv" style="--d:8.6s"><span class="logo">OpenVibe.Live</span> · you will see this page every time you open the site, on every device and every network you use — until you press continue.</p>
@@@ -136,6 +137,7 @@
// Continue: lift the ban server-side, then play the lift and go home.
(function () {
var btn = document.getElementById('go');
++ var ARROW = '<span class="arrow"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M3.2 12h16.7M13.7 5.7l6.3 6.3l-6.3 6.3"/></svg></span>';
if (!btn) return;
btn.addEventListener('click', async function (ev) {
if (btn.disabled) return;
@@@ -148,7 +150,7 @@
var j = await res.json();
ok = !!(res.ok && j.ok); name = j.name || null;
} catch (e) { ok = false; }
-- if (!ok) { btn.disabled = false; btn.innerHTML = 'Try again <span class="arrow">→</span>'; return; }
++ if (!ok) { btn.disabled = false; btn.innerHTML = 'Try again' + ARROW; return; }
var reduced = (document.documentElement.getAttribute('data-ov-motion') === 'off');
if (name) document.getElementById('welcome-line').textContent = name + ', the ban is lifted. Taking you to the site…';
if (!reduced) {
ddiff --git a/public/fragments/broadcast.html b/public/fragments/broadcast.html
iindex 70621543..3eeaf0b5 100644
---- a/public/fragments/broadcast.html
++++ b/public/fragments/broadcast.html
@@@ -152,7 +152,7 @@
</summary>
<div class="bc-app-guide-body">
<ol>
-- <li>Open OBS → Settings → Stream</li>
++ <li>Open OBS, then Settings, then Stream</li>
<li>Set Service to <strong>Custom</strong></li>
<li>Paste the <strong>Server URL</strong> above</li>
<li>Paste the <strong>Stream Key</strong></li>
@@@ -202,8 +202,8 @@
<h4 style="margin:12px 0 8px">IRL Pro Step-by-Step</h4>
<ol>
-- <li>Open <strong>IRL Pro</strong> → <strong>⚙️ Settings</strong> → <strong>Streaming</strong></li>
-- <li>Select <strong>Platform</strong> → <strong>Custom RTMP</strong></li>
++ <li>Open <strong>IRL Pro</strong>, then <strong>⚙️ Settings</strong>, then <strong>Streaming</strong></li>
++ <li>Select <strong>Platform</strong>, then <strong>Custom RTMP</strong></li>
<li>Paste the <strong>Server URL</strong> and <strong>Stream Key</strong> from Option A above</li>
<li><strong>Leave Username and Password empty</strong></li>
<li>Set <strong>Resolution</strong> to 720p, <strong>Bitrate</strong> to 2500–4000 kbps</li>
@@@ -236,14 +236,14 @@
<div class="bc-app-guide-body">
<p class="bc-app-guide-desc">Stream directly from a terminal — great for headless servers, Raspberry Pi, 3D printer cams, and automated pipelines.</p>
<div class="bc-code-block" style="margin-bottom:12px">
-- <label><i class="fa-solid fa-video"></i> Webcam + Audio → RTMP</label>
++ <label><i class="fa-solid fa-video"></i> Webcam + Audio to RTMP</label>
<div class="bc-code-copy">
<code id="bc-rtmp-ffmpeg-cam" style="white-space:pre-wrap;word-break:break-all">loading...</code>
<button class="btn btn-small" onclick="copyToClipboard(document.getElementById('bc-rtmp-ffmpeg-cam').textContent)"><i class="fa-solid fa-copy"></i></button>
</div>
</div>
<div class="bc-code-block" style="margin-bottom:12px">
-- <label><i class="fa-solid fa-display"></i> Screen Capture → RTMP</label>
++ <label><i class="fa-solid fa-display"></i> Screen Capture to RTMP</label>
<div class="bc-code-copy">
<code id="bc-rtmp-ffmpeg-screen" style="white-space:pre-wrap;word-break:break-all">loading...</code>
<button class="btn btn-small" onclick="copyToClipboard(document.getElementById('bc-rtmp-ffmpeg-screen').textContent)"><i class="fa-solid fa-copy"></i></button>
@@@ -314,7 +314,7 @@
</div>
</div>
<div class="bc-code-block">
-- <label><i class="fa-solid fa-display"></i> OBS Virtual Camera → JSMPEG</label>
++ <label><i class="fa-solid fa-display"></i> OBS Virtual Camera to JSMPEG</label>
<div class="bc-code-copy">
<code id="bc-jsmpeg-cmd-obs" style="white-space:pre-wrap;word-break:break-all">loading...</code>
<button class="btn btn-small" onclick="copyToClipboard(document.getElementById('bc-jsmpeg-cmd-obs').textContent)"><i class="fa-solid fa-copy"></i></button>
@@@ -403,7 +403,7 @@
<div class="bc-code-block" style="margin-bottom:8px">
<label>Use PulseAudio instead of ALSA</label>
<div class="bc-code-copy">
-- <code style="white-space:pre-wrap">Replace: -f alsa -i default → -f pulse -i default</code>
++ <code style="white-space:pre-wrap">Replace: -f alsa -i default with -f pulse -i default</code>
<button class="btn btn-small" onclick="copyToClipboard('-f pulse -i default')"><i class="fa-solid fa-copy"></i></button>
</div>
</div>
@@@ -411,7 +411,7 @@
<div class="bc-code-block" style="margin-bottom:8px">
<label>OBS Virtual Camera</label>
<div class="bc-code-copy">
-- <code style="white-space:pre-wrap">Start OBS → Tools → Start Virtual Camera → use /dev/video2 (or check v4l2-ctl --list-devices)</code>
++ <code style="white-space:pre-wrap">Start OBS, then Tools, then Start Virtual Camera, then use /dev/video2 (or check v4l2-ctl --list-devices)</code>
<button class="btn btn-small" onclick="copyToClipboard('v4l2-ctl --list-devices')"><i class="fa-solid fa-copy"></i></button>
</div>
</div>
@@@ -481,7 +481,7 @@
</div>
</div>
<div class="bc-code-block" style="margin-bottom:12px">
-- <label>IP camera (RTSP) → JSMPEG</label>
++ <label>IP camera (RTSP) to JSMPEG</label>
<div class="bc-code-copy">
<code id="bc-jsmpeg-cmd-ipcam" style="white-space:pre-wrap;word-break:break-all">loading...</code>
<button class="btn btn-small" onclick="copyToClipboard(document.getElementById('bc-jsmpeg-cmd-ipcam').textContent)"><i class="fa-solid fa-copy"></i></button>
@@@ -550,7 +550,7 @@ WantedBy=multi-user.target</code>
<h2><i class="fa-solid fa-desktop"></i> WebRTC via OBS (WHIP)</h2>
<p class="bc-instructions-intro">Your stream is created! Paste the info below into OBS Studio 30+ to start streaming with ultra-low latency via WebRTC. This is newer and faster than RTMP.</p>
-- <p class="muted" style="margin-top:8px"><i class="fa-solid fa-info-circle"></i> Note: on some Linux distributions, the system OBS package may disable WHIP even in OBS 30+. If you do not see the WHIP service in Settings → Stream, install OBS from Flatpak or AppImage instead of the distro package.</p>
++ <p class="muted" style="margin-top:8px"><i class="fa-solid fa-info-circle"></i> Note: on some Linux distributions, the system OBS package may disable WHIP even in OBS 30+. If you do not see the WHIP service in Settings, then Stream, install OBS from Flatpak or AppImage instead of the distro package.</p>
<div class="bc-code-block">
<label>WHIP Endpoint</label>
<div class="bc-code-copy">
@@@ -570,7 +570,7 @@ WantedBy=multi-user.target</code>
<div class="bc-instructions-steps">
<h3>OBS 30+ Setup</h3>
<ol>
-- <li>Open OBS → Settings → Stream</li>
++ <li>Open OBS, then Settings, then Stream</li>
<li>Set Service to <strong>WHIP</strong></li>
<li>Paste the <strong>WHIP Endpoint</strong> URL above</li>
<li>Paste the <strong>Bearer Token</strong> as the auth key</li>
ddiff --git a/public/fragments/dashboard.html b/public/fragments/dashboard.html
iindex d4558567..3096355c 100644
---- a/public/fragments/dashboard.html
++++ b/public/fragments/dashboard.html
@@@ -366,7 +366,7 @@
<!-- AI Moments of my stream: auto-clips, kept apart from the clips people took (roadmap 33.7) -->
<div class="dash-card dash-card-wide" id="dash-card-aimoments">
<h3 style="margin:0 0 6px"><i class="fa-solid fa-sparkles"></i> AI Moments of My Stream</h3>
-- <p class="muted">Clips OpenVibe's AI cut from your streams when chat reacted. Nobody clipped these, so they are not in "My Clips". You can publish, unlist or delete them, and turn the AI off for your channel under Home &rarr; AI Moments from my streams.</p>
++ <p class="muted">Clips OpenVibe's AI cut from your streams when chat reacted. Nobody clipped these, so they are not in "My Clips". You can publish, unlist or delete them, and turn the AI off for your channel under Home, then AI Moments from my streams.</p>
<div id="dash-ai-clips" class="vod-grid"></div>
</div>
@@@ -504,7 +504,7 @@
<input type="checkbox" id="dash-ai-derivation" checked onchange="saveAiDerivationSetting(this)">
<span>Let OpenVibe's AI make Moments from my streams</span>
</label>
-- <p class="muted" style="font-size:0.85rem;margin:0">Moments are auto-clips cut when chat reacts, frames the AI picks and posts as notes, and AI-written after-show recaps. They are always labelled as AI-made, never credited to you, and kept out of search. Turned off, the AI makes no new ones from your streams (your after-show report keeps its stats). Moments already made stay until you delete them under Content &rarr; AI Moments.</p>
++ <p class="muted" style="font-size:0.85rem;margin:0">Moments are auto-clips cut when chat reacts, frames the AI picks and posts as notes, and AI-written after-show recaps. They are always labelled as AI-made, never credited to you, and kept out of search. Turned off, the AI makes no new ones from your streams (your after-show report keeps its stats). Moments already made stay until you delete them under Content, then AI Moments.</p>
<span id="dash-ai-derivation-status" class="muted" style="font-size:0.82rem"></span>
</div>
ddiff --git a/public/fragments/documentation.html b/public/fragments/documentation.html
iindex af77543a..b55ba67d 100644
---- a/public/fragments/documentation.html
++++ b/public/fragments/documentation.html
@@@ -96,7 +96,7 @@
<h3>Base URL &amp; Auth</h3>
<p>Base URL: <code>https://openvibe.live</code> (replace with your instance).</p>
<p>Auth: <code>Authorization: Bearer &lt;JWT&gt;</code> header. On WebSockets, the header on the upgrade request or the <code>token</code> of the first <code>join</code> message. A token in the URL or a cookie does not sign a socket in.</p>
--<p>API tokens: <code>hbt_</code> prefix, scoped permissions, created from Dashboard → API Tokens.</p>
++<p>API tokens: <code>hbt_</code> prefix, scoped permissions, created from Dashboard, then API Tokens.</p>
</div>
<!-- ═══════════════════════════════════════════════════════════ -->
@@@ -145,9 +145,9 @@
<h3>Admin Proxy Architecture</h3>
<p>openvibe.network admin panel proxies requests to OpenVibe.Live:</p>
--<pre><code>openvibe.network /api/admin/streamer/* → OpenVibe.Live /api/admin/*
--openvibe.network /api/admin/streamer-tts/* → OpenVibe.Live /api/tts/*
--openvibe.network /api/admin/streamer-mod/* → OpenVibe.Live /api/mod/*</code></pre>
++<pre><code>openvibe.network /api/admin/streamer/* maps to OpenVibe.Live /api/admin/*
++openvibe.network /api/admin/streamer-tts/* maps to OpenVibe.Live /api/tts/*
++openvibe.network /api/admin/streamer-mod/* maps to OpenVibe.Live /api/mod/*</code></pre>
<p>Server-to-server calls carry a short-lived service token from openvibe.network, scoped to the one capability each route performs.</p>
</div>
@@@ -171,8 +171,8 @@ openvibe.network /api/admin/streamer-mod/* → OpenVibe.Live /api/mod/*</code>
<table class="doc-table" tabindex="0">
<thead><tr><th>Method</th><th>Path</th><th>Auth</th><th>Notes</th></tr></thead>
<tbody>
--<tr><td>GET</td><td>/api/auth/sso/login</td><td>No</td><td>302 &rarr; openvibe.network OAuth authorize</td></tr>
--<tr><td>GET</td><td>/api/auth/callback?code&amp;state</td><td>No</td><td>OAuth code&rarr;token exchange (PKCE); sets cookies</td></tr>
++<tr><td>GET</td><td>/api/auth/sso/login</td><td>No</td><td>302 redirects to openvibe.network OAuth authorize</td></tr>
++<tr><td>GET</td><td>/api/auth/callback?code&amp;state</td><td>No</td><td>OAuth code to token exchange (PKCE); sets cookies</td></tr>
<tr><td>GET</td><td>/api/auth/health</td><td>No</td><td>OAuth client health; 503 only when the Network refuses this site's client</td></tr>
<tr><td>POST</td><td>/api/auth/refresh</td><td>Cookie</td><td>Refresh access token via httpOnly <code>ov_refresh</code> cookie</td></tr>
<tr><td>POST</td><td>/api/auth/logout</td><td>No</td><td>Clear auth cookies</td></tr>
@@@ -189,11 +189,11 @@ openvibe.network /api/admin/streamer-mod/* → OpenVibe.Live /api/mod/*</code>
</table>
<h3>API Tokens (<code>hbt_</code> prefix)</h3>
--<p>Long-lived, scoped tokens for bots/integrations. Create them from Dashboard &rarr; API Tokens (max 10 active). The token string is shown once. Use as <code>Authorization: Bearer hbt_xxxxxxxxxxxx</code>.</p>
++<p>Long-lived, scoped tokens for bots/integrations. Create them from Dashboard, then API Tokens (max 10 active). The token string is shown once. Use as <code>Authorization: Bearer hbt_xxxxxxxxxxxx</code>.</p>
<table class="doc-table" tabindex="0">
<thead><tr><th>Method</th><th>Path</th><th>Notes</th></tr></thead>
<tbody>
--<tr><td>POST</td><td>/api/auth/tokens</td><td>Create <code>{ label, scopes[], expiresAt? }</code> &rarr; <code>{ id, token, ... }</code> (JWT auth only, not an hbt_ token)</td></tr>
++<tr><td>POST</td><td>/api/auth/tokens</td><td>Create <code>{ label, scopes[], expiresAt? }</code> returns <code>{ id, token, ... }</code> (JWT auth only, not an hbt_ token)</td></tr>
<tr><td>GET</td><td>/api/auth/tokens</td><td>List tokens + <code>valid_scopes</code>, <code>scope_definitions</code>, <code>token_presets</code></td></tr>
<tr><td>DELETE</td><td>/api/auth/tokens/:id</td><td>Revoke a token</td></tr>
</tbody>
@@@ -201,7 +201,7 @@ openvibe.network /api/admin/streamer-mod/* → OpenVibe.Live /api/mod/*</code>
<p><strong>Scopes:</strong> <code>chat</code>, <code>read</code>, <code>stream</code>, <code>control</code>, <code>vibe_coding_publish</code>. <strong>Presets:</strong> chat-bot (<code>chat,read</code>), stream-controller (<code>read,stream,control</code>), vibe-coding-publisher (<code>read,vibe_coding_publish</code>).</p>
<h3>Example: use an API token</h3>
--<pre><code>// Create the token once from Dashboard &rarr; API Tokens (scopes: chat, read),
++<pre><code>// Create the token once from Dashboard, then API Tokens (scopes: chat, read),
// then use it directly &mdash; no login flow needed.
const TOKEN = 'hbt_xxxxxxxxxxxxxxxx';
@@@ -226,7 +226,7 @@ console.log('Acting as:', me.user.username);
<thead><tr><th>Method</th><th>Path</th><th>Auth</th><th>Notes</th></tr></thead>
<tbody>
<tr><td>GET</td><td>/api/streams/managed</td><td>Yes</td><td>My slots + slot limit</td></tr>
--<tr><td>POST</td><td>/api/streams/managed</td><td>Yes</td><td>Create a slot &rarr; unique <code>stream_key</code>. Body: <code>title, description, category, protocol, is_nsfw, slug, streaming_method</code></td></tr>
++<tr><td>POST</td><td>/api/streams/managed</td><td>Yes</td><td>Create a slot; it returns a unique <code>stream_key</code>. Body: <code>title, description, category, protocol, is_nsfw, slug, streaming_method</code></td></tr>
<tr><td>PUT</td><td>/api/streams/managed/:id</td><td>Owner</td><td>Update slot + slot settings (see below)</td></tr>
<tr><td>DELETE</td><td>/api/streams/managed/:id</td><td>Owner</td><td>Delete slot (409 if live)</td></tr>
<tr><td>POST</td><td>/api/streams/managed/:id/regenerate-key</td><td>Owner</td><td>New slot stream key</td></tr>
@@@ -238,7 +238,7 @@ console.log('Acting as:', me.user.username);
<table class="doc-table" tabindex="0">
<thead><tr><th>Method</th><th>Path</th><th>Auth</th><th>Notes</th></tr></thead>
<tbody>
--<tr><td>POST</td><td>/api/streams</td><td>Yes</td><td><strong>Go live</strong> from a slot. Body: <code>managed_stream_id</code> (required) + optional <code>title, category, tags, is_nsfw</code> &rarr; <code>{ stream, endpoint }</code></td></tr>
++<tr><td>POST</td><td>/api/streams</td><td>Yes</td><td><strong>Go live</strong> from a slot. Body: <code>managed_stream_id</code> (required) + optional <code>title, category, tags, is_nsfw</code> returns <code>{ stream, endpoint }</code></td></tr>
<tr><td>GET</td><td>/api/streams/:id/endpoint</td><td>Owner</td><td>Ingest endpoint + <code>stream_key</code> for the session's protocol</td></tr>
<tr><td>PUT</td><td>/api/streams/:id</td><td>Owner</td><td>Update live session (title/description/category/tags)</td></tr>
<tr><td>DELETE</td><td>/api/streams/:id</td><td>Owner</td><td>End the session</td></tr>
@@@ -325,32 +325,32 @@ const { answer, location } = await live.whip.publish(live.whip.url(slot.stream_k
<table class="doc-table" tabindex="0">
<thead><tr><th>Type</th><th>Dir</th><th>Key Fields</th><th>Description</th></tr></thead>
<tbody>
--<tr><td>welcome</td><td>S→C</td><td>peerId, role, streamId, viewerCount, iceServers</td><td>Sent immediately on connect</td></tr>
--<tr><td>watch</td><td>C→S</td><td>—</td><td>Viewer requests to start watching (triggers SFU consumer)</td></tr>
--<tr><td>broadcaster-ready</td><td>S→Viewer</td><td>peerId</td><td>Broadcaster or SFU source is live, safe to send/refresh <code>watch</code></td></tr>
--<tr><td>watch-queued</td><td>S→Viewer</td><td>reason, detail</td><td>Viewer is queued while the live source warms up or reconnects</td></tr>
--<tr><td>sfu-viewer-ready</td><td>S→Viewer</td><td>rtpCapabilities, producers</td><td>Server has live SFU producers ready for consumption</td></tr>
--<tr><td>sfu-viewer-create-transport</td><td>C→S</td><td>—</td><td>Viewer asks the server to create an SFU recv transport</td></tr>
--<tr><td>sfu-viewer-transport-created</td><td>S→Viewer</td><td>id, iceParameters, iceCandidates, dtlsParameters, iceServers</td><td>Viewer recv transport parameters</td></tr>
--<tr><td>sfu-viewer-connect-transport</td><td>C→S</td><td>transportId, dtlsParameters</td><td>Connect the viewer recv transport</td></tr>
--<tr><td>sfu-viewer-transport-connected</td><td>S→Viewer</td><td>transportId</td><td>Viewer recv transport connected</td></tr>
--<tr><td>sfu-viewer-consume</td><td>C→S</td><td>transportId, producerId, rtpCapabilities</td><td>Create an SFU consumer for a live producer</td></tr>
--<tr><td>sfu-viewer-consumed</td><td>S→Viewer</td><td>id, producerId, kind, rtpParameters</td><td>Consumer parameters returned to mediasoup-client</td></tr>
--<tr><td>sfu-source-unavailable</td><td>S→Viewer</td><td>reason</td><td>Live source exists, but it is not currently healthy for viewers</td></tr>
--<tr><td>sfu-get-capabilities</td><td>C→S</td><td>—</td><td>Get Mediasoup RTP capabilities</td></tr>
--<tr><td>sfu-create-transport</td><td>C→S</td><td>direction: send|recv</td><td>Create WebRTC transport</td></tr>
--<tr><td>sfu-connect-transport</td><td>C→S</td><td>transportId, dtlsParameters</td><td>Connect DTLS</td></tr>
--<tr><td>sfu-produce</td><td>C→S</td><td>transportId, kind, rtpParameters</td><td>Start producing audio/video</td></tr>
--<tr><td>sfu-stop-produce</td><td>C→S</td><td>producerId</td><td>Stop a producer</td></tr>
--<tr><td>viewer-count</td><td>S→All</td><td>count</td><td>Updated viewer count broadcast</td></tr>
--<tr><td>stream-ended</td><td>S→Viewers</td><td>—</td><td>Stream has ended</td></tr>
++<tr><td>welcome</td><td>S to C</td><td>peerId, role, streamId, viewerCount, iceServers</td><td>Sent immediately on connect</td></tr>
++<tr><td>watch</td><td>C to S</td><td>—</td><td>Viewer requests to start watching (triggers SFU consumer)</td></tr>
++<tr><td>broadcaster-ready</td><td>S to Viewer</td><td>peerId</td><td>Broadcaster or SFU source is live, safe to send/refresh <code>watch</code></td></tr>
++<tr><td>watch-queued</td><td>S to Viewer</td><td>reason, detail</td><td>Viewer is queued while the live source warms up or reconnects</td></tr>
++<tr><td>sfu-viewer-ready</td><td>S to Viewer</td><td>rtpCapabilities, producers</td><td>Server has live SFU producers ready for consumption</td></tr>
++<tr><td>sfu-viewer-create-transport</td><td>C to S</td><td>—</td><td>Viewer asks the server to create an SFU recv transport</td></tr>
++<tr><td>sfu-viewer-transport-created</td><td>S to Viewer</td><td>id, iceParameters, iceCandidates, dtlsParameters, iceServers</td><td>Viewer recv transport parameters</td></tr>
++<tr><td>sfu-viewer-connect-transport</td><td>C to S</td><td>transportId, dtlsParameters</td><td>Connect the viewer recv transport</td></tr>
++<tr><td>sfu-viewer-transport-connected</td><td>S to Viewer</td><td>transportId</td><td>Viewer recv transport connected</td></tr>
++<tr><td>sfu-viewer-consume</td><td>C to S</td><td>transportId, producerId, rtpCapabilities</td><td>Create an SFU consumer for a live producer</td></tr>
++<tr><td>sfu-viewer-consumed</td><td>S to Viewer</td><td>id, producerId, kind, rtpParameters</td><td>Consumer parameters returned to mediasoup-client</td></tr>
++<tr><td>sfu-source-unavailable</td><td>S to Viewer</td><td>reason</td><td>Live source exists, but it is not currently healthy for viewers</td></tr>
++<tr><td>sfu-get-capabilities</td><td>C to S</td><td>—</td><td>Get Mediasoup RTP capabilities</td></tr>
++<tr><td>sfu-create-transport</td><td>C to S</td><td>direction: send|recv</td><td>Create WebRTC transport</td></tr>
++<tr><td>sfu-connect-transport</td><td>C to S</td><td>transportId, dtlsParameters</td><td>Connect DTLS</td></tr>
++<tr><td>sfu-produce</td><td>C to S</td><td>transportId, kind, rtpParameters</td><td>Start producing audio/video</td></tr>
++<tr><td>sfu-stop-produce</td><td>C to S</td><td>producerId</td><td>Stop a producer</td></tr>
++<tr><td>viewer-count</td><td>S to All</td><td>count</td><td>Updated viewer count broadcast</td></tr>
++<tr><td>stream-ended</td><td>S to Viewers</td><td>—</td><td>Stream has ended</td></tr>
</tbody>
</table>
<p class="muted" style="margin-top:10px">Viewer media is delivered exclusively through the Mediasoup SFU. There is no direct peer-to-peer relay: legacy <code>offer</code> / <code>answer</code> / <code>ice-candidate</code> messages are not supported.</p>
<h3>RTMP Ingest (OBS)</h3>
<pre><code>Server: rtmp://openvibe.live/live
--Key: YOUR_STREAM_KEY (get from Dashboard → Stream Key)
++Key: YOUR_STREAM_KEY (get from Dashboard, then Stream Key)
# Playback proxy for browser
GET /api/streams/rtmp-proxy/:streamId.flv
@@@ -368,7 +368,7 @@ GET /api/streams/rtmp-proxy/:streamId.flv
<p>Connect: <code>wss://openvibe.live/ws/chat?stream=STREAM_ID</code> with an <code>Authorization: Bearer &lt;token&gt;</code> header (a JWT or an <code>hbt_</code> API token), then send <code>{ "type": "join", "streamId": STREAM_ID }</code>. A client that cannot set headers (a browser) puts the token in that join instead: <code>{ "type": "join", "streamId": STREAM_ID, "token": "…" }</code>.</p>
<p>Omit <code>stream</code> for global chat; omit the token to connect anonymously (assigned an anonXXXXX identity). A token in the URL does not sign the socket in: URLs end up in logs.</p>
--<h3>Client → Server Messages</h3>
++<h3>Client to Server Messages</h3>
<table class="doc-table" tabindex="0">
<thead><tr><th>Type</th><th>Payload</th><th>Description</th></tr></thead>
<tbody>
@@@ -380,7 +380,7 @@ GET /api/streams/rtmp-proxy/:streamId.flv
</tbody>
</table>
--<h3>Server → Client Messages</h3>
++<h3>Server to Client Messages</h3>
<table class="doc-table" tabindex="0">
<thead><tr><th>Type</th><th>Key Fields</th><th>Description</th></tr></thead>
<tbody>
@@@ -526,8 +526,8 @@ while True:
<tbody>
<tr><td>GET</td><td>/api/funds/balance</td><td>Yes</td><td>Both balances + USD cashout values (<code>usd_value = cashout_balance / 100</code>)</td></tr>
<tr><td>POST</td><td>/api/funds/donate</td><td>Yes</td><td>Donate spendable bucks to a streamer; credits their cashout balance + advances a goal. Body: <code>amount, streamer_id|stream_id, message?, goal_id?</code></td></tr>
--<tr><td>POST</td><td>/api/funds/cashout</td><td>Yes</td><td>Request cashout &rarr; escrow. Body: <code>amount</code> (&ge; 500), <code>paypal_email</code></td></tr>
--<tr><td>POST</td><td>/api/funds/recycle</td><td>Yes</td><td>Move cashout balance &rarr; spendable (re-donate instead of cashing out)</td></tr>
++<tr><td>POST</td><td>/api/funds/cashout</td><td>Yes</td><td>Request cashout to escrow. Body: <code>amount</code> (&ge; 500), <code>paypal_email</code></td></tr>
++<tr><td>POST</td><td>/api/funds/recycle</td><td>Yes</td><td>Move cashout balance to spendable (re-donate instead of cashing out)</td></tr>
<tr><td>GET</td><td>/api/funds/history</td><td>Yes</td><td>Transaction history (<code>?limit</code> &le; 200)</td></tr>
<tr><td>GET</td><td>/api/funds/leaderboard/:streamId</td><td>No</td><td>Top donors for a stream</td></tr>
<tr><td>GET</td><td>/api/funds/goals/:userId</td><td>No</td><td>Public goal widget set (active + recently reached)</td></tr>
@@@ -544,14 +544,14 @@ while True:
<thead><tr><th>Method</th><th>Path</th><th>Auth</th><th>Notes</th></tr></thead>
<tbody>
<tr><td>GET</td><td>/api/payments/config</td><td>No</td><td>Live providers + pricing: <code>packages</code>, <code>cashoutBucksPerUsd</code> (100), <code>minPurchaseBucks</code>, <code>subPriceUsd</code></td></tr>
--<tr><td>POST</td><td>/api/payments/bucks/checkout</td><td>Yes</td><td>Buy bucks (USD derived from volume tiers). Body: <code>provider, bucks</code> &rarr; <code>{ url }</code> hosted checkout</td></tr>
++<tr><td>POST</td><td>/api/payments/bucks/checkout</td><td>Yes</td><td>Buy bucks (USD derived from volume tiers). Body: <code>provider, bucks</code> returns <code>{ url }</code> hosted checkout</td></tr>
<tr><td>POST</td><td>/api/payments/subscribe</td><td>Yes</td><td>Subscribe to a channel. Body: <code>provider</code> (<code>stripe</code>|<code>bucks</code>), <code>streamer</code></td></tr>
<tr><td>GET</td><td>/api/payments/subscriptions/mine &middot; /channel/:username</td><td>Yes/Opt</td><td>My subs / am-I-subscribed + count + price</td></tr>
<tr><td>POST</td><td>/api/payments/subscriptions/:id/cancel</td><td>Yes</td><td>Cancel (Stripe at period end; bucks immediate)</td></tr>
<tr><td>POST/ALL</td><td>/api/payments/webhook/{stripe,paypal,ccbill,crypto}</td><td>No</td><td><code>410 Gone</code> with <code>webhook_url</code>: providers deliver to OpenVibe.Billing (<code>https://billing.openvibe.network/webhooks/{stripe,paypal,ccbill,nowpayments}</code>)</td></tr>
</tbody>
</table>
--<p><strong>Packages</strong>: <code>100, 500, 1000, 2500, 5000, 10000, 25000</code> bucks. <strong>Per-buck price tiers</strong>: &ge;25000&rarr;$0.0110, &ge;10000&rarr;$0.0115, &ge;5000&rarr;$0.0120, &ge;2500&rarr;$0.0124, &ge;1000&rarr;$0.0130, &ge;500&rarr;$0.0140, else $0.0150. Streamers keep <code>sub_streamer_share_pct</code> (default 70%) of sub value into their cashout balance.</p>
++<p><strong>Packages</strong>: <code>100, 500, 1000, 2500, 5000, 10000, 25000</code> bucks. <strong>Per-buck price tiers</strong>: &ge;25000: $0.0110, &ge;10000: $0.0115, &ge;5000: $0.0120, &ge;2500: $0.0124, &ge;1000: $0.0130, &ge;500: $0.0140, else $0.0150. Streamers keep <code>sub_streamer_share_pct</code> (default 70%) of sub value into their cashout balance.</p>
<h3>OpenCoins / Coins &mdash; <code>/api/coins</code></h3>
<p>Free currencies. <strong>GOLD</strong> is a global wallet (games, cosmetics, media requests). <strong>Channel points ("OpenCoins")</strong> are per-streamer loyalty points earned by watching/chatting a channel and spent on that streamer's rewards. Defaults: watch = 10/5min, chat = 5 (max 1/min), follow = 50 once; not earnable on your own stream.</p>
@@@ -559,7 +559,7 @@ while True:
<thead><tr><th>Method</th><th>Path</th><th>Auth</th><th>Notes</th></tr></thead>
<tbody>
<tr><td>GET</td><td>/api/coins/balance &middot; /channel-balance</td><td>Yes</td><td>Global GOLD / per-streamer points (<code>?streamerId</code>)</td></tr>
--<tr><td>POST</td><td>/api/coins/heartbeat &middot; /bonus</td><td>Yes</td><td>Watch-time tick / claim bonus game &rarr; earn points (<code>streamId</code>)</td></tr>
++<tr><td>POST</td><td>/api/coins/heartbeat &middot; /bonus</td><td>Yes</td><td>Watch-time tick / claim bonus game, then earn points (<code>streamId</code>)</td></tr>
<tr><td>GET</td><td>/api/coins/rewards/:userId &middot; /config/:userId</td><td>No</td><td>A streamer's rewards + points config</td></tr>
<tr><td>PUT</td><td>/api/coins/config</td><td>Yes</td><td>Configure my channel points (name, icon, watch/game rates)</td></tr>
<tr><td>POST / PUT / DELETE</td><td>/api/coins/rewards[/:id]</td><td>Yes</td><td>Manage my rewards (<code>title, cost, cooldown_seconds, max_per_stream, requires_input</code>)</td></tr>
@@@ -878,7 +878,7 @@ https://www.101soundboards.com/sounds/12345 150p 2</code></pre>
<table class="doc-table" tabindex="0">
<thead><tr><th>Method</th><th>Path</th><th>Auth</th><th>Notes</th></tr></thead>
<tbody>
--<tr><td>GET</td><td>/api/comments/:type/:id</td><td>Opt</td><td>List comments + replies, newest first (<code>type</code> = <code>vod|clip</code>, <code>?limit,offset</code>) &rarr; <code>{ comments, total, thread? }</code>; <code>thread.url</code> is the same thread on OpenVibe.Community (public and unlisted items)</td></tr>
++<tr><td>GET</td><td>/api/comments/:type/:id</td><td>Opt</td><td>List comments + replies, newest first (<code>type</code> = <code>vod|clip</code>, <code>?limit,offset</code>) returns <code>{ comments, total, thread? }</code>; <code>thread.url</code> is the same thread on OpenVibe.Community (public and unlisted items)</td></tr>
<tr><td>GET</td><td>/api/comments/:commentId/replies</td><td>Opt</td><td>All replies of a comment</td></tr>
<tr><td>POST</td><td>/api/comments/:type/:id</td><td>Yes</td><td>Add a comment/reply (<code>message</code>, <code>parent_id?</code>)</td></tr>
<tr><td>PUT</td><td>/api/comments/:commentId</td><td>Author</td><td>Edit your own comment</td></tr>
@@@ -893,7 +893,7 @@ https://www.101soundboards.com/sounds/12345 150p 2</code></pre>
<tbody>
<tr><td>GET</td><td>/api/pastes</td><td>Opt</td><td>List public pastes (<code>?limit,offset,type,username,search,sort</code>)</td></tr>
<tr><td>GET</td><td>/api/pastes/:slug &middot; /:slug/raw</td><td>Opt/No</td><td>Get a paste / raw content</td></tr>
--<tr><td>POST</td><td>/api/pastes</td><td>Opt</td><td>Create text paste (<code>content</code> + <code>title, language, visibility</code>); anon allowed if enabled &rarr; <code>{ paste, url }</code></td></tr>
++<tr><td>POST</td><td>/api/pastes</td><td>Opt</td><td>Create text paste (<code>content</code> + <code>title, language, visibility</code>); anon allowed if enabled returns <code>{ paste, url }</code></td></tr>
<tr><td>POST</td><td>/api/pastes/screenshot</td><td>Opt</td><td>Upload an image paste</td></tr>
<tr><td>PUT / DELETE</td><td>/api/pastes/:slug</td><td>Owner/staff</td><td>Edit / delete a paste</td></tr>
</tbody>
ddiff --git a/public/js/ai-viewers.js b/public/js/ai-viewers.js
iindex ffd3638b..2c91f7f1 100644
---- a/public/js/ai-viewers.js
++++ b/public/js/ai-viewers.js
@@@ -79,7 +79,7 @@
<span title="Live transcript"><i class="fa-solid fa-ear-listen"></i> heard ${st.senses.heard_lines ? `${st.senses.heard_lines} lines, latest ${ago(st.senses.heard_age_ms)}` : (st.timeline_enabled ? 'nothing recent' : 'off')}</span>
<span title="Latest screen analysis"><i class="fa-solid fa-eye"></i> seen ${st.senses.seen_age_ms != null ? ago(st.senses.seen_age_ms) : 'never'}${st.senses.seen_text ? ` — ${esc(st.senses.seen_text.slice(0, 90))}` : ''}</span>
<span title="Cached prompt prefix"><i class="fa-solid fa-bolt"></i> context ${st.senses.stable_prefix_chars ? `${Math.round(st.senses.stable_prefix_chars / 4)} tokens cached` : 'building'}</span>
-- <span><i class="fa-solid fa-clock"></i> next pass ${st.next_tick_in_ms != null ? `in ${Math.round(st.next_tick_in_ms / 1000)}s` : '—'} · ${st.lines_last_minute}/min · bot↔bot ${Math.round((st.bot_share || 0) * 100)}%</span>`;
++ <span><i class="fa-solid fa-clock"></i> next pass ${st.next_tick_in_ms != null ? `in ${Math.round(st.next_tick_in_ms / 1000)}s` : '—'} · ${st.lines_last_minute}/min · bot-to-bot ${Math.round((st.bot_share || 0) * 100)}%</span>`;
}
if (b) _setUsageMeter(b, st);
}
@@@ -162,7 +162,7 @@
function logRow(r) {
const icon = { line: 'fa-comment', tick: 'fa-clapperboard', skip: 'fa-forward', mention: 'fa-bullhorn', fold: 'fa-brain', vision: 'fa-eye', degrade: 'fa-gauge', pause: 'fa-pause', error: 'fa-triangle-exclamation', info: 'fa-circle-info' }[r.event] || 'fa-circle';
const cost = r.cost_usd != null ? `<span class="muted" title="${r.tokens_in || 0} in (${r.tokens_cached || 0} cached) / ${r.tokens_out || 0} out · ${esc(r.model || '')}">${money(r.cost_usd)}${r.tokens_in ? ` · ${Math.round(((r.tokens_cached || 0) / r.tokens_in) * 100)}% cached` : ''}</span>` : '';
-- const who = r.bot_username ? `<b>${esc(r.bot_username)}</b>${r.target ? ` <span class="muted">→ ${esc(r.target)}</span>` : ''}` : `<span class="muted">${esc(r.event)}</span>`;
++ const who = r.bot_username ? `<b>${esc(r.bot_username)}</b>${r.target ? ` <span class="muted">to ${esc(r.target)}</span>` : ''}` : `<span class="muted">${esc(r.event)}</span>`;
return `<div class="aiv-log-row aiv-log-${esc(r.event)}"><i class="fa-solid ${icon}"></i><div><div>${who} ${r.text ? `— ${esc(r.text)}` : ''}</div>${r.reason ? `<div class="muted aiv-log-reason">${esc(r.reason)}</div>` : ''}</div><div class="aiv-log-meta">${cost}<span class="muted">${tsAgo(r.created_at)}</span></div></div>`;
}
async function pollLog() {
@@@ -272,7 +272,7 @@
const p = d.plan;
box.innerHTML = `
<div class="muted" style="font-size:0.78rem">Context: ${d.context ? `${Math.round(d.context.stable_chars / 4)} tokens stable (cached) + ${Math.round(d.context.volatile_chars / 4)} tokens fresh · heard ${d.context.sources.heardAgeMs != null ? ago(d.context.sources.heardAgeMs) : 'nothing'} · seen ${d.context.sources.seenAgeMs != null ? ago(d.context.sources.seenAgeMs) : 'never'} · ${d.context.sources.chatLines} chat lines · ${d.context.sources.people} people known` : ''}${d.cost != null ? ` · this preview cost ${money(d.cost)}${d.usage && d.usage.cached ? ` (${Math.round((d.usage.cached / d.usage.input) * 100)}% cached)` : ''}` : ''}</div>
-- ${p && p.lines && p.lines.length ? p.lines.map(l => `<div class="aiv-preview-line"><b>${esc(l.bot)}</b> <span class="muted">→ ${esc(l.target)}${l.reply_to ? ' @' + esc(l.reply_to) : ''} · +${Math.round((l.delay_ms || 0) / 1000)}s</span><br>${esc(l.text)}<br><span class="muted aiv-log-reason">${esc(l.reason || '')}</span></div>`).join('') : `<p class="muted">The director would stay quiet right now${p && p.notes ? ` — ${esc(p.notes)}` : ''}.</p>`}
++ ${p && p.lines && p.lines.length ? p.lines.map(l => `<div class="aiv-preview-line"><b>${esc(l.bot)}</b> <span class="muted">to ${esc(l.target)}${l.reply_to ? ' @' + esc(l.reply_to) : ''} · +${Math.round((l.delay_ms || 0) / 1000)}s</span><br>${esc(l.text)}<br><span class="muted aiv-log-reason">${esc(l.reason || '')}</span></div>`).join('') : `<p class="muted">The director would stay quiet right now${p && p.notes ? ` — ${esc(p.notes)}` : ''}.</p>`}
<details style="margin-top:6px"><summary class="muted" style="cursor:pointer;font-size:0.78rem">What the bots were shown</summary><pre class="aiv-pre">${esc(d.stable_preview || '')}\n\n---\n\n${esc(d.volatile_preview || '')}</pre></details>`;
} catch (e) { if (box) box.innerHTML = `<span class="muted">${esc(e.message || 'Preview failed')}</span>`; }
finally { if (btn) btn.disabled = false; }
ddiff --git a/public/js/app-channel.js b/public/js/app-channel.js
iindex 20cb0866..b26259a7 100644
---- a/public/js/app-channel.js
++++ b/public/js/app-channel.js
@@@ -3607,7 +3607,7 @@ function _startCaptions(username, displayName, isLive) {
panel.classList.remove('collapsed');
panel.innerHTML = `<div class="ch-captions-head">
<span class="ch-captions-title"><span class="ch-captions-dot"></span> <i class="fa-solid fa-closed-captioning"></i> Live translation</span>
-- <span class="ch-captions-sub">${esc(_chLang.flag || '')} ${esc(_chLang.name || '')} → 🇺🇸 English · what ${esc(displayName)} is saying</span>
++ <span class="ch-captions-sub">${esc(_chLang.flag || '')} ${esc(_chLang.name || '')} to 🇺🇸 English · what ${esc(displayName)} is saying</span>
<button class="ch-captions-toggle" type="button" onclick="toggleCaptionsPanel()" title="Collapse / expand"><i class="fa-solid fa-chevron-up"></i></button>
</div>
<div class="ch-captions-lines" id="ch-captions-lines"><div class="ch-captions-wait">Listening… lines land a few seconds behind live.</div></div>`;
ddiff --git a/public/js/broadcast-media-setup.js b/public/js/broadcast-media-setup.js
iindex f46cf91a..fced475d 100644
---- a/public/js/broadcast-media-setup.js
++++ b/public/js/broadcast-media-setup.js
@@@ -86,7 +86,7 @@
if (/Firefox\//.test(ua)) return 'Firefox shows the prompt just under the address bar. Tick “Remember this decision” so it stops asking.';
if (/Edg\//.test(ua)) return 'Edge shows the prompt under the address bar, on the left.';
if (/Chrome\//.test(ua)) return 'Chrome shows the prompt under the address bar, on the left.';
-- if (/Safari\//.test(ua)) return 'Safari asks once per site — if you miss it, use Safari → Settings for This Website.';
++ if (/Safari\//.test(ua)) return 'Safari asks once per site — if you miss it, use Settings for This Website in Safari.';
return 'Your browser will show the prompt near the address bar.';
}
@@@ -94,7 +94,7 @@
const ua = navigator.userAgent;
const what = KIND[kind]?.label || 'device';
if (/Firefox\//.test(ua)) return `Click the padlock in the address bar, then clear the blocked ${what} permission and reload.`;
-- if (/Safari\//.test(ua)) return `Open Safari → Settings for This Website, set ${what} to Allow, then reload.`;
++ if (/Safari\//.test(ua)) return `Open Settings for This Website in Safari, set ${what} to Allow, then reload.`;
return `Click the padlock (or camera icon) in the address bar, set ${what} to Allow, then reload.`;
}
ddiff --git a/public/js/broadcast-workspace.js b/public/js/broadcast-workspace.js
iindex b56497de..a3aa29ba 100644
---- a/public/js/broadcast-workspace.js
++++ b/public/js/broadcast-workspace.js
@@@ -717,7 +717,7 @@ function _wsRenderPanel() {
<label class="bc-ws-saver">
<input type="checkbox" id="bc-ws-battery-saver"${typeof batterySaverOn === 'function' && batterySaverOn() ? ' checked' : ''}
onchange="setBatterySaver(this.checked); if (broadcastState.activeStreamId) applyBroadcastQuality().catch(() => {})">
-- <span><b>Battery saver on this device</b><small>480p at 30 fps, whatever is set above: a cooler phone and a longer stream. Change it while live under More → Video.</small></span>
++ <span><b>Battery saver on this device</b><small>480p at 30 fps, whatever is set above: a cooler phone and a longer stream. Change it while live under More, then Video.</small></span>
</label>
</details>
</div>
@@@ -793,7 +793,7 @@ function _wsRenderPanel() {
</label>
<p class="muted" style="font-size:0.78rem;margin:4px 0 0">
Covers OpenVibe chat, RobotStreamer chat and every restream chat relay on this slot.
-- Each restream destination below also has its own <strong>→ PowerChat</strong> switch, so you can keep e.g. Twitch chat on the overlay but leave Kick off.
++ Each restream destination below also has its own <strong>PowerChat</strong> switch, so you can keep e.g. Twitch chat on the overlay but leave Kick off.
</p>
</div>
</div>
@@@ -1785,7 +1785,7 @@ function _wsRenderMethodEndpoint(method, streamKey, managedStreamId) {
<span class="bc-ws-method-info-note">Use the key shown above</span>
</div>
<p class="bc-ws-method-info-hint">
-- <strong>OBS:</strong> Settings &rarr; Stream &rarr; Service: Custom &rarr; paste server &amp; key above.<br>
++ <strong>OBS:</strong> Settings, then Stream, then Service: Custom, then paste server &amp; key above.<br>
<strong>Streamlabs / IRL Pro:</strong> Use Custom RTMP with the same server &amp; key.
</p>
</div>`;
@@@ -1823,7 +1823,7 @@ function _wsRenderMethodEndpoint(method, streamKey, managedStreamId) {
<span class="bc-ws-method-info-note">Use the stream key shown above as the Bearer Token in OBS.</span>
</div>
<p class="bc-ws-method-info-hint">
-- <strong>OBS WHIP setup:</strong> Settings &rarr; Stream &rarr; Service: WHIP &rarr; paste URL above.
++ <strong>OBS WHIP setup:</strong> Settings, then Stream, then Service: WHIP, then paste URL above.
Set Bearer Token to your stream key. Your stream will auto-start when OBS begins transmitting.
</p>
</div>`;
@@@ -1871,7 +1871,7 @@ function _wsRenderMethodEndpoint(method, streamKey, managedStreamId) {
${esc(jsmpegServer)}/${esc(streamKey)}/640/480/</pre>
<h5><i class="fa-solid fa-microchip"></i> Raspberry Pi Camera</h5>
-- <pre class="bc-ws-cli-code"># Pi Camera v2 / libcamera &rarr; JSMPEG
++ <pre class="bc-ws-cli-code"># Pi Camera v2 / libcamera to JSMPEG
rpicam-vid -t 0 --width 640 --height 480 --framerate 24 \\
--codec yuv420 -o - | \\
ffmpeg -f rawvideo -pix_fmt yuv420p -s 640x480 -r 24 -i - \\
@@@ -1936,7 +1936,7 @@ rpicam-vid -t 0 --width 640 --height 480 --framerate 24 \\
-f flv ${esc(rtmpServer)}/${esc(streamKey)}</pre>
<h5><i class="fa-solid fa-microchip"></i> Raspberry Pi Camera</h5>
-- <pre class="bc-ws-cli-code"># Pi Camera &rarr; RTMP
++ <pre class="bc-ws-cli-code"># Pi Camera to RTMP
rpicam-vid -t 0 --width 1280 --height 720 --framerate 30 \\
--codec h264 --bitrate 2000000 -o - | \\
ffmpeg -f h264 -i - -c:v copy -an \\
ddiff --git a/public/js/broadcast.js b/public/js/broadcast.js
iindex ebb9f550..161e2b4d 100644
---- a/public/js/broadcast.js
++++ b/public/js/broadcast.js
@@@ -54,7 +54,7 @@ function parseRsRestreamSlotValue() {
// Migrate to managed format in-place
const migratedId = ss.streamData.managed_stream_id;
localStorage.setItem('bc-rs-restream-slot', `m:${migratedId}`);
-- console.log(`[RS Slot] Migrated legacy s:${legacyId} → m:${migratedId}`);
++ console.log(`[RS Slot] Migrated legacy s:${legacyId} to m:${migratedId}`);
return { managedStreamId: migratedId };
}
}
ddiff --git a/public/js/channel-uploads.js b/public/js/channel-uploads.js
iindex 794a3617..1790edf6 100644
---- a/public/js/channel-uploads.js
++++ b/public/js/channel-uploads.js
@@@ -222,7 +222,7 @@
function renderSoundTab(body) {
body.innerHTML = `
<form class="cu-form" id="cu-sound-form">
-- <input type="text" id="cu-sound-cmd" maxlength="24" placeholder="Command name (e.g. airhorn) → !airhorn" autocomplete="off">
++ <input type="text" id="cu-sound-cmd" maxlength="24" placeholder="Command name (e.g. airhorn, then !airhorn)" autocomplete="off">
<input type="file" id="cu-sound-file" accept="audio/*" multiple>
<input type="text" id="cu-sound-emote" maxlength="32" placeholder="Attach an emote code (optional) — shows the emote instead of “played !cmd”" autocomplete="off">
<div id="cu-sound-preview" style="display:none;margin:2px 0"></div>
@@@ -303,7 +303,7 @@
</div>
<div class="cu-set-group">
<div class="cu-set-group-title">Channel Points</div>
-- <div class="cu-hint">Channel Points, rewards, and the redemption queue moved to your <b>Dashboard → Points</b> tab.</div>
++ <div class="cu-hint">Channel Points, rewards, and the redemption queue moved to the <b>Points</b> tab in your <b>Dashboard</b>.</div>
</div>
<button class="cu-btn" id="cu-set-save" type="button">Save channel settings</button>
<div class="cu-hint">Applies to everyone in your channel's chat. Pitch/speed limits live in your dashboard's moderation panel.</div>
ddiff --git a/public/js/chat.js b/public/js/chat.js
iindex 0dc5b465..482913b3 100644
---- a/public/js/chat.js
++++ b/public/js/chat.js
@@@ -1871,7 +1871,7 @@ function _updateTranslateHint(lang) {
if (!pill) { pill = document.createElement('span'); pill.className = 'chat-translate-hint'; row.appendChild(pill); }
const name = _LANG_LABEL[_chatChannelLang] || _chatChannelLang;
pill.title = `This streamer reads ${name}. Type in YOUR language — every message is auto-translated to ${name} for them, and their ${name} is translated to English for you.`;
-- pill.innerHTML = `<i class="fa-solid fa-language"></i> <span>Auto-translated ↔ ${esc(name)}</span>`;
++ pill.innerHTML = `<i class="fa-solid fa-language"></i> <span>Auto-translated with ${esc(name)}</span>`;
});
}
@@@ -2824,13 +2824,13 @@ function addRichSystemMessage(html, style = 'info') {
/* ── Event cards: someone went live, a Rundown episode is out ──────────────────
* Live posts these as system rows ("🔴 <name> is live. Share the stream: <url>", metadata.kind 'live_link';
-- * "📰 <name>: “<title>” → <url>", metadata.rundown_episode). They were italic blue sentences with a bare URL; now
++ * "📰 <name>: “<title>” at <url>", metadata.rundown_episode). They were italic blue sentences with a bare URL; now
* each is a small card like the deploy card: what happened, who, when (with the viewer's timestamp setting, always in
* global chat as messages are), a Watch/Read button and a copy-link button. Old rows are recognised by their text.
* The same person going live again within 15 minutes updates the card instead of adding another. Built from DOM
* nodes; a link is used only when it is an OpenVibe address. */
const _CHAT_EV_LIVE_RE = /^\s*\u{1F534}\s*(.+?) is live\. Share the stream:\s*(https?:\/\/\S+)\s*$/u;
--const _CHAT_EV_RUNDOWN_RE = /^\s*\u{1F4F0}\s*(.+?):\s*[“"](.+)[”"]\s*→\s*(https?:\/\/\S+)\s*$/u;
++const _CHAT_EV_RUNDOWN_RE = /^\s*\u{1F4F0}\s*(.+?):\s*[“"](.+)[”"]\s+(?:\u2192|at)\s+(https?:\/\/\S+)\s*$/u;
const _CHAT_EV_FOLD_MS = 15 * 60 * 1000;
function _chatEvSafeUrl(u) {
try {
@@@ -4272,7 +4272,7 @@ async function ctxRenameUsername(username, userId) {
if (newUsername == null || !newUsername.trim()) return;
try {
await api(`/admin/users/${userId}`, { method: 'PUT', body: { username: newUsername.trim() } });
-- toast(`Username changed: @${username} → @${newUsername.trim()}`, 'success');
++ toast(`Username changed: @${username} to @${newUsername.trim()}`, 'success');
} catch (err) {
toast('Failed to rename username: ' + (err.message || 'unknown error'), 'error');
}
@@@ -4284,7 +4284,7 @@ async function ctxRenameDisplayName(username, userId, currentDisplay) {
if (newName == null || !newName.trim()) return;
try {
await api(`/admin/users/${userId}`, { method: 'PUT', body: { display_name: newName.trim() } });
-- toast(`Display name changed: @${username} → ${newName.trim()}`, 'success');
++ toast(`Display name changed: @${username} to ${newName.trim()}`, 'success');
} catch (err) {
toast('Failed to rename display name: ' + (err.message || 'unknown error'), 'error');
}
ddiff --git a/public/js/dashboard.js b/public/js/dashboard.js
iindex caeb2719..a90cad13 100644
---- a/public/js/dashboard.js
++++ b/public/js/dashboard.js
@@@ -477,7 +477,7 @@ function showEndpointInfo(stream) {
} else if (proto === 'webrtc') {
info = `Protocol: WebRTC\n\nUse the browser "Start Broadcasting" feature.\nYour webcam/screen will be shared via Mediasoup SFU.`;
} else if (proto === 'rtmp') {
-- info = `Protocol: RTMP\n\nServer: rtmp://${host}:1935/live\nStream Key: ${stream.stream_key || '(use your stream key)'}\n\nIn OBS: Settings → Stream → Custom → paste above.`;
++ info = `Protocol: RTMP\n\nServer: rtmp://${host}:1935/live\nStream Key: ${stream.stream_key || '(use your stream key)'}\n\nIn OBS: Settings, then Stream, then Custom, then paste above.`;
}
el.textContent = info;
@@@ -991,7 +991,7 @@ function _bucksTxRow(t) {
else if (t.type === 'donation') { icon = 'fa-gift'; if (!t.label) label = `Received from ${esc(t.from_display || t.from_username || 'someone')}`; sign = '+'; cls = 'pos'; }
else if (t.type === 'cashout' || t.type === 'cashout_request') { icon = 'fa-money-bill-transfer'; if (!t.label) label = 'Cashout requested'; sign = '−'; cls = 'neg'; }
else if (t.type === 'refund') { icon = 'fa-rotate-left'; if (!t.label) label = 'Cashout refunded'; sign = '+'; cls = 'pos'; }
-- else if (t.type === 'recycle') { icon = 'fa-recycle'; if (!t.label) label = 'Moved cashout → spendable'; sign = ''; cls = ''; }
++ else if (t.type === 'recycle') { icon = 'fa-recycle'; if (!t.label) label = 'Moved cashout to spendable'; sign = ''; cls = ''; }
const url = t.subject && t.subject.url;
const text = url && /^https:\/\//.test(url)
? `<a href="${esc(url)}" target="_blank" rel="noopener">${esc(label)}</a>` : esc(label);
ddiff --git a/public/js/guides-live.js b/public/js/guides-live.js
iindex 9092d575..e05ef152 100644
---- a/public/js/guides-live.js
++++ b/public/js/guides-live.js
@@@ -172,8 +172,8 @@
const d = el.querySelector('#ovg-method-detail'); if (!d) return;
const key = D.key || '(loading…)';
if (D.method === 'browser') d.innerHTML = ui.box('Browser streaming', `<p>When you press <b>Go live</b> we'll ask for your camera and mic (or a screen to share). Nothing to install.</p><div class="ovg-seg"><button type="button" class="${D.mode === 'camera' ? 'on' : ''}" data-mode="camera"><i class="fa-solid fa-video"></i> Camera + mic</button><button type="button" class="${D.mode === 'screen' ? 'on' : ''}" data-mode="screen"><i class="fa-solid fa-display"></i> Share my screen</button></div><ul class="ovg-tips"><li>Phones work great — landscape looks best.</li><li>Good light beats a good camera.</li></ul>`, 'fa-globe');
-- else if (D.method === 'rtmp') d.innerHTML = ui.box('Paste these into OBS', `${ui.kv('Server', D.rtmpUrl || 'rtmp://openvibe.live/live', true)}${ui.secret('Stream key', key)}${ui.steps(['<b>Settings</b> → <b>Stream</b>', 'Service: <b>Custom…</b>', 'Paste the server and the key', '<b>OK</b>, then <b>Start Streaming</b>'])}<p class="muted ovg-fine">Keep the key private — anyone with it can stream as you. You can regenerate it on the Go Live page.</p>`, 'fa-desktop');
-- else d.innerHTML = ui.box('OBS 30+ over WHIP', `${ui.kv('Server', `${D.whipBase || location.origin}/whip/${D.slot.id}`, true)}${ui.secret('Bearer token', key)}${ui.steps(['<b>Settings</b> → <b>Stream</b>', 'Service: <b>WHIP</b>', 'Paste the server and the bearer token', '<b>OK</b>, then <b>Start Streaming</b>'])}<p class="muted ovg-fine">WHIP is WebRTC: viewers see you with under a second of delay.</p>`, 'fa-bolt');
++ else if (D.method === 'rtmp') d.innerHTML = ui.box('Paste these into OBS', `${ui.kv('Server', D.rtmpUrl || 'rtmp://openvibe.live/live', true)}${ui.secret('Stream key', key)}${ui.steps(['<b>Settings</b>, then <b>Stream</b>', 'Service: <b>Custom…</b>', 'Paste the server and the key', '<b>OK</b>, then <b>Start Streaming</b>'])}<p class="muted ovg-fine">Keep the key private — anyone with it can stream as you. You can regenerate it on the Go Live page.</p>`, 'fa-desktop');
++ else d.innerHTML = ui.box('OBS 30+ over WHIP', `${ui.kv('Server', `${D.whipBase || location.origin}/whip/${D.slot.id}`, true)}${ui.secret('Bearer token', key)}${ui.steps(['<b>Settings</b>, then <b>Stream</b>', 'Service: <b>WHIP</b>', 'Paste the server and the bearer token', '<b>OK</b>, then <b>Start Streaming</b>'])}<p class="muted ovg-fine">WHIP is WebRTC: viewers see you with under a second of delay.</p>`, 'fa-bolt');
ui.wire(d);
d.querySelectorAll('[data-mode]').forEach(b => b.addEventListener('click', async () => { D.mode = b.dataset.mode; d.querySelectorAll('[data-mode]').forEach(x => x.classList.remove('on')); b.classList.add('on'); try { await api(`/streams/managed/${D.slot.id}`, { method: 'PUT', body: { browser_mode: D.mode } }); D.slot.browser_mode = D.mode; } catch { /* */ } }));
};
@@@ -227,9 +227,9 @@
const [name, icon] = PLAT[plat] || PLAT.custom; const needsUrl = plat === 'custom' || plat === 'kick';
const oauth = (D.oauth && D.oauth[plat]) || null;
const canConnect = !!(oauth && oauth.configured);
-- const manualHelp = plat === 'twitch' ? 'Twitch → Creator Dashboard → Settings → Stream → copy the <b>Primary Stream key</b>.'
-- : plat === 'youtube' ? 'YouTube Studio → Go live → copy the <b>Stream key</b>.'
-- : plat === 'kick' ? 'Kick → Creator Dashboard → Settings → Stream key: copy the <b>URL</b> and the <b>key</b>.'
++ const manualHelp = plat === 'twitch' ? 'Twitch: open Creator Dashboard, then Settings, then Stream, then copy the <b>Primary Stream key</b>.'
++ : plat === 'youtube' ? 'YouTube Studio: go live, then copy the <b>Stream key</b>.'
++ : plat === 'kick' ? 'Kick: Creator Dashboard, then Settings, then Stream key: copy the <b>URL</b> and the <b>key</b>.'
: 'Paste the RTMP server URL and stream key from the service you want to mirror to.';
// Two ways in, and the reader picks. Signing in is one tap and fills the key in for them;
@@@ -313,7 +313,7 @@
one('offline', 'Offline screen', 'fa-image', async () => `${ui.lead('What visitors see on your channel when you are not live: an image, a looping video, or your own HTML page. It sits above your top content and the discover board.')}${ui.grid([ui.pick({ id: 'image', icon: 'fa-solid fa-image', title: 'Image', sub: 'a banner or a still from your stream' }), ui.pick({ id: 'video', icon: 'fa-solid fa-film', title: 'Video loop', sub: 'a short muted clip' }), ui.pick({ id: 'html', icon: 'fa-solid fa-code', title: 'Custom HTML', sub: 'anything you can build' })], 3)}<p class="muted ovg-fine">The upload lives in your dashboard — the button below takes you straight to it and points at the right spot.</p>`,
(el) => { el.querySelectorAll('[data-pick]').forEach(b => b.addEventListener('click', () => { el.querySelectorAll('[data-pick]').forEach(x => x.classList.remove('on')); b.classList.add('on'); })); },
async () => { dashboard('#dash-card-offline', 'Offline Screen', 'Pick an image, a looping video or your own HTML — this is what visitors see when you are not live.'); return true; }, 'Open the offline screen settings');
-- one('panels', 'Your About panels', 'fa-table-columns', async () => `${ui.lead('Panels are the blocks under your player: schedule, links, rules, gear, whatever you want people to know. They live on your channel page and in the About tab.')}${ui.steps(['Open your channel page', 'Tap <b>About</b> → <b>Edit panels</b>', 'Add a title and text (links and images work)', 'Save — it shows up for everyone right away'])}`,
++ one('panels', 'Your About panels', 'fa-table-columns', async () => `${ui.lead('Panels are the blocks under your player: schedule, links, rules, gear, whatever you want people to know. They live on your channel page and in the About tab.')}${ui.steps(['Open your channel page', 'Tap <b>About</b>, then <b>Edit panels</b>', 'Add a title and text (links and images work)', 'Save — it shows up for everyone right away'])}`,
null, async () => { const u = me(); G.close(false); G.cfg.navigate(`/@${(u && u.username) || ''}#about`); return true; }, 'Go to my channel');
one('powerchat', 'Connect PowerChat', 'fa-hand-holding-dollar', async () => `${ui.lead('PowerChat handles real-money tips (card and crypto) for OpenVibe streamers, with alerts on your stream and a tip link you can share anywhere.')}${ui.steps(['Press <b>Connect PowerChat</b> — you sign in there once', 'Tips land as chat alerts with the amount and message', 'Your channel gets a <b>Tip</b> button and a shareable tip link'])}<p class="muted ovg-fine">Nothing to pay to set up; PowerChat takes its cut per tip.</p>`,
null, async () => { G.close(false); location.href = '/api/powerchat/oauth/start'; return true; }, 'Connect PowerChat');
ddiff --git a/public/js/powerchat.js b/public/js/powerchat.js
iindex 9b034e61..94b98054 100644
---- a/public/js/powerchat.js
++++ b/public/js/powerchat.js
@@@ -95,7 +95,7 @@
: (notes.length ? notes.map(d => d.message).join(' ')
: (okDiag ? okDiag.message : 'Tips confirmed on PowerChat now flow into your goals, alerts, and chat automatically.'));
// Relay routing lives on the Broadcast page — streamers rarely find it unprompted.
-- hintEl.innerHTML = `${esc(base)}<br><span class="pc-tip"><i class="fa-solid fa-lightbulb"></i> Tip: chat from every stream slot and restream (Twitch, Kick, YouTube, RobotStreamer) is merged into your PowerChat overlay by default. To turn that off for a specific slot or platform, open <a href="/broadcast" onclick="event.preventDefault();navigate('/broadcast')">Broadcast</a> → a slot's <strong>PowerChat Overlay</strong> settings, or the <strong>→ PowerChat</strong> switches in each restream destination. The viewer count on your overlay is your total audience (OpenVibe + restream platforms + RobotStreamer) — the same switches let you leave a platform's viewers out.</span>`;
++ hintEl.innerHTML = `${esc(base)}<br><span class="pc-tip"><i class="fa-solid fa-lightbulb"></i> Tip: chat from every stream slot and restream (Twitch, Kick, YouTube, RobotStreamer) is merged into your PowerChat overlay by default. To turn that off for a specific slot or platform, open <a href="/broadcast" onclick="event.preventDefault();navigate('/broadcast')">Broadcast</a>, then a slot's <strong>PowerChat Overlay</strong> settings, or the <strong>PowerChat</strong> switches in each restream destination. The viewer count on your overlay is your total audience (OpenVibe + restream platforms + RobotStreamer) — the same switches let you leave a platform's viewers out.</span>`;
}
} else {
_swap(statusEl, '<span class="pc-dot pc-dot-off"></span> Not connected.');
ddiff --git a/public/kiosk.html b/public/kiosk.html
iindex 583951b5..236495a4 100644
---- a/public/kiosk.html
++++ b/public/kiosk.html
@@@ -654,7 +654,7 @@ button{font:inherit}
<script>
(() => {
"use strict";
-- // Served from openvibe.live/kiosk → everything is same-origin (no CORS, no cross-host WS).
++ // Served from openvibe.live/kiosk, so everything is same-origin (no CORS, no cross-host WS).
const API="/api";
const WS_URL=(location.protocol==="https:"?"wss://":"ws://")+location.host+"/ws/chat";
const $=s=>document.querySelector(s);
@@@ -1046,7 +1046,7 @@ button{font:inherit}
const LS_PROV="openvibe_kiosk_search", LS_HIST="openvibe_kiosk_history";
let provider=localStorage.getItem(LS_PROV); if(!PROVIDERS[provider]) provider="google";
let hist=[]; try{ hist=JSON.parse(localStorage.getItem(LS_HIST)||"[]"); if(!Array.isArray(hist)) hist=[]; }catch{}
-- const resolved=new Map(); // domain(lowercased) → {reachable,url,host,title}
++ const resolved=new Map(); // domain (lowercased): {reachable,url,host,title}
const resolving=new Set();
let curItems=[], selIdx=-1, isOpen=false, resolveTimer=null;
@@@ -1130,7 +1130,7 @@ button{font:inherit}
if(looksLikeDomain(q)){
const key=q.toLowerCase(), c=resolved.get(key);
if(c && !c.reachable){
-- // resolved as unreachable → offer search only
++ // resolved as unreachable, so offer search only
} else if(c && c.reachable){
add({type:"domain",title:c.title||q,sub:c.host+(c.title?" · "+c.title:""),host:c.host,url:c.url});
} else {
@@@ -1244,7 +1244,7 @@ button{font:inherit}
document.addEventListener("click",e=>{ if(!provMenu.hidden && !provMenu.contains(e.target) && e.target!==provBtn && !provBtn.contains(e.target)) setProvMenu(false); });
document.addEventListener("keydown",e=>{ if(e.key==="Escape" && !provMenu.hidden){ e.stopPropagation(); setProvMenu(false); } },true);
-- // Global: start typing anywhere → focus the (possibly ghosted or hidden) bar and seed it.
++ // Global: start typing anywhere, focus the (possibly ghosted or hidden) bar and seed it.
document.addEventListener("keydown",e=>{
if(document.activeElement===input) return; // already typing in the bar — let it be
if(e.key==="Escape") return;
ddiff --git a/public/obs/chat.html b/public/obs/chat.html
iindex ac39f150..e81bfae6 100644
---- a/public/obs/chat.html
++++ b/public/obs/chat.html
@@@ -502,7 +502,7 @@ html[data-ov-motion=off] .name-fx-twofat{filter: drop-shadow(0 0 3px rgba(255, 4
let reconnectDelay = RECONNECT_BASE;
let reconnectTimer = null;
let intentionalClose = false;
-- let emoteMap = {}; // code → {url, animated}
++ let emoteMap = {}; // code: {url, animated}
let fadeTimers = [];
// ── URL params for customization ───────────────────────────
@@@ -531,7 +531,7 @@ html[data-ov-motion=off] .name-fx-twofat{filter: drop-shadow(0 0 3px rgba(255, 4
document.body.classList.add('no-shadow');
}
}
-- // ?streamtitle=1 → show which stream slot each message came from (default off).
++ // ?streamtitle=1 shows which stream slot each message came from (default off).
// Handy for a per-user overlay aggregating several live slots.
const showStreamTitle = ['1', 'true', 'on', 'yes'].includes(String(params.get('streamtitle') || '').toLowerCase());
@@@ -629,7 +629,7 @@ html[data-ov-motion=off] .name-fx-twofat{filter: drop-shadow(0 0 3px rgba(255, 4
YouTube: '#ff0000',
RS: '#7dd3fc',
};
-- // Relay platform → icon badge (replaces the old "[Twitch]/[RS]" text prefix).
++ // Relay platform: icon badge (replaces the old "[Twitch]/[RS]" text prefix).
const RELAY_BADGE = {
twitch: { icon: 'fa-brands fa-twitch', label: 'Twitch' },
kick: { icon: 'fa-solid fa-bolt', label: 'Kick' },
@@@ -705,7 +705,7 @@ html[data-ov-motion=off] .name-fx-twofat{filter: drop-shadow(0 0 3px rgba(255, 4
// Suppress noisy OpenVibe.Live join/system lines (a clean "connected" shows on open).
if (msg.type === 'system' && OV_JOIN_RE.test(_rawText)) return;
-- // RobotStreamer relayed noise → suppress; announce RS connection once on first RS msg.
++ // RobotStreamer relayed noise: suppress; announce RS connection once on first RS msg.
if (_isRs) {
if (!_rsConnectedShown) { _rsConnectedShown = true; _showConnected('Connected to RobotStreamer chat successfully'); }
if (RS_NOISE_RE.test(_rawText)) return;
ddiff --git a/public/popout-chat.html b/public/popout-chat.html
iindex 6fb94fcc..163713ff 100644
---- a/public/popout-chat.html
++++ b/public/popout-chat.html
@@@ -275,7 +275,7 @@
(function parseUrl() {
const seg = window.location.pathname.split('/').filter(Boolean); // ['popout'|'popout-chat', user?, id?]
if (seg[0] === 'popout' || seg[0] === 'popout-chat') {
-- // No username (or explicit "global") → global chat.
++ // No username (or explicit "global") means global chat.
if (!seg[1] || seg[1].toLowerCase() === 'global') { mode = 'global'; return; }
mode = 'channel';
channelName = decodeURIComponent(seg[1]);
@@@ -314,7 +314,7 @@
// across tabs by the shared storage listener.
// Request the viewer list over the shared chat WebSocket. The reply is
-- // handled by chat.js → renderChatUsersList(), which now also fills #pc-users-body.
++ // handled by chat.js: renderChatUsersList(), which now also fills #pc-users-body.
window.requestUserList = function() {
try {
if (typeof chatWs !== 'undefined' && chatWs && chatWs.readyState === WebSocket.OPEN) {
@@@ -407,9 +407,9 @@
}
// Decide which stream the popout SHOULD be in right now:
-- // pinned stream while it's live → the stream we're already in while it's live
-- // (concurrent slots can reorder by viewer count — never flap between them) →
-- // any other live stream → channel chat (null).
++ // pinned stream while it's live: the stream we're already in while it's live
++ // (concurrent slots can reorder by viewer count — never flap between them), then
++ // any other live stream: channel chat (null).
function desiredTarget(liveStreams) {
const liveIds = liveStreams.map(s => Number(s.id));
if (pinnedStreamId && liveIds.includes(Number(pinnedStreamId))) return Number(pinnedStreamId);
ddiff --git a/public/whip-publisher.html b/public/whip-publisher.html
iindex b95ba87e..51a2f259 100644
---- a/public/whip-publisher.html
++++ b/public/whip-publisher.html
@@@ -69,7 +69,7 @@
<div class="card">
<label for="whip-url">WHIP URL</label>
<input id="whip-url" type="url" placeholder="https://ingest.openvibe.live/whip/&lt;your-stream-key&gt;" autocomplete="off" spellcheck="false">
-- <p class="hint">Copy it from <strong>Broadcast → your stream → Streaming method: WHIP</strong>. The key inside the URL is your credential — treat the URL like a password. It is remembered in this browser only.</p>
++ <p class="hint">Copy it from <strong>Broadcast, then your stream, then Streaming method: WHIP</strong>. The key inside the URL is your credential — treat the URL like a password. It is remembered in this browser only.</p>
<label for="whip-token">Bearer token <span style="text-transform:none;letter-spacing:0">(optional — only needed if your URL uses the numeric slot form <code>/whip/42</code>)</span></label>
<input id="whip-token" type="password" placeholder="stream key" autocomplete="off">
@@@ -216,7 +216,7 @@
els.preview.srcObject = stream;
for (const track of stream.getTracks()) {
log(`${track.kind} track: ${track.label || 'unnamed'}`);
-- // "Stop sharing" in the browser's screen-share bar ends the track → end the session.
++ // "Stop sharing" in the browser's screen-share bar ends the track, so end the session.
track.addEventListener('ended', () => { if (state.phase === 'live' || state.phase === 'connecting') stop('source ended'); });
}
ddiff --git a/server/ai/ai-service.js b/server/ai/ai-service.js
iindex 21b08d64..20d4619a 100644
---- a/server/ai/ai-service.js
++++ b/server/ai/ai-service.js
@@@ -77,7 +77,7 @@ async function _fetchJson(method, path, body, timeoutMs, retried = false) {
if (!res.ok && res.status !== 202) {
// AI is resting (ai.resting): keep its sentence so a product can show it instead of "could not answer".
if (res.status === 503 && json && json.code === 'ai.resting') _lastProblem = { ...RESTING, retry_after_seconds: Number(res.headers.get('retry-after')) || null };
-- warn(`${method} ${path} -> ${res.status} ${(json && (json.code || json.detail)) || ''}`);
++ warn(`${method} ${path} returned ${res.status} ${(json && (json.code || json.detail)) || ''}`);
return null;
}
return json;
ddiff --git a/server/ai/auto-clip-job.js b/server/ai/auto-clip-job.js
iindex c8553c70..20c3e268 100644
---- a/server/ai/auto-clip-job.js
++++ b/server/ai/auto-clip-job.js
@@@ -183,7 +183,7 @@ async function _checkLiveStream(stream) {
// deduplicated: Media handed back a clip someone already cut of this window. It stays in
// the log for the caps, but it is theirs, not the AI's (syncAutoClipFlags skips it).
await _logClip({ stream_id: streamId, vod_id: rec.vodId, start_time: clipStart, title, clip_id: clip.id, dedup: !!clip.deduplicated || undefined, desc: verdict.desc || '', speech, sig: _sceneSig(verdict.desc || title), score: rated.score, visibility: rated.verdict === 'public' ? 'public' : 'unlisted', bucket: rated.bucket, peak: peak.count, chatters: chatters == null ? undefined : chatters });
-- console.log(`[AutoClip] LIVE clip for stream ${streamId} @${Math.round(momentOffset)}s ("${title}") — spike ${peak.count} msgs, score ${rated.score} → ${rated.verdict}${cut.snapped ? ', on sentences' : ''}`);
++ console.log(`[AutoClip] LIVE clip for stream ${streamId} @${Math.round(momentOffset)}s ("${title}") — spike ${peak.count} msgs, score ${rated.score}, verdict ${rated.verdict}${cut.snapped ? ', on sentences' : ''}`);
}
} catch (e) {
console.warn(`[AutoClip] live check failed for stream ${streamId}:`, e.message);
@@@ -454,7 +454,7 @@ async function clipVodMoment(o) {
await _logClip({ stream_id: vod.stream_id || null, vod_id: vodId, start_time: start, title: title || 'Standout Moment', clip_id: clip.id, dedup: !!clip.deduplicated || undefined, desc: desc || '', speech, archival: archival || undefined, sig: _sceneSig(desc || title),
score: rated.score, visibility: wanted, bucket: rated.bucket, peak: Number(sp.peak) || 0, chatters: evidence.chatters == null ? undefined : evidence.chatters,
picked_by: evidence.picked_by || undefined, policy: VOD_POLICY, verified: verified ? verified.verdict : undefined });
-- console.log(`[AutoClip] VOD ${vodId} @${Math.round(offset)}s cut (${evidence.picked_by || 'signals'}) — score ${rated.score} → ${wanted}${verified ? ` (check: ${verified.verdict})` : ''}, bucket ${rated.bucket}${cut.snapped ? ', on sentences' : ''}`);
++ console.log(`[AutoClip] VOD ${vodId} @${Math.round(offset)}s cut (${evidence.picked_by || 'signals'}) — score ${rated.score}, verdict ${wanted}${verified ? ` (check: ${verified.verdict})` : ''}, bucket ${rated.bucket}${cut.snapped ? ', on sentences' : ''}`);
}
return clip || null;
} catch { return null; }
ddiff --git a/server/ai/context/index.js b/server/ai/context/index.js
iindex 8e01a340..61a5fe96 100644
---- a/server/ai/context/index.js
++++ b/server/ai/context/index.js
@@@ -224,7 +224,7 @@ async function threadsBlock(channelUserId, limit) {
rows,
text: rows.map(t => {
let who = []; try { who = JSON.parse(t.participants_json || '[]'); } catch { /* */ }
-- return `- thread #${t.id} (${t.kind.replace('_', '↔')}, ${who.join(' & ')}${t.topic ? `, about: ${clip(t.topic, 60)}` : ''}, ${t.turns} turns)${t.last_line ? ` last: ${t.last_line_by}: "${clip(t.last_line, 120)}"` : ''}${t.awaiting ? ` — waiting on ${t.awaiting}` : ''}`;
++ return `- thread #${t.id} (${t.kind.replace('_', ' to ')}, ${who.join(' & ')}${t.topic ? `, about: ${clip(t.topic, 60)}` : ''}, ${t.turns} turns)${t.last_line ? ` last: ${t.last_line_by}: "${clip(t.last_line, 120)}"` : ''}${t.awaiting ? ` — waiting on ${t.awaiting}` : ''}`;
}).join('\n'),
};
}
ddiff --git a/server/ai/descriptions.js b/server/ai/descriptions.js
iindex 144983fa..3a5cc213 100644
---- a/server/ai/descriptions.js
++++ b/server/ai/descriptions.js
@@@ -37,7 +37,7 @@ function plainText(value) {
}
text = db.publicMemoryText(text)
.replace(/<[^>]*>/g, ' ')
-- .replace(/-->/g, '→')
++ .replace(/-->/g, ' ')
.replace(/\s+/g, ' ')
.trim();
return text;
ddiff --git a/server/ai/stream-audio.js b/server/ai/stream-audio.js
iindex e5a91d92..2016c333 100644
---- a/server/ai/stream-audio.js
++++ b/server/ai/stream-audio.js
@@@ -372,7 +372,7 @@ async function startContinuousCapture(stream) {
};
ff.on('close', onExit);
ff.on('error', (e) => onExit(e && e.message));
-- if (!retry) console.log(`[AI-Hear] stream ${stream.id}: continuous capture started (${SEGMENT_SEC}s segments → ${dir})`);
++ if (!retry) console.log(`[AI-Hear] stream ${stream.id}: continuous capture started (${SEGMENT_SEC}s segments to ${dir})`);
return true;
}
ddiff --git a/server/ai/timeline-job.js b/server/ai/timeline-job.js
iindex e248e127..63cd9abe 100644
---- a/server/ai/timeline-job.js
++++ b/server/ai/timeline-job.js
@@@ -206,7 +206,7 @@ let _interval = null;
function start() {
if (_interval) return;
_interval = setInterval(() => { tick().catch(() => {}); }, POLL_MS);
-- console.log('[AI] Timeline job started (continuous audio → stream_timeline_events)');
++ console.log('[AI] Timeline job started (continuous audio to stream_timeline_events)');
}
/**
ddiff --git a/server/ai/viewers/engine.js b/server/ai/viewers/engine.js
iindex d46c78e0..cfb0c417 100644
---- a/server/ai/viewers/engine.js
++++ b/server/ai/viewers/engine.js
@@@ -256,7 +256,7 @@ class AiViewersEngineV3 {
const accepted = await this._acceptPlan(w, res.plan, bots, consumed);
await poster.log(w, {
event: 'tick', reason: res.plan.notes || (res.plan.skip ? 'director: skip' : `planned ${accepted} line(s)`),
-- text: accepted ? res.plan.lines.slice(0, accepted).map(l => `${l.bot}→${l.target}${l.reply_to ? '@' + l.reply_to : ''}`).join(', ') : null,
++ text: accepted ? res.plan.lines.slice(0, accepted).map(l => `${l.bot} to ${l.target}${l.reply_to ? '@' + l.reply_to : ''}`).join(', ') : null,
tokens_in: res.usage.input, tokens_cached: res.usage.cached, tokens_out: res.usage.output, cost_usd: res.cost, model: res.model,
});
for (const id of res.plan.threads_close || []) { const n = parseInt(id, 10); if (n) { try { await db.closeAiViewerThread(n); } catch { /* */ } } }
ddiff --git a/server/auth/usernames.js b/server/auth/usernames.js
iindex 5eb87236..7b2bcc5e 100644
---- a/server/auth/usernames.js
++++ b/server/auth/usernames.js
@@@ -27,12 +27,12 @@ async function syncUsername(userId, newName) {
const user = await db.get('SELECT id, username FROM users WHERE id = ?', [userId]);
if (!user || String(user.username || '').toLowerCase() === newName.toLowerCase()) return false;
const clash = await db.get('SELECT id FROM users WHERE lower(username) = lower(?) AND id != ?', [newName, userId]);
-- if (clash) { console.warn(`[Usernames] ${user.username} → ${newName}: another Live user holds that name; not renamed`); return false; }
++ if (clash) { console.warn(`[Usernames] ${user.username} to ${newName}: another Live user holds that name; not renamed`); return false; }
await db.getDb().tx(async () => {
await db.run('INSERT INTO username_history (user_id, old_username, new_username) VALUES (?, ?, ?)', [user.id, user.username, newName]);
await db.run('UPDATE users SET username = ? WHERE id = ?', [newName, user.id]);
});
-- console.log(`[Usernames] renamed ${user.username} → ${newName} (Network)`);
++ console.log(`[Usernames] renamed ${user.username} to ${newName} (Network)`);
return true;
}
ddiff --git a/server/drill.js b/server/drill.js
iindex c4355ab0..9cad8efe 100644
---- a/server/drill.js
++++ b/server/drill.js
@@@ -80,7 +80,7 @@ function problems(env = process.env, { repoRoot = REPO_ROOT, cwd = process.cwd()
const abs = path.resolve(cwd, value);
const real = realish(abs);
const root = roots.find((r) => inside(abs, r) || inside(real, r));
-- if (root) out.push(`${name} (${abs}${real !== abs ? ` → ${real}` : ''}) is inside ${root}; a drill writes nothing in the checkout or in production's data`);
++ if (root) out.push(`${name} (${abs}${real !== abs ? ` resolves to ${real}` : ''}) is inside ${root}; a drill writes nothing in the checkout or in production's data`);
};
// The database: the restored copy, never production's. Boot migrates through DATABASE_DIRECT_URL, so it must name
// the copy too whenever DATABASE_URL does.
ddiff --git a/server/events/stream-events.js b/server/events/stream-events.js
iindex 3ab6adcb..2b87982f 100644
---- a/server/events/stream-events.js
++++ b/server/events/stream-events.js
@@@ -111,7 +111,7 @@ function init({ eventsUrl = EVENTS_URL, clientSecret = CLIENT_SECRET, fetchImpl,
outbox.start();
pruneTimer = setInterval(() => { if (outbox) outbox.prune().catch(() => { /* next time */ }); }, PRUNE_EVERY_MS);
if (pruneTimer.unref) pruneTimer.unref();
-- outbox.pending().then((n) => console.log(`[Events] stream lifecycle → ${eventsUrl} (${n} pending)`), () => {});
++ outbox.pending().then((n) => console.log(`[Events] stream lifecycle to ${eventsUrl} (${n} pending)`), () => {});
return outbox;
}
ddiff --git a/server/integrations/chat-relay-service.js b/server/integrations/chat-relay-service.js
iindex 01dd984f..c8acd00c 100644
---- a/server/integrations/chat-relay-service.js
++++ b/server/integrations/chat-relay-service.js
@@@ -151,7 +151,7 @@ class ChatRelayService {
// every relayed message is duplicated (once per stale overlapping bridge).
for (const [existingKey, b] of this.bridges) {
if (b.destId === dest.id && b.streamId !== streamId) {
-- console.log(`[ChatRelay] Replacing stale bridge ${existingKey} -> ${key} (same destination, prevents duplicate relay)`);
++ console.log(`[ChatRelay] Replacing stale bridge ${existingKey} with ${key} (same destination, prevents duplicate relay)`);
this._teardown(b);
this.bridges.delete(existingKey);
}
@@@ -881,7 +881,7 @@ class ChatRelayService {
try {
const current = await findLiveChatId();
if (current && current !== liveChatId) {
-- console.log(`[ChatRelay] YouTube: live chat rotated for stream ${bridge.streamId} → ${current}`);
++ console.log(`[ChatRelay] YouTube: live chat rotated for stream ${bridge.streamId} to ${current}`);
liveChatId = current;
bridge._ytPageToken = undefined;
bridge._ytFirstPoll = true; // don't dump the new chat's backlog
ddiff --git a/server/integrations/platform-oauth.js b/server/integrations/platform-oauth.js
iindex 75cf39e0..a499f321 100644
---- a/server/integrations/platform-oauth.js
++++ b/server/integrations/platform-oauth.js
@@@ -149,7 +149,7 @@ async function postForm(url, formObj, headers = {}) {
try { json = text ? JSON.parse(text) : {}; } catch { /* non-json */ }
if (!res.ok) {
const msg = (json && (json.message || json.error_description || json.error)) || text || res.statusText;
-- throw new Error(`${url} -> ${res.status}: ${msg}`);
++ throw new Error(`${url} returned ${res.status}: ${msg}`);
}
return json || {};
}
@@@ -200,7 +200,7 @@ async function getJson(url, headers) {
try { json = text ? JSON.parse(text) : {}; } catch { /* */ }
if (!res.ok) {
const msg = (json && (json.message || json.error?.message || json.error)) || text || res.statusText;
-- throw new Error(`${url} -> ${res.status}: ${msg}`);
++ throw new Error(`${url} returned ${res.status}: ${msg}`);
}
return json || {};
}
@@@ -286,7 +286,7 @@ async function createYouTubeStream(headers) {
body: JSON.stringify(body),
});
const json = await res.json().catch(() => ({}));
-- if (!res.ok) throw new Error(`YouTube liveStreams.insert -> ${res.status}: ${json.error?.message || res.statusText}`);
++ if (!res.ok) throw new Error(`YouTube liveStreams.insert returned ${res.status}: ${json.error?.message || res.statusText}`);
return json;
}
ddiff --git a/server/integrations/robotstreamer-service.js b/server/integrations/robotstreamer-service.js
iindex 585b678b..b677596a 100644
---- a/server/integrations/robotstreamer-service.js
++++ b/server/integrations/robotstreamer-service.js
@@@ -859,7 +859,7 @@ class RobotStreamerService {
};
outgoing = JSON.stringify(msg);
}
-- console.log(`[RS Publish] Client → SFU: ${msg.method} (id=${msg.id}) | upstream ready: ${upstreamReady} | queued: ${outboundQueue.length}`);
++ console.log(`[RS Publish] Client to SFU: ${msg.method} (id=${msg.id}) | upstream ready: ${upstreamReady} | queued: ${outboundQueue.length}`);
}
if (!upstreamReady) outboundQueue.push(outgoing);
@@@ -992,11 +992,11 @@ class RobotStreamerService {
const parsed = safeJsonParse(raw);
if (parsed?.response) {
const status = parsed.ok === false ? 'ERROR' : 'OK';
-- console.log(`[RS Publish] SFU → client: response id=${parsed.id} ${status}`, parsed.ok === false ? JSON.stringify({ error: parsed.error, reason: parsed.reason }) : '');
++ console.log(`[RS Publish] SFU to client: response id=${parsed.id} ${status}`, parsed.ok === false ? JSON.stringify({ error: parsed.error, reason: parsed.reason }) : '');
} else if (parsed?.request) {
-- console.log(`[RS Publish] SFU → client: request method=${parsed.method}`);
++ console.log(`[RS Publish] SFU to client: request method=${parsed.method}`);
} else if (parsed?.notification) {
-- console.log(`[RS Publish] SFU → client: notification method=${parsed.method}`);
++ console.log(`[RS Publish] SFU to client: notification method=${parsed.method}`);
}
});
ddiff --git a/server/integrations/rs-passthrough-relay.js b/server/integrations/rs-passthrough-relay.js
iindex 76754d33..99934238 100644
---- a/server/integrations/rs-passthrough-relay.js
++++ b/server/integrations/rs-passthrough-relay.js
@@@ -482,7 +482,7 @@ class RsPassthroughRelay {
session.state = 'restarting';
session.nextRestartAt = Date.now() + delay;
log(session.streamId, `⚠️ passthrough restart #${session.restarts}/${MAX_RESTARTS} in ${delay}ms — reason: ${reason} ` +
-- `(RS viewers see this as the video cutting out: new SSRC → new consumer → client blanks the MediaStream)`);
++ `(RS viewers see this as the video cutting out: new SSRC, then a new consumer, then the client blanks the MediaStream)`);
session.restartTimer = setTimeout(async () => {
session.restartTimer = null;
if (session.stopped) return;
@@@ -632,7 +632,7 @@ class RsPassthroughRelay {
const isH264 = /h264/i.test(videoIn.mimeType || 'video/VP8');
const videoCodec = isH264 ? useH264({ payloadType: 103 }) : useVP8({ payloadType: 101 });
const audioCodec = useOPUS({ payloadType: 100 });
-- log(sid, `source video codec: ${videoIn.mimeType} → producing ${videoCodec.mimeType}`);
++ log(sid, `source video codec ${videoIn.mimeType}, producing ${videoCodec.mimeType}`);
const pc = new RTCPeerConnection({
codecs: { video: [videoCodec], audio: [audioCodec] },
headerExtensions: {
@@@ -1040,7 +1040,7 @@ class RsPassthroughRelay {
// RS was told one codec; a source that changed codec needs a new RS producer (a restart).
if (kind === 'video' && String(next.mimeType).toLowerCase() !== String(videoIn.mimeType).toLowerCase()) {
closeIngest(next);
-- log(sid, `source video codec changed (${videoIn.mimeType} → ${next.mimeType}) — restarting for a new RS producer`);
++ log(sid, `source video codec changed (${videoIn.mimeType} to ${next.mimeType}) — restarting for a new RS producer`);
this._teardown(session);
this._scheduleRestart(session, 'source video codec changed').catch(err => log(sid, 'restart check error:', err.message));
return false;
@@@ -1050,7 +1050,7 @@ class RsPassthroughRelay {
session.ingests = session.ingests.filter(i => i !== cur).concat(next);
closeIngest(cur);
stats.switches = (stats.switches || 0) + 1;
-- log(sid, `↪ ${kind} now from producer ${producer.id} (${why}) — same RS producer and SSRC, no restart`);
++ log(sid, `switched: ${kind} now from producer ${producer.id} (${why}) — same RS producer and SSRC, no restart`);
if (kind === 'video') { reqKeyNow(0); setTimeout(() => reqKeyNow(250), 400); }
return true;
} finally { _switching = false; }
@@@ -1143,7 +1143,7 @@ class RsPassthroughRelay {
`aIn ${Math.round((stats.aIn - lastAIn) / 10)}/s aOut ${Math.round((stats.aOut - lastAOut) / 10)}/s${stalled} ` +
`| writeErr v=${stats.vErr} a=${stats.aErr} alienSsrc v=${stats.vAlien} a=${stats.aAlien} | stalls=${stats.ingestStalls} (${Math.round(stats.ingestStallMs / 1000)}s total) ` +
`| kf ${stats.kf - lastKf}/10s maxKfGap ${stats.maxKfGap}ms injSPS/PPS=${stats.inj}${INJECT_PARAMS ? '' : '(off)'} gopDmg=${stats.gopDamaged} ` +
-- `| lossIN ${stats.lostIn - lastLostIn} (goosely→relay) lateFill ${stats.lateFill - lastLateFill} ` +
++ `| lossIN ${stats.lostIn - lastLostIn} (goosely to relay) lateFill ${stats.lateFill - lastLateFill} ` +
`| RS lost=${rsLostPct}% pktsLost=${s.remotePacketsLost ?? '?'} pliRx=${s.pliCount ?? '?'} firRx=${s.firCount ?? '?'} nackRx=${s.nackCount ?? '?'} retx=${s.retransmittedPacketsSent ?? '?'} deepRtx=${stats.rtxDeep} rtxMiss=${stats.rtxMiss} rtxTooOld=${stats.rtxTooOld} rtt=${Math.round((s.rtt || 0) * 1000)}ms ` +
`| rsRemb=${remb} bwe=${bwe} | keyReq=${stats.pli} werift=${pc.connectionState} dtls/ice=${vSenderState()}`);
lastVIn = stats.vIn; lastVOut = stats.vOut; lastAIn = stats.aIn; lastAOut = stats.aOut;
ddiff --git a/server/media-proxy/comments.js b/server/media-proxy/comments.js
iindex 698174b5..9e3b945b 100644
---- a/server/media-proxy/comments.js
++++ b/server/media-proxy/comments.js
@@@ -291,7 +291,7 @@ router.post('/:type/:id', requireAuth, async (req, res) => {
priority: 'normal',
title: parentId ? `New reply on your ${target.label}` : `New comment on your ${target.label}`,
message: `${actor.sender_name} ${parentId ? 'replied on' : 'commented on'} "${truncatePreview(target.title, 80)}"`,
-- icon: parentId ? '↩️' : '💬',
++ icon: '💬',
...actor,
service: 'live',
url: `${target.url}#comments`,
@@@ -311,7 +311,7 @@ router.post('/:type/:id', requireAuth, async (req, res) => {
priority: 'normal',
title: 'New reply to your comment',
message: `${actor.sender_name} replied to your comment on "${truncatePreview(target.title, 80)}"`,
-- icon: '↩️',
++ icon: '💬',
...actor,
service: 'live',
url: `${target.url}#comments`,
ddiff --git a/server/media-proxy/pastes.js b/server/media-proxy/pastes.js
iindex dbd4c5f9..8b4dd114 100644
---- a/server/media-proxy/pastes.js
++++ b/server/media-proxy/pastes.js
@@@ -89,7 +89,7 @@ function toCommunity(subPath, whenDown = null) {
}
res.send(Buffer.from(await upstream.arrayBuffer()));
} catch (err) {
-- console.warn('[Pastes → Community]', err.message);
++ console.warn('[Pastes to Community]', err.message);
if (!res.headersSent) {
if (whenDown) return res.json(whenDown(req));
res.status(502).json({ error: 'Paste service unavailable' });
@@@ -115,7 +115,7 @@ communityRouter.post('/:slug/set-avatar', requireAuth, async (req, res) => {
} catch { /* next report */ }
res.json({ success: true, avatar_url: stored });
} catch (err) {
-- console.warn('[Pastes → Community] set-avatar:', err.message);
++ console.warn('[Pastes to Community] set-avatar:', err.message);
res.status(500).json({ error: 'Failed to set avatar' });
}
});
ddiff --git a/server/rundown/chat-commands.js b/server/rundown/chat-commands.js
iindex 704f66af..923a075e 100644
---- a/server/rundown/chat-commands.js
++++ b/server/rundown/chat-commands.js
@@@ -35,19 +35,19 @@ async function handle(chat, ws, client, cmd, parts) {
if (limited(_last, voterKey(client), RATE_MS)) { reply('Easy — one command every few seconds.'); return true; }
const work = (async () => {
try {
-- if (cmd !== '!arena') return reply(`Beefs are gone. The Rundown writes up what streamers talk about instead → ${base()}/rundown`);
++ if (cmd !== '!arena') return reply(`Beefs are gone. The Rundown writes up what streamers talk about instead: ${base()}/rundown`);
const stream = client.streamId ? await db.getStreamById(client.streamId) : null;
const target = parts[1] ? await db.get('SELECT id, username, display_name FROM users WHERE lower(username) = lower(?)', [String(parts[1]).replace(/^@/, '')])
: (stream ? await db.get('SELECT id, username, display_name FROM users WHERE id = ?', [stream.user_id]) : null);
-- if (!target) return reply(parts[1] ? `Nobody called ${String(parts[1]).slice(0, 30)} here.` : `What streamers talked about, written up → ${base()}/rundown`);
++ if (!target) return reply(parts[1] ? `Nobody called ${String(parts[1]).slice(0, 30)} here.` : `What streamers talked about, written up at ${base()}/rundown`);
const read = require('./read');
const page = await read.streamer(target.username);
const name = target.display_name || target.username;
if (!page || page.opted_out) return reply(`${name} keeps themselves out of The Rundown.`);
const ep = page.episodes[0];
const topics = page.topics.slice(0, 3).map((t) => t.title);
-- if (!ep && !topics.length) return reply(`Nothing on The Rundown about ${name} yet: it takes talk on a transcribed stream → ${base()}/rundown`);
-- return reply(`📰 ${name}${ep ? ` — latest episode: “${ep.title}”` : ''}${topics.length ? ` · talks about ${topics.join(', ')}` : ''} → ${base()}/rundown/${encodeURIComponent(target.username)}`);
++ if (!ep && !topics.length) return reply(`Nothing on The Rundown about ${name} yet: it takes talk on a transcribed stream. See ${base()}/rundown`);
++ return reply(`📰 ${name}${ep ? ` — latest episode: “${ep.title}”` : ''}${topics.length ? ` · talks about ${topics.join(', ')}` : ''} at ${base()}/rundown/${encodeURIComponent(target.username)}`);
} catch (err) {
reply(`The Rundown: ${err.message}`);
}
ddiff --git a/server/rundown/desk.js b/server/rundown/desk.js
iindex a0d756e3..ef6a1f45 100644
---- a/server/rundown/desk.js
++++ b/server/rundown/desk.js
@@@ -415,7 +415,7 @@ async function announce(stream, user, title) {
let base = 'https://openvibe.live';
try { base = String(require('../config').baseUrl || base).replace(/\/$/, ''); } catch { /* */ }
const url = `${base}/rundown/e/${stream.id}`;
-- await require('../chat/chat-delivery').message({ username: 'THE RUNDOWN', user_id: null, message: `📰 ${user.display_name || user.username}: “${title}” → ${url}`.slice(0, 480), message_type: 'system', key: `rundown:episode:${stream.id}`, metadata: { rundown_episode: stream.id, url }, is_global: true });
++ await require('../chat/chat-delivery').message({ username: 'THE RUNDOWN', user_id: null, message: `📰 ${user.display_name || user.username}: “${title}” at ${url}`.slice(0, 480), message_type: 'system', key: `rundown:episode:${stream.id}`, metadata: { rundown_episode: stream.id, url }, is_global: true });
return true;
}
ddiff --git a/server/seo/pages.js b/server/seo/pages.js
iindex bd065812..25215c96 100644
---- a/server/seo/pages.js
++++ b/server/seo/pages.js
@@@ -17,6 +17,7 @@
'use strict';
const seo = require('openvibe-shared/seo');
const kit = require('openvibe-shared/kit');
++const glyphs = require('openvibe-shared/glyphs');
const { esc } = seo;
const { setRobotsHeader } = require('../web/robots-header');
const db = require('../db/database');
@@@ -278,7 +279,7 @@ async function _channelSectionMeta(username, section, page) {
const hasNext = pages ? page < pages : rows.length === CHANNEL_PAGE_SIZE;
const pager = `<nav class="pagination" aria-label="Pages">${page > 1 ? `<a rel="prev" href="${href(page - 1)}">Previous</a>` : ''}<span>Page ${page}${pages ? ` of ${pages}` : ''}</span>${hasNext ? `<a rel="next" href="${href(page + 1)}">Next</a>` : ''}</nav>`;
const list = items.length ? `<ol>${items.map((item) => `<li><a href="${esc(abs(item.url))}">${esc(clean(item.name, 120))}</a><time>${esc(item.meta)}</time></li>`).join('')}</ol>` : '<p>No items on this page.</p>';
-- const snapshot = `<p><a href="${esc(abs(`/@${acct.username}`))}">← ${esc(name)}'s channel</a></p><h1>${esc(name)} · ${esc(title)}</h1>${list}${pager}`;
++ const snapshot = `<p><a href="${esc(abs(`/@${acct.username}`))}">${glyphs.svg('arrow-left', { size: 16 })} ${esc(name)}'s channel</a></p><h1>${esc(name)} · ${esc(title)}</h1>${list}${pager}`;
return {
title: `${name} — ${title}, page ${page} | ${SITE_NAME}`, description: `Browse ${name}'s ${title.toLowerCase()} on ${SITE_NAME}.`,
canonicalPath, robots: page > 1 && !rows.length ? 'noindex,follow' : 'index,follow',
ddiff --git a/server/streaming/recorder.js b/server/streaming/recorder.js
iindex 5fa82064..9be29360 100644
---- a/server/streaming/recorder.js
++++ b/server/streaming/recorder.js
@@@ -235,7 +235,7 @@ class StreamRecorder {
const rtmpUrl = `rtmp://127.0.0.1:${config.rtmp.port}/live/${endpoint.streamKey}`;
await media.ingestRtmp(rec.vodId, rtmpUrl);
this._partStarted(stream, rec);
-- console.log(`[VOD] Media RTMP ingest started: stream ${streamId} → vod ${rec.vodId} (${rec.mode})`);
++ console.log(`[VOD] Media RTMP ingest started: stream ${streamId} to vod ${rec.vodId} (${rec.mode})`);
}
async _startRtpRecording(streamId, stream, rec) {
@@@ -342,7 +342,7 @@ class StreamRecorder {
rec.webrtcState = webrtcState;
this._partStarted(stream, rec);
-- console.log(`[VOD] Media RTP ingest started: stream ${streamId} → vod ${rec.vodId} (${videoPort ? `v:${videoPort}` : 'audio only'}${audioPort ? ` a:${audioPort}` : ''}, ${rec.mode})`);
++ console.log(`[VOD] Media RTP ingest started: stream ${streamId} to vod ${rec.vodId} (${videoPort ? `v:${videoPort}` : 'audio only'}${audioPort ? ` a:${audioPort}` : ''}, ${rec.mode})`);
}
/**
@@@ -425,7 +425,7 @@ class StreamRecorder {
await media.deleteVod(rec.vodId).catch(() => {});
console.log(`[VOD] Discarded ephemeral clips-only recording (vod ${rec.vodId}, stream ${streamId})`);
} else {
-- console.log(`[VOD] Recording stopped: stream ${streamId} → vod ${rec.vodId} (Media finalizing)`);
++ console.log(`[VOD] Recording stopped: stream ${streamId} to vod ${rec.vodId} (Media finalizing)`);
}
})();
finish.catch(() => {}).finally(() => this._finalizing.delete(streamId));
ddiff --git a/server/streaming/restream-manager.js b/server/streaming/restream-manager.js
iindex fc92ab20..00dd24ef 100644
---- a/server/streaming/restream-manager.js
++++ b/server/streaming/restream-manager.js
@@@ -326,7 +326,7 @@ class RestreamManager extends EventEmitter {
if (protocol === 'webrtc') {
const webrtcSFU = require('./webrtc-sfu');
if (!webrtcSFU.ready) {
-- console.warn('[Restream] WebRTC → RTMP requires Mediasoup SFU. Mediasoup not available.');
++ console.warn('[Restream] WebRTC to RTMP requires Mediasoup SFU. Mediasoup not available.');
return null;
}
}
@@@ -532,7 +532,7 @@ class RestreamManager extends EventEmitter {
}
/**
-- * Start WebRTC → RTMP restream.
++ * Start WebRTC to RTMP restream.
*
* Flow:
* 1. Signal the broadcaster to produce tracks into Mediasoup SFU
ddiff --git a/server/streaming/restream-routes.js b/server/streaming/restream-routes.js
iindex cf340f6d..1cd605b7 100644
---- a/server/streaming/restream-routes.js
++++ b/server/streaming/restream-routes.js
@@@ -413,11 +413,11 @@ router.post('/destinations/:id/start', requireAuth, async (req, res) => {
}
if (stream.protocol === 'webrtc') {
-- // WebRTC → RTMP requires Mediasoup SFU
++ // WebRTC to RTMP requires Mediasoup SFU
const webrtcSFU = require('./webrtc-sfu');
if (!webrtcSFU.ready) {
return res.status(400).json({
-- error: 'WebRTC → RTMP restreaming requires Mediasoup. Install mediasoup: npm install mediasoup',
++ error: 'WebRTC to RTMP restreaming requires Mediasoup. Install mediasoup: npm install mediasoup',
});
}
}
@@@ -650,7 +650,7 @@ router.get('/oauth/:platform/start', requireAuth, async (req, res) => {
function oauthErrorText(platform, err, description) {
const name = (platformOAuth.PLATFORMS[platform] && platformOAuth.PLATFORMS[platform].name) || String(platform).charAt(0).toUpperCase() + String(platform).slice(1);
if (/redirect/i.test(String(err || '')) || /redirect[_ ]?ur[il]/i.test(String(description || ''))) {
-- return `${name} refused the sign-in because this site's address is not on its app's list. In the ${name} developer console, add ${platformOAuth.redirectUri(platform)} as an OAuth redirect URL for the app whose client ID is set in Admin → Integrations, then connect again. Until then, paste the stream key into the destination by hand.`;
++ return `${name} refused the sign-in because this site's address is not on its app's list. In the ${name} developer console, add ${platformOAuth.redirectUri(platform)} as an OAuth redirect URL for the app whose client ID is set in Admin, under Integrations, then connect again. Until then, paste the stream key into the destination by hand.`;
}
return description || err;
}
ddiff --git a/setup-guide.html b/setup-guide.html
iindex 6b49de33..5c673970 100644
---- a/setup-guide.html
++++ b/setup-guide.html
@@@ -477,8 +477,8 @@ npm run init-db</code></pre>
<h3>Main website</h3>
<p>Create these records in Cloudflare DNS:</p>
<ul>
--<li><code>A</code> record for <code>@</code> → <code>YOUR_OVH_PUBLIC_IP</code></li>
--<li><code>CNAME</code> for <code>www</code> → your apex hostname</li>
++<li><code>A</code> record for <code>@</code> pointing at <code>YOUR_OVH_PUBLIC_IP</code></li>
++<li><code>CNAME</code> for <code>www</code> pointing at your apex hostname</li>
</ul>
<p>Set both to <strong>Proxied</strong> (orange cloud) after the origin is working.</p>
@@@ -1031,18 +1031,18 @@ sudo ufw status verbose</code></pre>
<h3>Approximate retained recording hours (at 2.5 Mbps)</h3>
<ul>
--<li>30 GB usable → ~27 hours</li>
--<li>80 GB usable → ~73 hours</li>
--<li>180 GB usable → ~164 hours</li>
--<li>380 GB usable → ~345 hours</li>
++<li>30 GB usable, about 27 hours</li>
++<li>80 GB usable, about 73 hours</li>
++<li>180 GB usable, about 164 hours</li>
++<li>380 GB usable, about 345 hours</li>
</ul>
<h3>At 4.0 Mbps</h3>
<ul>
--<li>30 GB usable → ~17 hours</li>
--<li>80 GB usable → ~45 hours</li>
--<li>180 GB usable → ~102 hours</li>
--<li>380 GB usable → ~216 hours</li>
++<li>30 GB usable, about 17 hours</li>
++<li>80 GB usable, about 45 hours</li>
++<li>180 GB usable, about 102 hours</li>
++<li>380 GB usable, about 216 hours</li>
</ul>
<div class="card tip"><strong>Storage takeaway:</strong> 50 GB is test-only. 100 GB is okay for dev/alpha. <strong>200 GB</strong> is the first production-usable size. 400 GB is much better but still not archival. Long-term, push older VODs to object storage.</div>
ddiff --git a/test/chat-event-cards.test.js b/test/chat-event-cards.test.js
iindex aa4a0244..882b75ed 100644
---- a/test/chat-event-cards.test.js
++++ b/test/chat-event-cards.test.js
@@@ -31,11 +31,17 @@ assert.strictEqual(old.kind, 'live'); assert.strictEqual(old.name, 'AmberFieraGa
const fromMeta = _chatEventOf({ message_type: 'system', message: 'something else', metadata: { kind: 'live_link', url: 'https://openvibe.live/@iZoR_One' } });
assert.strictEqual(fromMeta.name, 'iZoR_One', 'the name comes from the link when the text is not the usual one');
--const rd = _chatEventOf({ message_type: 'system', message: '\u{1F4F0} goosely: “Second McDoanld\'s Run: Bug Fix Later” → https://openvibe.live/rundown/e/2891',
++const rd = _chatEventOf({ message_type: 'system', message: '\u{1F4F0} goosely: “Second McDoanld\'s Run: Bug Fix Later” at https://openvibe.live/rundown/e/2891',
metadata: { rundown_episode: 2891, url: 'https://openvibe.live/rundown/e/2891' } });
assert.deepStrictEqual({ kind: rd.kind, name: rd.name, title: rd.title, url: rd.url },
{ kind: 'rundown', name: 'goosely', title: "Second McDoanld's Run: Bug Fix Later", url: 'https://openvibe.live/rundown/e/2891' });
++// Rows posted before the wording changed still read as the same card (the parser keeps the old separator as an escape).
++const oldRd = _chatEventOf({ message_type: 'system', message: '\u{1F4F0} goosely: “Old episode” \u2192 https://openvibe.live/rundown/e/2890',
++ metadata: { rundown_episode: 2890, url: 'https://openvibe.live/rundown/e/2890' } });
++assert.deepStrictEqual({ kind: oldRd.kind, name: oldRd.name, title: oldRd.title, url: oldRd.url },
++ { kind: 'rundown', name: 'goosely', title: 'Old episode', url: 'https://openvibe.live/rundown/e/2890' });
++
assert.strictEqual(_chatEventOf({ message_type: 'system', message: '\u{1F534} x is live. Share the stream: https://evil.example/@x' }), null, 'never a link to another site');
assert.strictEqual(_chatEventOf({ message_type: 'system', message: '\u{1F534} x is live. Share the stream: javascript:alert(1)' }), null);
assert.strictEqual(_chatEventOf({ message_type: 'system', message: '🚀 1 update shipped' }), null, 'a deploy row is not an event');
ddiff --git a/test/rundown-chat.test.js b/test/rundown-chat.test.js
iindex cac05fc1..112c7d8d 100644
---- a/test/rundown-chat.test.js
++++ b/test/rundown-chat.test.js
@@@ -39,7 +39,7 @@ assert.match(sent.pop(), /Beefs are gone.*\/rundown$/);
await run({ anonId: 'zz', streamId: liveId }, '!beef');
assert.match(sent.pop(), /Beefs are gone/);
await run({ anonId: 'zy', streamId: liveId }, '!arena');
--assert.match(sent.pop(), /^📰 Nova — latest episode: “The Fridge Files” · talks about Fridges → .*\/rundown\/nova$/, 'the streamer whose chat it is');
++assert.match(sent.pop(), /^📰 Nova — latest episode: “The Fridge Files” · talks about Fridges at .*\/rundown\/nova$/, 'the streamer whose chat it is');
await run({ anonId: 'zx', streamId: liveId }, '!arena quietguy');
assert.match(sent.pop(), /Nothing on The Rundown about Quietguy yet/);
await run({ anonId: 'zw', streamId: liveId }, '!arena nobody_here');
ddiff --git a/test/rundown.test.js b/test/rundown.test.js
iindex 7706002b..34ab0270 100644
---- a/test/rundown.test.js
++++ b/test/rundown.test.js
@@@ -126,7 +126,7 @@ assert.strictEqual(ep.title, 'The Fridge Files');
assert.strictEqual(ep.recap, 'goosely opened with French fridges.\n\nThen ranked.');
assert.deepStrictEqual(JSON.parse(ep.chapters_json).map((c) => [c.title, Number(c.segment_id)]), [['Fridges', Number(segs[0].id)], ['Ranked', Number(segs[1].id)]], 'chapters name real segments');
assert.strictEqual(announced.length, 1);
--assert.match(announced[0].message, /^📰 goosely: “The Fridge Files” → .*\/rundown\/e\/\d+$/);
++assert.match(announced[0].message, /^📰 goosely: “The Fridge Files” at .*\/rundown\/e\/\d+$/);
assert.strictEqual((await db.get('SELECT done FROM rundown_progress WHERE stream_id = ?', [s1])).done, 1);
const before = calls.length;
await desk.tick();