openvibers/openvibe.live Commits
Channel and chat polish: the PiP's handle, one emote button, a reply in the box, the AI ticker, glyphs in tiles; openvibe-shared 4.235.0 (#877)
Changes
@@@ -184,5 +184,5 @@ Minimum `.env`: `BASE_URL`, `JWT_SECRET`, `OV_NETWORK_URL`, `OV_NETWORK_INTERNAL<!-- versions:start -->- openvibe-contracts: v0.419.0- openvibe-sdk: v0.161.0--- openvibe-shared: v4.233.0++- openvibe-shared: v4.235.0<!-- versions:end -->@@@ -7,7 +7,7 @@"domain": "openvibe.live (www, ingest, whip)","contracts": "openvibe-contracts v0.419.0","sdk": "openvibe-sdk v0.161.0",-- "shared": "openvibe-shared v4.233.0",++ "shared": "openvibe-shared v4.235.0","capabilities": ["live.chat_context.read","live.chat_effects.write",@@@ -26,7 +26,7 @@"onnxruntime-node": "^1.20.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.419.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.161.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.233.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.235.0","pg": "^8.23.1","sanitize-html": "^2.17.7","sdp-transform": "^3.0.0",@@@ -3417,9 +3417,9 @@}},"node_modules/openvibe-shared": {-- "version": "4.233.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.233.0",-- "integrity": "sha512-kaNnKiQfMIVB5SasCeJX03Z+N/T3n/3Qklh4Be9EdCFz3PjAjUrPTqXOAgy1WMj2iuFXEODy5CLhonN7eLIOHw==",++ "version": "4.235.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.235.0",++ "integrity": "sha512-szSdbilNQWBPuZAUinpmtInKtyalGDzhy+Lso3f8uJ/wiJgR0msDL5E5gvcnajExWdygK48QWdycj9FGIUYSng==","license": "MIT","dependencies": {"clean-css": "^5.3.3",@@@ -46,7 +46,7 @@"onnxruntime-node": "^1.20.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.419.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.161.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.233.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.235.0","pg": "^8.23.1","sanitize-html": "^2.17.7","sdp-transform": "^3.0.0",@@@ -33,7 +33,7 @@".btn-primary",".btn-small",".card-ai-clamp",-- ".card-ai-clamp i.fa-wand-magic-sparkles",++ ".card-ai-clamp i.fa-sparkles",".card-ai-overview",".card-ai-static",".card-ai-toggle",@@@ -1926,14 +1926,8 @@ html[data-ov-motion=off] #page-channel .ch-tab-panel{scroll-margin-top: calc(var#page-channel .ch-more-item i { width: 17px; color: var(--text-secondary); }#page-channel .ch-more-item[hidden], #page-channel .ch-more-mod[hidden] { display: none; }#page-channel .ch-more-mod { border-top: 1px solid var(--border); margin-top: 5px; padding-top: 5px; }--/* Open, it is in the top layer and placed on the screen by app-channel.js (a phone's live layout clipped it). */--#page-channel .ch-more-menu.is-layer { position: fixed; inset: auto; margin: 0; right: auto; max-height: calc(100dvh - 16px); overflow-y: auto; color: var(--text-primary);-- transform-origin: top right; animation: chMoreIn var(--ov-dur-2, .16s) var(--ov-ease-out, cubic-bezier(.2, .9, .25, 1)); }--#page-channel .ch-more-menu.is-layer.is-above { transform-origin: bottom right; }--@keyframes chMoreIn { from { opacity: 0; transform: translateY(-4px) scale(.96); } }--#page-channel .ch-more-menu.is-layer.is-above { animation-name: chMoreUp; }--@keyframes chMoreUp { from { opacity: 0; transform: translateY(4px) scale(.96); } }--html[data-ov-motion="off"] #page-channel .ch-more-menu.is-layer { animation: none; }++/* Open, the network's layer anchors it to its button in the top layer (OVSurfaces, app-channel.js). */++#page-channel .ch-more-menu[data-ov-anchored] { color: var(--text-primary); }#page-channel .ch-more-heading { display: block; padding: 5px 9px; color: var(--text-muted); font-size: 11px; font-weight: 750; text-transform: uppercase; letter-spacing: .05em; }.ch-action-dialog { width: min(420px, calc(100vw - 32px)); box-sizing: border-box; border: 1px solid var(--border); border-radius: 14px; padding: 22px; background: var(--bg-card); color: var(--text-primary); }.ch-action-dialog::backdrop { background: rgba(0,0,0,.65); }@@@ -2252,6 +2246,9 @@ html[data-ov-motion="off"] #page-channel .ch-rec-dot { animation: none; }#page-channel .ch-sec-ic { display: inline-grid; place-items: center; flex: none; width: 30px; height: 30px; border-radius: 9px; font-size: .9rem;color: var(--accent-light, var(--accent)); background: color-mix(in srgb, var(--accent) 14%, transparent); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 22%, transparent); }#page-channel svg.ch-sec-ic { padding: 7px; box-sizing: border-box; }++/* The glyph inside the tile, not the tile: an OV glyph draws its picture at 100% of its element, and here the element++ is the tile, so every glyph ran to the tile's edges (ask 371, 11-13: "a tiny bit of padding"). 62% of it. */++#page-channel .ch-sec-ic > svg { width: 62%; height: 62%; }/* A part of a section (AI Moments in Clips, About's latest videos and popular clips): the same tile, a size down. */#page-channel .ch-sub-h { display: flex; align-items: center; gap: 9px; margin: 22px 0 6px; font-size: 1rem; font-weight: 800; line-height: 1.2; letter-spacing: -.005em; text-transform: none; color: var(--text-primary); }#page-channel .ch-sub-h > .ch-sec-ic { width: 26px; height: 26px; border-radius: 8px; font-size: .78rem; }@@@ -16,6 +16,16 @@}.global-ai-strip:hover { background: linear-gradient(90deg, color-mix(in srgb, var(--accent) 20%, transparent), transparent); }.gai-strip-caret { flex-shrink: 0; opacity: 0.6; transition: transform var(--ov-dur-2, 0.2s); }++/* The ticker (app-chatpage.js): a sentence rolls up into place; one longer than the strip pans to its end, then holds. */++.global-ai-strip .gai-strip-text { position: relative; text-overflow: clip; }++.global-ai-strip .gai-t { display: inline-block; white-space: nowrap; }++.global-ai-strip .gai-strip-text.gai-roll .gai-t { animation: gaiRoll var(--ov-dur-3, .42s) var(--ov-ease-out, cubic-bezier(.2, .9, .25, 1)); }++@keyframes gaiRoll { from { opacity: 0; transform: translateY(70%); } }++.global-ai-strip .gai-strip-text.gai-pan .gai-t { animation: gaiPan var(--gai-pan-s, 4s) ease-in-out 1.4s both; }++.global-ai-strip .gai-strip-text.gai-roll.gai-pan .gai-t { animation: gaiRoll var(--ov-dur-3, .42s) var(--ov-ease-out, ease-out), gaiPan var(--gai-pan-s, 4s) ease-in-out 1.6s both; }++@keyframes gaiPan { to { transform: translateX(var(--gai-shift, 0)); } }++.global-ai-strip:not(.open) .gai-strip-text { -webkit-mask-image: linear-gradient(90deg, #000 calc(100% - 18px), transparent); mask-image: linear-gradient(90deg, #000 calc(100% - 18px), transparent); }++html[data-ov-motion="off"] .global-ai-strip .gai-t { animation: none !important; }.global-ai-strip.open .gai-strip-caret { transform: rotate(180deg); }.global-ai-panel {@@@ -2,8 +2,9 @@One stylesheet for every PIP (2026-11-11, owner: "move it anywhere … more solid feeling and more responsive … use theOV animations"). A PIP sits in the player (absolute, its place in fractions of the player) or over the page(.is-floating: fixed, fractions of the window, above the page and below the navbar's menus); while it is moved it is-- lifted (.is-lifted: fixed, moved by a transform each frame), and it lands with a spring. Tucked into a side, only its-- tab stays on screen (the `translate` property; `transform` is the landing's). Its waits are the mark at work. */++ lifted (.is-lifted: fixed, moved by a transform each frame), and it lands with a spring. Tucked into a side it folds++ into a small handle at the edge (openvibe-shared 4.235.0's clip; the handle is .ov-pip-tab). Its waits are the mark++ at work. */.ov-pip {container: ovpip / inline-size;@@@ -21,8 +22,10 @@-webkit-tap-highlight-color: transparent;contain: layout paint;transition: box-shadow var(--ov-dur-2, .15s) var(--ov-ease-out, ease), border-color var(--ov-dur-2, .15s) var(--ov-ease-out, ease),-- translate var(--ov-dur-3, .3s) var(--ov-ease-spring, cubic-bezier(.2, 1.25, .3, 1)), opacity var(--ov-dur-2, .2s);++ opacity var(--ov-dur-2, .2s), visibility 0s;}++/* Out of the browser in its own window: the box in the page steps aside until that window closes. */++.ov-pip.is-popped { opacity: 0; visibility: hidden; pointer-events: none; transition: opacity var(--ov-dur-2, .2s), visibility 0s linear var(--ov-dur-2, .2s); }.ov-pip.is-floating { position: fixed; z-index: 900; }.ov-pip.is-lifted { position: fixed; z-index: 905; will-change: transform; transition: none; }.ov-pip:hover { border-color: rgba(255, 255, 255, .3); }@@@ -102,20 +105,29 @@.ov-pip-state-t { max-width: 100%; overflow-wrap: anywhere; text-shadow: 0 1px 3px rgba(0, 0, 0, .6); }.ov-pip[data-state="offline"] .ov-pip-state-t { color: color-mix(in srgb, #fbbf24 70%, #fff); }--/* Tucked into a side: only the tab and an edge of its picture stay on screen; a tap brings it back. */--.ov-pip.is-tucked-left { translate: calc(-100% + 30px) 0; }--.ov-pip.is-tucked-right { translate: calc(100% - 30px) 0; }--.ov-pip.is-tucked .ov-pip-bar, .ov-pip.is-tucked .ov-pip-resize, .ov-pip.is-tucked .ov-pip-state-t { opacity: 0; pointer-events: none; }--.ov-pip.is-tucked .ov-pip-video { filter: brightness(.75); }++/* Tucked into a side: it folds into a small handle at the edge (the engine's clip), its picture still going behind the++ glass; a tap opens it, a drag up or down slides it along the side, a pull brings it out. */++.ov-pip.is-tucked { border-color: transparent; }++.ov-pip.is-tucked .ov-pip-bar, .ov-pip.is-tucked .ov-pip-resize, .ov-pip.is-tucked .ov-pip-state { opacity: 0; pointer-events: none; }.ov-pip-tab {-- position: absolute; top: 0; bottom: 0; z-index: 4; width: 30px; display: none; place-items: center; padding: 0; cursor: pointer;-- border: 0; color: #fff; font-size: .85rem;-- background: linear-gradient(90deg, rgba(0, 0, 0, .55), rgba(0, 0, 0, .25));-- -webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px);++ position: absolute; top: 50%; z-index: 4; display: none; place-items: center; padding: 0; cursor: pointer; touch-action: none;++ width: var(--ov-float-tab-w, 30px); height: min(var(--ov-float-tab-h, 64px), 100%); translate: 0 -50%;++ border: 0; color: #fff; font-size: .9rem;++ background: color-mix(in srgb, rgba(10, 12, 18, .58) 82%, var(--accent, #3b82f6));++ -webkit-backdrop-filter: blur(10px) saturate(1.25); backdrop-filter: blur(10px) saturate(1.25);}--.ov-pip.is-tucked .ov-pip-tab { display: grid; }--.ov-pip.is-tucked-left .ov-pip-tab { right: 0; }--.ov-pip.is-tucked-right .ov-pip-tab { left: 0; background: linear-gradient(270deg, rgba(0, 0, 0, .55), rgba(0, 0, 0, .25)); }++.ov-pip.is-tucked .ov-pip-tab { display: grid; animation: ovPipTabIn var(--ov-dur-3, .32s) var(--ov-ease-out, ease-out); }++@keyframes ovPipTabIn { from { opacity: 0; } }++.ov-pip.is-tucked-left .ov-pip-tab { left: 0; border-radius: 0 12px 12px 0; box-shadow: inset -1px 0 0 rgba(255, 255, 255, .22); }++.ov-pip.is-tucked-right .ov-pip-tab { right: 0; border-radius: 12px 0 0 12px; box-shadow: inset 1px 0 0 rgba(255, 255, 255, .22); }++.ov-pip-tab:hover i, .ov-pip-tab:focus-visible i { transform: scale(1.18); }++.ov-pip-tab i { transition: transform var(--ov-dur-1, .12s) var(--ov-ease-spring, ease); }++/* Its live dot: the picture is still going behind the handle. */++.ov-pip-tab::after {++ content: ""; position: absolute; top: 9px; left: 50%; width: 6px; height: 6px; margin-left: -3px; border-radius: 50%;++ background: #ef4444; box-shadow: 0 0 0 2px rgba(239, 68, 68, .25);++}++.ov-pip:not([data-state="live"]) .ov-pip-tab::after { background: rgba(255, 255, 255, .5); box-shadow: none; }.ov-pip-tab-l, .ov-pip-tab-r { display: none; }.ov-pip.is-tucked-right .ov-pip-tab-l, .ov-pip.is-tucked-left .ov-pip-tab-r { display: grid; place-items: center; }@@@ -143,6 +155,9 @@.ov-pip { border-radius: 12px; }}++/* While it moves, the glass under its buttons is plain (a blur repainted every frame made a phone's drag stutter). */++.ov-pip.is-lifted .ov-pip-actions button, .ov-pip.is-lifted .ov-pip-state { -webkit-backdrop-filter: none; backdrop-filter: none; }++/* Motion off: no spring and no fade, it is simply where it goes. */html[data-ov-motion="off"] .ov-pip, html[data-ov-motion="off"] .ov-pip-bar { transition: none; animation: none; }@media (prefers-reduced-motion: reduce) {@@@ -4729,7 +4729,7 @@ body :is(.modal-overlay, .bc-ws-confirm-overlay, .bc-modal, .bc-perm-overlay, .b}.gai-strip-text { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.gai-panel-head h3 { margin: 0; font-size: 1.05rem; display: flex; align-items: center; gap: 8px; }--.gai-panel-head h3 .fa-wand-magic-sparkles { color: var(--accent); }++.gai-panel-head h3 .fa-sparkles { color: var(--accent); }.gai-col h4 { margin: 0 0 10px; font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.03em; color: var(--text-secondary); display: flex; align-items: center; gap: 6px; }/* Timeline browser: head (title + search), period chips, scrollable list with day dividers. */.gai-tl-head h4 { margin: 0; }@@@ -9098,7 +9098,7 @@ html[data-theme-mode="light"] .hero h1 { background-image: linear-gradient(90degdisplay: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;}.card-ai-overview { position: relative; }--.card-ai-clamp i.fa-wand-magic-sparkles { opacity: 0.85; margin-right: 2px; }++.card-ai-clamp i.fa-sparkles { opacity: 0.85; margin-right: 2px; }/* Reserve space at the end so the chevron never overlaps the text. */.card-ai-clamp { padding-right: 16px; }.card-ai-overview.expanded .card-ai-clamp { -webkit-line-clamp: unset; overflow: visible; }@@@ -1157,12 +1157,6 @@ WantedBy=multi-user.target</code></button></div><div class="chat-tools-row">-- <button class="emote-picker-btn" onclick="toggleEmotePicker('bc-chat-input')" title="Emotes">-- <i class="fa-solid fa-face-smile"></i>-- </button>-- <button class="emote-picker-btn gif-btn" onclick="toggleGifPicker('bc-chat-input')" title="GIFs">-- <span class="gif-btn-text">GIF</span>-- </button><button class="chat-tts-toggle tts-active" onclick="toggleChatTTS(this)" title="TTS On (click to mute)"><i class="fa-solid fa-volume-high"></i></button>@@@ -244,15 +244,6 @@</button></div><div class="chat-tools-row">-- <button class="emote-picker-btn" onclick="toggleEmotePicker('chat-input')" title="Emotes">-- <i class="fa-solid fa-face-smile"></i>-- </button>-- <button class="emote-picker-btn gif-btn" onclick="toggleGifPicker('chat-input')" title="GIFs">-- <span class="gif-btn-text">GIF</span>-- </button>-- <button class="emote-picker-btn" onclick="openChannelUploadModal()" title="Add channel emote / sound">-- <i class="fa-solid fa-plus"></i>-- </button><button class="emote-picker-btn chat-limits-btn" style="display:none" onclick="toggleChatLimitsPopover(this)" title="Chat limits (message + TTS length)"><i class="fa-solid fa-sliders"></i></button>@@@ -369,15 +360,6 @@</button></div><div class="chat-tools-row">-- <button class="emote-picker-btn" onclick="toggleEmotePicker('offline-chat-input')" title="Emotes">-- <i class="fa-solid fa-face-smile"></i>-- </button>-- <button class="emote-picker-btn gif-btn" onclick="toggleGifPicker('offline-chat-input')" title="GIFs">-- <span class="gif-btn-text">GIF</span>-- </button>-- <button class="emote-picker-btn" onclick="openChannelUploadModal()" title="Add channel emote / sound">-- <i class="fa-solid fa-plus"></i>-- </button><button class="chat-tts-toggle tts-active" onclick="toggleChatTTS(this)" title="TTS On (click to mute)"><i class="fa-solid fa-volume-high"></i></button>@@@ -457,7 +439,7 @@<div id="ch-clips-of-pagination" class="pastes-pagination" style="display:none"></div><!-- What the AI cut from these streams, after what people made (roadmap 33.6). --><div class="ch-ai-clips" id="ch-ai-clips-section" hidden>-- <h3 class="ch-sub-h"><i class="fa-solid fa-wand-magic-sparkles ch-sec-ic" aria-hidden="true"></i>AI Moments</h3>++ <h3 class="ch-sub-h"><i class="fa-solid fa-sparkles ch-sec-ic" aria-hidden="true"></i>AI Moments</h3><p class="ch-panel-hint">Clips OpenVibe's AI cut automatically when chat reacted. No one clipped these.</p><div class="stream-grid" id="ch-ai-clips-grid"></div><div id="ch-ai-clips-pagination" class="pastes-pagination" style="display:none"></div>@@@ -124,7 +124,7 @@</div></div><div class="global-ai-strip" id="global-ai-strip" style="display:none" onclick="toggleGlobalAiPanel()" title="AI read of the chat — click for the full insight & timeline">-- <span class="gai-badge"><i class="fa-solid fa-wand-magic-sparkles"></i> AI</span>++ <span class="gai-badge"><i class="fa-solid fa-sparkles"></i> AI</span><span class="gai-strip-text" id="global-ai-strip-text"></span><i class="fa-solid fa-chevron-down gai-strip-caret" id="global-ai-strip-caret"></i></div>@@@ -155,12 +155,6 @@</button></div><div class="chat-tools-row">-- <button class="emote-picker-btn" onclick="toggleEmotePicker('global-chat-input')" title="Emotes">-- <i class="fa-solid fa-face-smile"></i>-- </button>-- <button class="emote-picker-btn gif-btn" onclick="toggleGifPicker('global-chat-input')" title="GIFs">-- <span class="gif-btn-text">GIF</span>-- </button></div></div></div>@@@ -169,7 +163,7 @@<!-- ── Global Chat AI insight + timeline (below the layout) ── --><section class="global-ai-panel" id="global-ai-panel" style="display:none"><div class="gai-panel-head">-- <h3><i class="fa-solid fa-wand-magic-sparkles"></i> Global Chat — AI Insight</h3>++ <h3><i class="fa-solid fa-sparkles"></i> Global Chat — AI Insight</h3><span class="gai-meta" id="global-ai-meta"></span><button class="gai-collapse-btn" onclick="toggleGlobalAiPanel()" title="Collapse"><i class="fa-solid fa-chevron-up"></i></button></div>@@@ -5,7 +5,7 @@<p class="cf-sub">Streams, clips and pastes made by people on OpenVibe.</p></div><div class="cf-head-actions">-- <a class="cf-crosslink" href="/moments" data-cf-link><i class="fa-solid fa-wand-magic-sparkles" aria-hidden="true"></i> AI Moments</a>++ <a class="cf-crosslink" href="/moments" data-cf-link><i class="fa-solid fa-sparkles" aria-hidden="true"></i> AI Moments</a><button type="button" class="btn btn-small btn-outline cf-new-paste" data-cf-new-paste hidden><i class="fa-solid fa-plus" aria-hidden="true"></i> New paste</button></div></header>@@@ -26,7 +26,7 @@<button class="ch-tab active" data-cdtab="videos" onclick="switchDashContentTab('videos', this)"><i class="fa-solid fa-video"></i> Videos</button><button class="ch-tab" data-cdtab="streamclips" onclick="switchDashContentTab('streamclips', this)"><i class="fa-solid fa-scissors"></i> Clips of My Stream</button><button class="ch-tab" data-cdtab="myclips" onclick="switchDashContentTab('myclips', this)"><i class="fa-solid fa-film"></i> My Clips</button>-- <button class="ch-tab" data-cdtab="aimoments" onclick="switchDashContentTab('aimoments', this)"><i class="fa-solid fa-wand-magic-sparkles"></i> AI Moments<span class="dash-subtab-count" id="dash-ai-moments-count" hidden></span></button>++ <button class="ch-tab" data-cdtab="aimoments" onclick="switchDashContentTab('aimoments', this)"><i class="fa-solid fa-sparkles"></i> AI Moments<span class="dash-subtab-count" id="dash-ai-moments-count" hidden></span></button><button class="ch-tab" data-cdtab="pastes" onclick="switchDashContentTab('pastes', this)"><i class="fa-solid fa-paste"></i> Pastes</button></div><div class="dash-subpanel active" id="dash-content-videos"><div class="dash-grid" id="dash-grid-content-videos"></div></div>@@@ -134,7 +134,7 @@</label><ul class="dash-el-list" id="dash-el-list" aria-label="Your buttons"></ul><p class="dash-el-actions">-- <a class="btn btn-primary btn-small" href="https://openvibe.design/create?kind=element" target="_blank" rel="noopener"><i class="fa-solid fa-wand-magic-sparkles" aria-hidden="true"></i> Make one with AI</a>++ <a class="btn btn-primary btn-small" href="https://openvibe.design/create?kind=element" target="_blank" rel="noopener"><i class="fa-solid fa-sparkles" aria-hidden="true"></i> Make one with AI</a><a class="btn btn-small" href="https://openvibe.design/gallery" target="_blank" rel="noopener"><i class="fa-solid fa-grip" aria-hidden="true"></i> Free ones in the gallery</a><a class="btn btn-small" id="dash-el-see" href="/" hidden><i class="fa-solid fa-eye" aria-hidden="true"></i> See them on your channel</a></p>@@@ -193,7 +193,7 @@<!-- Vibe on stream (server/ai/vibe-voice.js): the streamer's call word wakes OpenVibe's assistant --><div class="dash-card dash-card-wide" id="dash-card-vibe">-- <h3><i class="fa-solid fa-wand-magic-sparkles"></i> Vibe on stream <span class="muted">your call word</span></h3>++ <h3><i class="fa-solid fa-sparkles"></i> Vibe on stream <span class="muted">your call word</span></h3><p class="muted" style="font-size:0.9rem">Say your call word on stream and Vibe, OpenVibe's assistant, jumps in. <b>"<span data-vibe-word>hey vibe</span>, clip that"</b> clips the last 45 seconds and posts the link. Anything else, like <b>"<span data-vibe-word>hey vibe</span>, what was the score last night?"</b>, gets an answer in your chat.</p><form class="vibe-form" id="dash-vibe-form"><label class="vibe-switch"><input type="checkbox" name="enabled"> <span>Listen for my call word</span></label>@@@ -333,7 +333,7 @@Clips</label><button class="btn btn-small btn-danger" onclick="dashBulkActionByAge()">-- <i class="fa-solid fa-wand-magic-sparkles"></i> Apply++ <i class="fa-solid fa-sparkles"></i> Apply</button></div><div id="dash-vods-list" class="vod-grid"></div>@@@ -365,7 +365,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-wand-magic-sparkles"></i> AI Moments of My Stream</h3>++ <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 → AI Moments from my streams.</p><div id="dash-ai-clips" class="vod-grid"></div></div>@@@ -499,7 +499,7 @@<!-- AI Moments from my streams: the channel's AI derivation switch (roadmap 33.7) --><div class="dash-card dash-card-wide" id="dash-card-ai-derivation">-- <h3><i class="fa-solid fa-wand-magic-sparkles"></i> AI Moments from my streams</h3>++ <h3><i class="fa-solid fa-sparkles"></i> AI Moments from my streams</h3><label class="staff-inline-toggle" style="margin-bottom:8px"><input type="checkbox" id="dash-ai-derivation" checked onchange="saveAiDerivationSetting(this)"><span>Let OpenVibe's AI make Moments from my streams</span>@@@ -3,7 +3,7 @@<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:1rem;flex-wrap:wrap;margin-bottom:0.5rem"><div>-- <h1 style="margin-bottom:0.25rem"><i class="fa-solid fa-wand-magic-sparkles"></i> OpenVibe.Live API Docs</h1>++ <h1 style="margin-bottom:0.25rem"><i class="fa-solid fa-sparkles"></i> OpenVibe.Live API Docs</h1><p class="text-muted" style="margin:0">Modular docs for vibe coders. Copy just the section you need, or grab everything.</p></div><div style="display:flex;gap:0.5rem;flex-shrink:0">@@@ -26,12 +26,12 @@<a href="https://openvibe.games" style="--i:2" class="hb hb-game"><span class="hb-ico"><i class="fa-solid fa-gamepad"></i></span><span class="hb-label">Game</span><i class="fa-solid fa-chevron-right hb-go" aria-hidden="true"></i></a><a href="/documentation" onclick="return handleLinkClick(event, '/documentation')" style="--i:3" class="hb hb-vibe"data-ovtip-title="Mod it, bot it, build on it"-- data-ovtip-icon="fa-wand-magic-sparkles"++ data-ovtip-icon="fa-sparkles"data-ovtip-accent="#f472b6"data-ovtip-place="top"data-ovtip="Chat, streams, clips, overlays, robots, sound commands, whole games — every feature here has an <b>open API</b>, and the entire site is <b>open source</b>. <em>You do not need to know how to code.</em> Point an AI at these docs, describe the thing you want, and it writes it for you."data-ovtip-foot="<i class="fa-solid fa-heart"></i> We love what people build here — come make something"-- data-ovtip-go="Open the API docs"><span class="hb-ico"><i class="fa-solid fa-wand-magic-sparkles"></i></span><span class="hb-label">Build <span class="hb-amp">& Mod</span> <small>API docs</small></span><i class="fa-solid fa-chevron-right hb-go" aria-hidden="true"></i></a>++ data-ovtip-go="Open the API docs"><span class="hb-ico"><i class="fa-solid fa-sparkles"></i></span><span class="hb-label">Build <span class="hb-amp">& Mod</span> <small>API docs</small></span><i class="fa-solid fa-chevron-right hb-go" aria-hidden="true"></i></a><a href="https://github.com/OpenVibers/OpenVibe.Live" target="_blank" style="--i:4" class="hb hb-github" rel="noopener"><span class="hb-ico"><i class="fa-brands fa-github"></i></span><span class="hb-label">GitHub</span><i class="fa-solid fa-chevron-right hb-go" aria-hidden="true"></i></a><a href="https://discord.gg/M6MuRUaeJj" target="_blank" style="--i:5" class="hb hb-discord" rel="noopener noreferrer"><span class="hb-ico"><i class="fa-brands fa-discord"></i></span><span class="hb-label">Discord</span><i class="fa-solid fa-chevron-right hb-go" aria-hidden="true"></i></a></nav>@@@ -294,7 +294,7 @@<span>Set funding goals with custom art, celebrate live in chat, and keep a real creator economy — with fair, transparent payouts.</span></div><div class="home-cta-feature">-- <i class="fa-solid fa-wand-magic-sparkles"></i>++ <i class="fa-solid fa-sparkles"></i><strong>AI clips, VODs & transcripts</strong><span>Every broadcast auto-records, clips on the fly, and gets AI overviews + searchable audio transcripts.</span></div>@@@ -360,9 +360,9 @@<p class="ovnet-eyebrow"><span class="ovnet-dot" aria-hidden="true"></span>The OpenVibe network</p><h2 id="ovnet-title">One account. <span class="ovnet-count" data-count="55">55</span> sites.</h2><p class="ovnet-lede">Stream, play, cook, find work, build and ship: every OpenVibe site opens with the same sign-in, wears your theme and carries your OpenCoins and Vibes.</p>-- <nav class="ovnet-jump" data-ovk-scroll aria-label="Kinds of OpenVibe sites"><a href="#ovnet-g-streams" style="--g:#ef4444"><i class="fa-solid fa-tower-broadcast" aria-hidden="true"></i>Streams & places<b>7</b></a><a href="#ovnet-g-games" style="--g:#a855f7"><i class="fa-solid fa-gamepad" aria-hidden="true"></i>Games & laughs<b>6</b></a><a href="#ovnet-g-answers" style="--g:#f59e0b"><i class="fa-solid fa-newspaper" aria-hidden="true"></i>News & answers<b>8</b></a><a href="#ovnet-g-make" style="--g:#3b82f6"><i class="fa-solid fa-wand-magic-sparkles" aria-hidden="true"></i>Make & build<b>8</b></a><a href="#ovnet-g-shop" style="--g:#22c55e"><i class="fa-solid fa-basket-shopping" aria-hidden="true"></i>Shop & swap<b>8</b></a><a href="#ovnet-g-people" style="--g:#ec4899"><i class="fa-solid fa-user-group" aria-hidden="true"></i>People & you<b>7</b></a><a href="#ovnet-g-build" style="--g:#14b8a6"><i class="fa-solid fa-code" aria-hidden="true"></i>For developers<b>11</b></a></nav>++ <nav class="ovnet-jump" data-ovk-scroll aria-label="Kinds of OpenVibe sites"><a href="#ovnet-g-streams" style="--g:#ef4444"><i class="fa-solid fa-tower-broadcast" aria-hidden="true"></i>Streams & places<b>7</b></a><a href="#ovnet-g-games" style="--g:#a855f7"><i class="fa-solid fa-gamepad" aria-hidden="true"></i>Games & laughs<b>6</b></a><a href="#ovnet-g-answers" style="--g:#f59e0b"><i class="fa-solid fa-newspaper" aria-hidden="true"></i>News & answers<b>8</b></a><a href="#ovnet-g-make" style="--g:#3b82f6"><i class="fa-solid fa-sparkles" aria-hidden="true"></i>Make & build<b>8</b></a><a href="#ovnet-g-shop" style="--g:#22c55e"><i class="fa-solid fa-basket-shopping" aria-hidden="true"></i>Shop & swap<b>8</b></a><a href="#ovnet-g-people" style="--g:#ec4899"><i class="fa-solid fa-user-group" aria-hidden="true"></i>People & you<b>7</b></a><a href="#ovnet-g-build" style="--g:#14b8a6"><i class="fa-solid fa-code" aria-hidden="true"></i>For developers<b>11</b></a></nav></header>-- <div class="ovnet-groups" data-sites="55"><div class="ovnet-group" id="ovnet-g-streams" role="group" aria-labelledby="ovnet-gh-streams" data-group="streams" style="--g:#ef4444"><h3 id="ovnet-gh-streams"><i class="fa-solid fa-tower-broadcast" aria-hidden="true"></i><span>Streams & places</span><b class="ovnet-n">7</b></h3><p class="ovnet-gline">Go live, watch, share pictures and video</p><ul class="ovnet-sites" data-ovk-scroll><li><a class="ovnet-site" href="https://openvibe.live/" data-site="live" style="--i:0"><span class="ov-icon" data-icon="live" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Live</b><small>openvibe.live</small><span>Go live, watch streams</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.video/" data-site="media-hub" style="--i:1"><span class="ov-icon" data-icon="video" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Video</b><small>openvibe.video</small><span>Clips and past streams</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.pics/" data-site="pics" style="--i:2"><span class="ov-icon" data-icon="pics" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Pics</b><small>openvibe.pics</small><span>Share pictures</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.download/" data-site="download" style="--i:3"><span class="ov-icon" data-icon="download" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Download</b><small>openvibe.download</small><span>Your files</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.cam/" data-site="cam" style="--i:4"><span class="ov-icon" data-icon="cam" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Cam</b><small>openvibe.cam</small><span>Your camera, your cameras, and Live's cam button</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.tours/" data-site="tours" style="--i:5"><span class="ov-icon" data-icon="tours" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Tours</b><small>openvibe.tours</small><span>Go anywhere: fly, stay, walk, watch</span></span></a></li><li><a class="ovnet-site" href="https://openre.stream/" data-site="stream" style="--i:6"><span class="ov-icon" data-icon="stream" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Restream</b><small>openre.stream</small><span>Stream everywhere</span></span></a></li></ul></div><div class="ovnet-group" id="ovnet-g-games" role="group" aria-labelledby="ovnet-gh-games" data-group="games" style="--g:#a855f7"><h3 id="ovnet-gh-games"><i class="fa-solid fa-gamepad" aria-hidden="true"></i><span>Games & laughs</span><b class="ovnet-n">6</b></h3><p class="ovnet-gline">Play in the browser, laugh, explore</p><ul class="ovnet-sites" data-ovk-scroll><li><a class="ovnet-site" href="https://openvibe.games/" data-site="games" style="--i:7"><span class="ov-icon" data-icon="games" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Games</b><small>openvibe.games</small><span>Play in your browser</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.fun/" data-site="fun" style="--i:8"><span class="ov-icon" data-icon="fun" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Fun</b><small>openvibe.fun</small><span>Something to do</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.casino/" data-site="casino" style="--i:9"><span class="ov-icon" data-icon="casino" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Casino</b><small>openvibe.casino</small><span>Learn it, play it, never pay it</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.meme/" data-site="meme" style="--i:10"><span class="ov-icon" data-icon="meme" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Meme</b><small>openvibe.meme</small><span>The internet's meme database</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.surf/" data-site="surf" style="--i:11"><span class="ov-icon" data-icon="surf" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Surf</b><small>openvibe.surf</small><span>Ride, browse, watch</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.quest/" data-site="quest" style="--i:12"><span class="ov-icon" data-icon="quest" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Quest</b><small>openvibe.quest</small><span>Earn badges</span></span></a></li></ul></div><div class="ovnet-group" id="ovnet-g-answers" role="group" aria-labelledby="ovnet-gh-answers" data-group="answers" style="--g:#f59e0b"><h3 id="ovnet-gh-answers"><i class="fa-solid fa-newspaper" aria-hidden="true"></i><span>News & answers</span><b class="ovnet-n">8</b></h3><p class="ovnet-gline">News, reports, reviews and answers</p><ul class="ovnet-sites" data-ovk-scroll><li><a class="ovnet-site" href="https://openvibe.news/" data-site="news" style="--i:13"><span class="ov-icon" data-icon="news" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>News</b><small>openvibe.news</small><span>Sourced, not spun</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.report/" data-site="report" style="--i:14"><span class="ov-icon" data-icon="report" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Report</b><small>openvibe.report</small><span>Your numbers, honest</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.blog/" data-site="blog" style="--i:15"><span class="ov-icon" data-icon="blog" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Blog</b><small>openvibe.blog</small><span>Write long-form</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.today/" data-site="today" style="--i:16"><span class="ov-icon" data-icon="today" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Today</b><small>openvibe.today</small><span>Everything happening for you, today</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.reviews/" data-site="reviews" style="--i:17"><span class="ov-icon" data-icon="reviews" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Reviews</b><small>openvibe.reviews</small><span>Honest reviews</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.vision/" data-site="vision" style="--i:18"><span class="ov-icon" data-icon="vision" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Vision</b><small>openvibe.vision</small><span>Ask a picture</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.wiki/" data-site="wiki" style="--i:19"><span class="ov-icon" data-icon="wiki" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Wiki</b><small>openvibe.wiki</small><span>Facts with sources</span></span></a></li><li><a class="ovnet-site" href="https://search.openvibe.network/" data-site="search" style="--i:20"><span class="ov-icon" data-icon="search" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Search</b><small>search.openvibe.network</small><span>Search everything</span></span></a></li></ul></div><div class="ovnet-group" id="ovnet-g-make" role="group" aria-labelledby="ovnet-gh-make" data-group="make" style="--g:#3b82f6"><h3 id="ovnet-gh-make"><i class="fa-solid fa-wand-magic-sparkles" aria-hidden="true"></i><span>Make & build</span><b class="ovnet-n">8</b></h3><p class="ovnet-gline">Tools, sites, bots, designs and code</p><ul class="ovnet-sites" data-ovk-scroll><li><a class="ovnet-site" href="https://openvibe.tools/" data-site="tools" style="--i:21"><span class="ov-icon" data-icon="tools" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Tools</b><small>openvibe.tools</small><span>Every online tool</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.space/" data-site="space" style="--i:22"><span class="ov-icon" data-icon="space" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Space</b><small>openvibe.space</small><span>Host a site or app</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.bot/" data-site="bot" style="--i:23"><span class="ov-icon" data-icon="bot" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Bot</b><small>openvibe.bot</small><span>Drive a robot</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.design/" data-site="design" style="--i:24"><span class="ov-icon" data-icon="design" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Design</b><small>openvibe.design</small><span>Make it yours</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.host/" data-site="host" style="--i:25"><span class="ov-icon" data-icon="host" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Host</b><small>openvibe.host</small><span>Compare hosting</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.domains/" data-site="domains" style="--i:26"><span class="ov-icon" data-icon="domains" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Domains</b><small>openvibe.domains</small><span>Name it, price it, run it</span></span></a></li><li><a class="ovnet-site" href="https://connect.openvibe.website/" data-site="connect" style="--i:27"><span class="ov-icon" data-icon="connect" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Connect</b><small>connect.openvibe.website</small><span>Your computer, on the web, privately</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.codes/" data-site="codes" style="--i:28"><span class="ov-icon" data-icon="codes" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Codes</b><small>openvibe.codes</small><span>Code with any agent</span></span></a></li></ul></div><div class="ovnet-group" id="ovnet-g-shop" role="group" aria-labelledby="ovnet-gh-shop" data-group="shop" style="--g:#22c55e"><h3 id="ovnet-gh-shop"><i class="fa-solid fa-basket-shopping" aria-hidden="true"></i><span>Shop & swap</span><b class="ovnet-n">8</b></h3><p class="ovnet-gline">Deals, jobs, food, homes and trades</p><ul class="ovnet-sites" data-ovk-scroll><li><a class="ovnet-site" href="https://openvibe.coupons/" data-site="coupons" style="--i:29"><span class="ov-icon" data-icon="coupons" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Coupons</b><small>openvibe.coupons</small><span>Codes that work</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.work/" data-site="work" style="--i:30"><span class="ov-icon" data-icon="work" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Work</b><small>openvibe.work</small><span>Find a job</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.deals/" data-site="deals" style="--i:31"><span class="ov-icon" data-icon="deals" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Deals</b><small>openvibe.deals</small><span>Deals people share</span></span></a></li><li><a class="ovnet-site" href="https://open.shoes/" data-site="shoes" style="--i:32"><span class="ov-icon" data-icon="shoes" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Shoes</b><small>open.shoes</small><span>Every shoe, every market, the right price</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.trade/" data-site="trade" style="--i:33"><span class="ov-icon" data-icon="trade" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Trade</b><small>openvibe.trade</small><span>Watch the markets</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.food/" data-site="food" style="--i:34"><span class="ov-icon" data-icon="food" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Food</b><small>openvibe.food</small><span>Food near you</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.rent/" data-site="rent" style="--i:35"><span class="ov-icon" data-icon="rent" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Rent</b><small>openvibe.rent</small><span>Places to rent</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.homes/" data-site="homes" style="--i:36"><span class="ov-icon" data-icon="homes" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Homes</b><small>openvibe.homes</small><span>Homes for sale</span></span></a></li></ul></div><div class="ovnet-group" id="ovnet-g-people" role="group" aria-labelledby="ovnet-gh-people" data-group="people" style="--g:#ec4899"><h3 id="ovnet-gh-people"><i class="fa-solid fa-user-group" aria-hidden="true"></i><span>People & you</span><b class="ovnet-n">7</b></h3><p class="ovnet-gline">Your people, your account, your stuff</p><ul class="ovnet-sites" data-ovk-scroll><li><a class="ovnet-site" href="https://openvibe.community/" data-site="community" style="--i:37"><span class="ov-icon" data-icon="community" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Community</b><small>openvibe.community</small><span>Posts and pastes</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.network/" data-site="network" style="--i:38"><span class="ov-icon" data-icon="network" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Network</b><small>openvibe.network</small><span>Your account</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.chat/" data-site="chat" style="--i:39"><span class="ov-icon" data-icon="chat" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Chat</b><small>openvibe.chat</small><span>Chat with anyone</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.tips/" data-site="tips" style="--i:40"><span class="ov-icon" data-icon="tips" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Tips</b><small>openvibe.tips</small><span>Support streamers</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.vip/" data-site="vip" style="--i:41"><span class="ov-icon" data-icon="vip" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>VIP</b><small>openvibe.vip</small><span>Back your creators</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.help/" data-site="help" style="--i:42"><span class="ov-icon" data-icon="help" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Help</b><small>openvibe.help</small><span>Help nearby</span></span></a></li><li><a class="ovnet-site" href="https://inventory.openvibe.network/" data-site="inventory" style="--i:43"><span class="ov-icon" data-icon="inventory" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Inventory</b><small>inventory.openvibe.network</small><span>Your items</span></span></a></li></ul></div><div class="ovnet-group" id="ovnet-g-build" role="group" aria-labelledby="ovnet-gh-build" data-group="build" style="--g:#14b8a6"><h3 id="ovnet-gh-build"><i class="fa-solid fa-code" aria-hidden="true"></i><span>For developers</span><b class="ovnet-n">11</b></h3><p class="ovnet-gline">APIs and services to build on</p><ul class="ovnet-sites" data-ovk-scroll><li><a class="ovnet-site" href="https://openvibe.media/" data-site="media" style="--i:44"><span class="ov-icon" data-icon="media" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Media</b><small>openvibe.media</small><span>Media API</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.exchange/" data-site="exchange" style="--i:45"><span class="ov-icon" data-icon="exchange" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Exchange</b><small>openvibe.exchange</small><span>AI models, one account</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.zone/" data-site="zone" style="--i:46"><span class="ov-icon" data-icon="zone" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Zone</b><small>openvibe.zone</small><span>S3 storage</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.services/" data-site="services" style="--i:47"><span class="ov-icon" data-icon="services" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Services</b><small>openvibe.services</small><span>APIs, SDK and keys</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.actor/" data-site="actor" style="--i:48"><span class="ov-icon" data-icon="actor" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Actor</b><small>openvibe.actor</small><span>The agent engine</span></span></a></li><li><a class="ovnet-site" href="https://ai.openvibe.services/" data-site="ai" style="--i:49"><span class="ov-icon" data-icon="ai" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>AI</b><small>ai.openvibe.services</small><span>AI router, your keys</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.events/" data-site="events" style="--i:50"><span class="ov-icon" data-icon="events" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Events</b><small>openvibe.events</small><span>Event streams</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.watch/" data-site="watch" style="--i:51"><span class="ov-icon" data-icon="watch" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Watch</b><small>openvibe.watch</small><span>Know when it changes</span></span></a></li><li><a class="ovnet-site" href="https://play.openvibe.network/" data-site="play" style="--i:52"><span class="ov-icon" data-icon="play" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Play</b><small>play.openvibe.network</small><span>Game services</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.run/" data-site="run" style="--i:53"><span class="ov-icon" data-icon="run" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Run</b><small>openvibe.run</small><span>Run jobs anywhere</span></span></a></li><li><a class="ovnet-site" href="https://bench.openvibe.services/" data-site="bench" style="--i:54"><span class="ov-icon" data-icon="bench" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Bench</b><small>bench.openvibe.services</small><span>Which AI is best</span></span></a></li></ul></div></div>++ <div class="ovnet-groups" data-sites="55"><div class="ovnet-group" id="ovnet-g-streams" role="group" aria-labelledby="ovnet-gh-streams" data-group="streams" style="--g:#ef4444"><h3 id="ovnet-gh-streams"><i class="fa-solid fa-tower-broadcast" aria-hidden="true"></i><span>Streams & places</span><b class="ovnet-n">7</b></h3><p class="ovnet-gline">Go live, watch, share pictures and video</p><ul class="ovnet-sites" data-ovk-scroll><li><a class="ovnet-site" href="https://openvibe.live/" data-site="live" style="--i:0"><span class="ov-icon" data-icon="live" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Live</b><small>openvibe.live</small><span>Go live, watch streams</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.video/" data-site="media-hub" style="--i:1"><span class="ov-icon" data-icon="video" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Video</b><small>openvibe.video</small><span>Clips and past streams</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.pics/" data-site="pics" style="--i:2"><span class="ov-icon" data-icon="pics" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Pics</b><small>openvibe.pics</small><span>Share pictures</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.download/" data-site="download" style="--i:3"><span class="ov-icon" data-icon="download" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Download</b><small>openvibe.download</small><span>Your files</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.cam/" data-site="cam" style="--i:4"><span class="ov-icon" data-icon="cam" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Cam</b><small>openvibe.cam</small><span>Your camera, your cameras, and Live's cam button</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.tours/" data-site="tours" style="--i:5"><span class="ov-icon" data-icon="tours" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Tours</b><small>openvibe.tours</small><span>Go anywhere: fly, stay, walk, watch</span></span></a></li><li><a class="ovnet-site" href="https://openre.stream/" data-site="stream" style="--i:6"><span class="ov-icon" data-icon="stream" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Restream</b><small>openre.stream</small><span>Stream everywhere</span></span></a></li></ul></div><div class="ovnet-group" id="ovnet-g-games" role="group" aria-labelledby="ovnet-gh-games" data-group="games" style="--g:#a855f7"><h3 id="ovnet-gh-games"><i class="fa-solid fa-gamepad" aria-hidden="true"></i><span>Games & laughs</span><b class="ovnet-n">6</b></h3><p class="ovnet-gline">Play in the browser, laugh, explore</p><ul class="ovnet-sites" data-ovk-scroll><li><a class="ovnet-site" href="https://openvibe.games/" data-site="games" style="--i:7"><span class="ov-icon" data-icon="games" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Games</b><small>openvibe.games</small><span>Play in your browser</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.fun/" data-site="fun" style="--i:8"><span class="ov-icon" data-icon="fun" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Fun</b><small>openvibe.fun</small><span>Something to do</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.casino/" data-site="casino" style="--i:9"><span class="ov-icon" data-icon="casino" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Casino</b><small>openvibe.casino</small><span>Learn it, play it, never pay it</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.meme/" data-site="meme" style="--i:10"><span class="ov-icon" data-icon="meme" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Meme</b><small>openvibe.meme</small><span>The internet's meme database</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.surf/" data-site="surf" style="--i:11"><span class="ov-icon" data-icon="surf" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Surf</b><small>openvibe.surf</small><span>Ride, browse, watch</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.quest/" data-site="quest" style="--i:12"><span class="ov-icon" data-icon="quest" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Quest</b><small>openvibe.quest</small><span>Earn badges</span></span></a></li></ul></div><div class="ovnet-group" id="ovnet-g-answers" role="group" aria-labelledby="ovnet-gh-answers" data-group="answers" style="--g:#f59e0b"><h3 id="ovnet-gh-answers"><i class="fa-solid fa-newspaper" aria-hidden="true"></i><span>News & answers</span><b class="ovnet-n">8</b></h3><p class="ovnet-gline">News, reports, reviews and answers</p><ul class="ovnet-sites" data-ovk-scroll><li><a class="ovnet-site" href="https://openvibe.news/" data-site="news" style="--i:13"><span class="ov-icon" data-icon="news" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>News</b><small>openvibe.news</small><span>Sourced, not spun</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.report/" data-site="report" style="--i:14"><span class="ov-icon" data-icon="report" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Report</b><small>openvibe.report</small><span>Your numbers, honest</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.blog/" data-site="blog" style="--i:15"><span class="ov-icon" data-icon="blog" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Blog</b><small>openvibe.blog</small><span>Write long-form</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.today/" data-site="today" style="--i:16"><span class="ov-icon" data-icon="today" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Today</b><small>openvibe.today</small><span>Everything happening for you, today</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.reviews/" data-site="reviews" style="--i:17"><span class="ov-icon" data-icon="reviews" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Reviews</b><small>openvibe.reviews</small><span>Honest reviews</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.vision/" data-site="vision" style="--i:18"><span class="ov-icon" data-icon="vision" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Vision</b><small>openvibe.vision</small><span>Ask a picture</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.wiki/" data-site="wiki" style="--i:19"><span class="ov-icon" data-icon="wiki" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Wiki</b><small>openvibe.wiki</small><span>Facts with sources</span></span></a></li><li><a class="ovnet-site" href="https://search.openvibe.network/" data-site="search" style="--i:20"><span class="ov-icon" data-icon="search" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Search</b><small>search.openvibe.network</small><span>Search everything</span></span></a></li></ul></div><div class="ovnet-group" id="ovnet-g-make" role="group" aria-labelledby="ovnet-gh-make" data-group="make" style="--g:#3b82f6"><h3 id="ovnet-gh-make"><i class="fa-solid fa-sparkles" aria-hidden="true"></i><span>Make & build</span><b class="ovnet-n">8</b></h3><p class="ovnet-gline">Tools, sites, bots, designs and code</p><ul class="ovnet-sites" data-ovk-scroll><li><a class="ovnet-site" href="https://openvibe.tools/" data-site="tools" style="--i:21"><span class="ov-icon" data-icon="tools" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Tools</b><small>openvibe.tools</small><span>Every online tool</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.space/" data-site="space" style="--i:22"><span class="ov-icon" data-icon="space" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Space</b><small>openvibe.space</small><span>Host a site or app</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.bot/" data-site="bot" style="--i:23"><span class="ov-icon" data-icon="bot" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Bot</b><small>openvibe.bot</small><span>Drive a robot</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.design/" data-site="design" style="--i:24"><span class="ov-icon" data-icon="design" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Design</b><small>openvibe.design</small><span>Make it yours</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.host/" data-site="host" style="--i:25"><span class="ov-icon" data-icon="host" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Host</b><small>openvibe.host</small><span>Compare hosting</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.domains/" data-site="domains" style="--i:26"><span class="ov-icon" data-icon="domains" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Domains</b><small>openvibe.domains</small><span>Name it, price it, run it</span></span></a></li><li><a class="ovnet-site" href="https://connect.openvibe.website/" data-site="connect" style="--i:27"><span class="ov-icon" data-icon="connect" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Connect</b><small>connect.openvibe.website</small><span>Your computer, on the web, privately</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.codes/" data-site="codes" style="--i:28"><span class="ov-icon" data-icon="codes" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Codes</b><small>openvibe.codes</small><span>Code with any agent</span></span></a></li></ul></div><div class="ovnet-group" id="ovnet-g-shop" role="group" aria-labelledby="ovnet-gh-shop" data-group="shop" style="--g:#22c55e"><h3 id="ovnet-gh-shop"><i class="fa-solid fa-basket-shopping" aria-hidden="true"></i><span>Shop & swap</span><b class="ovnet-n">8</b></h3><p class="ovnet-gline">Deals, jobs, food, homes and trades</p><ul class="ovnet-sites" data-ovk-scroll><li><a class="ovnet-site" href="https://openvibe.coupons/" data-site="coupons" style="--i:29"><span class="ov-icon" data-icon="coupons" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Coupons</b><small>openvibe.coupons</small><span>Codes that work</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.work/" data-site="work" style="--i:30"><span class="ov-icon" data-icon="work" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Work</b><small>openvibe.work</small><span>Find a job</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.deals/" data-site="deals" style="--i:31"><span class="ov-icon" data-icon="deals" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Deals</b><small>openvibe.deals</small><span>Deals people share</span></span></a></li><li><a class="ovnet-site" href="https://open.shoes/" data-site="shoes" style="--i:32"><span class="ov-icon" data-icon="shoes" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Shoes</b><small>open.shoes</small><span>Every shoe, every market, the right price</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.trade/" data-site="trade" style="--i:33"><span class="ov-icon" data-icon="trade" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Trade</b><small>openvibe.trade</small><span>Watch the markets</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.food/" data-site="food" style="--i:34"><span class="ov-icon" data-icon="food" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Food</b><small>openvibe.food</small><span>Food near you</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.rent/" data-site="rent" style="--i:35"><span class="ov-icon" data-icon="rent" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Rent</b><small>openvibe.rent</small><span>Places to rent</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.homes/" data-site="homes" style="--i:36"><span class="ov-icon" data-icon="homes" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Homes</b><small>openvibe.homes</small><span>Homes for sale</span></span></a></li></ul></div><div class="ovnet-group" id="ovnet-g-people" role="group" aria-labelledby="ovnet-gh-people" data-group="people" style="--g:#ec4899"><h3 id="ovnet-gh-people"><i class="fa-solid fa-user-group" aria-hidden="true"></i><span>People & you</span><b class="ovnet-n">7</b></h3><p class="ovnet-gline">Your people, your account, your stuff</p><ul class="ovnet-sites" data-ovk-scroll><li><a class="ovnet-site" href="https://openvibe.community/" data-site="community" style="--i:37"><span class="ov-icon" data-icon="community" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Community</b><small>openvibe.community</small><span>Posts and pastes</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.network/" data-site="network" style="--i:38"><span class="ov-icon" data-icon="network" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Network</b><small>openvibe.network</small><span>Your account</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.chat/" data-site="chat" style="--i:39"><span class="ov-icon" data-icon="chat" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Chat</b><small>openvibe.chat</small><span>Chat with anyone</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.tips/" data-site="tips" style="--i:40"><span class="ov-icon" data-icon="tips" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Tips</b><small>openvibe.tips</small><span>Support streamers</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.vip/" data-site="vip" style="--i:41"><span class="ov-icon" data-icon="vip" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>VIP</b><small>openvibe.vip</small><span>Back your creators</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.help/" data-site="help" style="--i:42"><span class="ov-icon" data-icon="help" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Help</b><small>openvibe.help</small><span>Help nearby</span></span></a></li><li><a class="ovnet-site" href="https://inventory.openvibe.network/" data-site="inventory" style="--i:43"><span class="ov-icon" data-icon="inventory" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Inventory</b><small>inventory.openvibe.network</small><span>Your items</span></span></a></li></ul></div><div class="ovnet-group" id="ovnet-g-build" role="group" aria-labelledby="ovnet-gh-build" data-group="build" style="--g:#14b8a6"><h3 id="ovnet-gh-build"><i class="fa-solid fa-code" aria-hidden="true"></i><span>For developers</span><b class="ovnet-n">11</b></h3><p class="ovnet-gline">APIs and services to build on</p><ul class="ovnet-sites" data-ovk-scroll><li><a class="ovnet-site" href="https://openvibe.media/" data-site="media" style="--i:44"><span class="ov-icon" data-icon="media" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Media</b><small>openvibe.media</small><span>Media API</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.exchange/" data-site="exchange" style="--i:45"><span class="ov-icon" data-icon="exchange" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Exchange</b><small>openvibe.exchange</small><span>AI models, one account</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.zone/" data-site="zone" style="--i:46"><span class="ov-icon" data-icon="zone" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Zone</b><small>openvibe.zone</small><span>S3 storage</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.services/" data-site="services" style="--i:47"><span class="ov-icon" data-icon="services" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Services</b><small>openvibe.services</small><span>APIs, SDK and keys</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.actor/" data-site="actor" style="--i:48"><span class="ov-icon" data-icon="actor" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Actor</b><small>openvibe.actor</small><span>The agent engine</span></span></a></li><li><a class="ovnet-site" href="https://ai.openvibe.services/" data-site="ai" style="--i:49"><span class="ov-icon" data-icon="ai" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>AI</b><small>ai.openvibe.services</small><span>AI router, your keys</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.events/" data-site="events" style="--i:50"><span class="ov-icon" data-icon="events" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Events</b><small>openvibe.events</small><span>Event streams</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.watch/" data-site="watch" style="--i:51"><span class="ov-icon" data-icon="watch" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Watch</b><small>openvibe.watch</small><span>Know when it changes</span></span></a></li><li><a class="ovnet-site" href="https://play.openvibe.network/" data-site="play" style="--i:52"><span class="ov-icon" data-icon="play" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Play</b><small>play.openvibe.network</small><span>Game services</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.run/" data-site="run" style="--i:53"><span class="ov-icon" data-icon="run" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Run</b><small>openvibe.run</small><span>Run jobs anywhere</span></span></a></li><li><a class="ovnet-site" href="https://bench.openvibe.services/" data-site="bench" style="--i:54"><span class="ov-icon" data-icon="bench" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Bench</b><small>bench.openvibe.services</small><span>Which AI is best</span></span></a></li></ul></div></div><footer class="ovnet-foot"><a class="ovnet-cta" href="/api/auth/sso/login?next=%2F" data-ovnet-signin><i class="fa-solid fa-user-plus" aria-hidden="true"></i> Create your free account</a><button type="button" class="ovnet-switch" data-ovnet-switcher><span class="ovnet-orb" aria-hidden="true"><svg class="ovmb" viewBox="0 0 24 24" width="22" height="22"><rect class="ovmb-bar" x="4" y="5.2" width="16" height="5.6" rx="2.8"/><circle class="ovmb-dot" cx="7.4" cy="8" r="1.15"/><rect class="ovmb-app ovmb-a0" x="4.2" y="14.6" width="4" height="4" rx="1.3"/><rect class="ovmb-app ovmb-a1" x="10" y="14.6" width="4" height="4" rx="1.3"/><rect class="ovmb-app ovmb-a2" x="15.8" y="14.6" width="4" height="4" rx="1.3"/></svg></span><span class="ovnet-switch-t"><b>Every site is in the menu</b><small>This button, at the top of every page</small></span><i class="ovnet-switch-go" aria-hidden="true"></i></button>@@@ -1,7 +1,7 @@<div class="cf-wrap cf-wrap--ai" data-feed="moments"><header class="cf-head"><div class="cf-head-text">-- <h1 class="cf-h1"><span class="cf-ai-mark" aria-hidden="true"><i class="fa-solid fa-wand-magic-sparkles"></i></span> AI Moments</h1>++ <h1 class="cf-h1"><span class="cf-ai-mark" aria-hidden="true"><i class="fa-solid fa-sparkles"></i></span> AI Moments</h1><p class="cf-sub">Auto-clips, standout moments and after-show recaps the AI made from OpenVibe streams. Everything here is AI-generated.</p></div><div class="cf-head-actions">@@@ -128,7 +128,7 @@<div class="aiv-senses" id="aiv-senses"></div>${GROUPS.map((g, i) => `<details class="aiv-group" ${i === 0 ? 'open' : ''}><summary><i class="fa-solid ${g.icon}"></i> ${g.title}</summary><div class="aiv-group-body">${g.keys.filter(k => byKey[k]).map(k => fieldHtml(byKey[k], s[k])).join('')}</div></details>`).join('')}<div class="ai-bot-actions">-- <button class="btn btn-outline btn-small" onclick="previewAiViewers(this)"><i class="fa-solid fa-wand-magic-sparkles"></i> Preview what they'd say now</button>++ <button class="btn btn-outline btn-small" onclick="previewAiViewers(this)"><i class="fa-solid fa-sparkles"></i> Preview what they'd say now</button><span class="muted" id="aiv-save-state" style="font-size:0.8rem"></span></div><div id="aiv-preview" class="ai-bot-preview" style="display:none;"></div>`;@@@ -763,7 +763,7 @@ function _channelPasteCardHTML(p, canManage) {<div class="ch-paste-info"><div class="ch-paste-title">${esc(p.title || 'Untitled')} ${vis}</div><div class="ch-paste-meta muted"><span>${timeAgo(p.created_at)}</span> · <span><i class="fa-solid fa-eye"></i> ${p.views || 0}</span></div>-- ${(typeof _cardAiHTML === 'function') ? _cardAiHTML(p.ai_summary) : ((p.ai_summary && p.ai_summary.trim()) ? `<div class="card-ai-overview"><i class="fa-solid fa-wand-magic-sparkles"></i> ${esc(p.ai_summary)}</div>` : '')}++ ${(typeof _cardAiHTML === 'function') ? _cardAiHTML(p.ai_summary) : ((p.ai_summary && p.ai_summary.trim()) ? `<div class="card-ai-overview"><i class="fa-solid fa-sparkles"></i> ${esc(p.ai_summary)}</div>` : '')}</div></a>`;}@@@ -2975,7 +2975,7 @@ async function loadChannelAiTimeline(username) {const hasBothOverviews = !!(streamerOv && chatOverall);const topText = hasBothOverviews ? (combined || `${streamerOv}\n\n${chatOverall}`) : '';const header = topText-- ? `<div class="ai-tl-overview-card"><div class="ai-tl-overview-label"><i class="fa-solid fa-wand-magic-sparkles"></i> Overall AI overview <span class="ai-tl-ov-sub">as a streamer & chatter</span></div>${_collapsibleOverview(topText)}</div>`++ ? `<div class="ai-tl-overview-card"><div class="ai-tl-overview-label"><i class="fa-solid fa-sparkles"></i> Overall AI overview <span class="ai-tl-ov-sub">as a streamer & chatter</span></div>${_collapsibleOverview(topText)}</div>`: '';// ── As a streamer (inner content, no label — the sub-tab / side-label supplies it) ──@@@ -3498,7 +3498,7 @@ function _aboutAiEffectiveShow() {function _aboutAiOverviewHTML() {if (!_aboutAiEffectiveShow()) return '';return `<section class="ch-about-sec about-ai-overview" aria-label="AI summary">-- <h3 class="ch-about-h ch-sub-h"><i class="fa-solid fa-wand-magic-sparkles ch-sec-ic" aria-hidden="true"></i>AI summary <span class="ch-about-h-note">from past streams</span></h3>++ <h3 class="ch-about-h ch-sub-h"><i class="fa-solid fa-sparkles ch-sec-ic" aria-hidden="true"></i>AI summary <span class="ch-about-h-note">from past streams</span></h3>${_collapsibleOverview(_aboutAiOverview)}</section>`;}@@@ -3952,7 +3952,7 @@ function _renderAboutEdit() {<div class="ch-about-ai-toggle"><label class="ch-about-mods-toggle" title="Show the AI-generated overview at the top of your About tab"><input type="checkbox" id="ch-about-ai-overview-toggle" ${_aboutAiEffectiveShow() ? 'checked' : ''} onchange="_aboutAiPref=this.checked?'show':'hide';_markAboutDirty()">-- <span><i class="fa-solid fa-wand-magic-sparkles"></i> Show AI overview</span>++ <span><i class="fa-solid fa-sparkles"></i> Show AI overview</span></label><span class="muted ch-about-ai-hint">An AI-written summary of your streams. Shown automatically until you add a bio; turn it on here to keep showing it, or off to hide it.</span></div>` : ''}@@@ -4555,55 +4555,20 @@ async function offerLiveAlerts(name) {}/**-- * The ⋯ menu opens in the top layer, placed on the screen (ask 371, 2026-11-13: "the little three dots doesn't actually-- * open a menu, at least not on mobile"): on a phone the live layout clips what hangs out of it, so the menu opened-- * unseen. Below its button, or above it when there is no room; inside the visible screen; again on a scroll or a resize;-- * a press elsewhere or Escape closes it. Without the Popover API it is fixed, which a clipping ancestor still allows.++ * The ⋯ menu is the network's layer (openvibe-shared 4.235.0 OVSurfaces): anchored to its button in the top layer,++ * placed inside the visible screen, above the button when there is no room below, again on a scroll or a resize; one++ * open at a time, a press elsewhere or Escape closes it (ask 371: on a phone the live layout clipped it).*/function _wireChannelMoreLayer(menu) {if (menu.dataset.layer) return;menu.dataset.layer = '1';const pop = menu.querySelector('.ch-more-menu');const summary = menu.querySelector('summary');-- if (!pop || !summary) return;-- const place = () => {-- if (!menu.open) return;-- const s = summary.getBoundingClientRect();-- const vv = window.visualViewport;-- const ox = vv ? vv.offsetLeft : 0, oy = vv ? vv.offsetTop : 0;-- const vw = vv ? vv.width : innerWidth, vh = vv ? vv.height : innerHeight;-- const w = pop.offsetWidth, h = pop.offsetHeight, m = 8, gap = 6;-- const left = Math.min(Math.max(ox + m, s.right - w), ox + vw - w - m);-- let top = s.bottom + gap;-- const above = top + h > oy + vh - m;-- if (above) top = s.top - h - gap >= oy + m ? s.top - h - gap : Math.max(oy + m, oy + vh - h - m);-- pop.style.left = `${Math.round(left)}px`;-- pop.style.top = `${Math.round(top)}px`;-- pop.classList.toggle('is-above', above);-- };-- const onDown = (e) => { if (!menu.contains(e.target)) menu.open = false; };-- const onKey = (e) => { if (e.key === 'Escape') { menu.open = false; summary.focus(); } };++ let surface = null;menu.addEventListener('toggle', () => {-- const topLayer = typeof pop.showPopover === 'function';-- if (!menu.open) {-- if (topLayer && pop.hasAttribute('popover')) { try { pop.hidePopover(); } catch { /* closed */ } pop.removeAttribute('popover'); }-- pop.classList.remove('is-layer', 'is-above');-- pop.style.left = pop.style.top = '';-- document.removeEventListener('pointerdown', onDown, true);-- document.removeEventListener('keydown', onKey, true);-- window.removeEventListener('scroll', place, true);-- window.removeEventListener('resize', place);-- window.visualViewport?.removeEventListener('resize', place);-- return;-- }-- pop.classList.add('is-layer');-- if (topLayer) { pop.setAttribute('popover', 'manual'); try { pop.showPopover(); } catch { /* stays fixed */ } }-- place();-- document.addEventListener('pointerdown', onDown, true);-- document.addEventListener('keydown', onKey, true);-- window.addEventListener('scroll', place, { capture: true, passive: true });-- window.addEventListener('resize', place);-- window.visualViewport?.addEventListener('resize', place);++ if (!window.OVSurfaces || !pop || !summary) return;++ if (!surface) surface = window.OVSurfaces.popover({ el: menu, trigger: summary, pop, place: 'bottom-end', close: () => { menu.open = false; } });++ if (menu.open) surface.open(); else surface.close();});}@@@ -21,7 +21,11 @@ async function loadGlobalChatAi() {if (!strip || !panel) return;let insight = null;try { insight = (await api('/chat/ai/global')).insight; } catch { /* silent */ }++ // A read older than 12 hours is not "now": the strip steps aside (the panel keeps the notes and the timeline). The++ // owner, 11-13: Global's line was three days old.++ if (insight && insight.updated_at && _gaiAgeMs(insight.updated_at) > 12 * 3600e3) insight = { ...insight, overview: '' };if (!insight || !insight.overview) {++ _gaiTicker(null, '');strip.style.display = 'none';// Keep the panel only if the user had already opened it.if (!panel.classList.contains('gai-user-opened')) panel.style.display = 'none';@@@ -29,8 +33,8 @@ async function loadGlobalChatAi() {}// Header strip (compact)const stripText = document.getElementById('global-ai-strip-text');-- if (stripText) stripText.textContent = insight.overview;strip.style.display = 'flex';++ if (stripText) _gaiTicker(stripText, insight.overview);// Full panelconst ov = document.getElementById('global-ai-overview');@@@ -50,6 +54,45 @@ async function loadGlobalChatAi() {if (mem && !insight.memory) { mem.innerHTML = '<span class="gai-empty">Still building a picture of the community…</span>'; }}++/** How old a read is; Chat's times are UTC with no zone ("2026-11-10 08:38:23"). */++function _gaiAgeMs(ts) {++ const s = String(ts || '');++ const t = Date.parse(/[zZ]|[+-]\d\d:?\d\d$/.test(s) ? s : `${s.replace(' ', 'T')}Z`);++ return Number.isFinite(t) ? Date.now() - t : 0;++}++++/**++ * The strip reads like a ticker (the owner, 11-13: "make it scroll … scrolls through the summary … click to expand"):++ * one sentence at a time, each rolling in every five seconds; one longer than the strip pans to its end first. It++ * waits while a pointer is over it, while the panel is open and while the tab is hidden. With motion off the words++ * change in place.++ */++let _gaiTick = { timer: 0, parts: [], i: 0 };++function _gaiTicker(el, text) {++ clearInterval(_gaiTick.timer);++ const parts = (String(text || '').match(/[^.!?…]+[.!?…]+["”’)]*|[^.!?…]+$/g) || []).map((x) => x.trim()).filter(Boolean).slice(0, 4);++ _gaiTick = { timer: 0, parts, i: 0 };++ if (!el) return;++ const show = (i, roll) => {++ let t = el.querySelector('.gai-t');++ if (!t) { t = document.createElement('span'); t.className = 'gai-t'; el.replaceChildren(t); }++ t.textContent = parts[i] || '';++ el.classList.remove('gai-roll', 'gai-pan');++ void el.offsetWidth;++ if (roll) el.classList.add('gai-roll');++ const over = t.scrollWidth - el.clientWidth;++ if (over > 4) { el.style.setProperty('--gai-shift', `${-over - 8}px`); el.style.setProperty('--gai-pan-s', `${Math.min(9, 2 + over / 40)}s`); el.classList.add('gai-pan'); }++ };++ show(0, false);++ if (parts.length < 2 && !el.classList.contains('gai-pan')) return;++ _gaiTick.timer = setInterval(() => {++ if (document.hidden || !el.isConnected || el.matches(':hover') || el.closest('.global-ai-strip.open')) return;++ if (parts.length < 2) { show(0, false); return; }++ _gaiTick.i = (_gaiTick.i + 1) % parts.length;++ show(_gaiTick.i, true);++ }, 5200 + (el.classList.contains('gai-pan') ? 2000 : 0));++}++// ── Global chat AI timeline browser (search + period jump + infinite scroll) ─────────────let _gaiTlInited = false;let _gaiTl = { q: '', since: null, periodBefore: null, oldestTs: 0, loading: false, done: false, lastDay: null };@@@ -2453,12 +2453,12 @@ function _renderHomeStar(data) {} else if (bioSrc) {bioHtml = `<p class="star-bio">${_linkify(esc(bioSrc))}</p>`;}-- const overview = s.ai_overview ? `<p class="star-overview"><i class="fa-solid fa-wand-magic-sparkles"></i> ${esc(String(s.ai_overview).slice(0, 260))}${String(s.ai_overview).length > 260 ? '…' : ''}</p>` : '';++ const overview = s.ai_overview ? `<p class="star-overview"><i class="fa-solid fa-sparkles"></i> ${esc(String(s.ai_overview).slice(0, 260))}${String(s.ai_overview).length > 260 ? '…' : ''}</p>` : '';const pick = s.pick || null;// Who picked them and when the next star lands are two separate facts, each kept on one line:// as one uppercase run the countdown broke in half ("NEXT STAR / IN 3H") on a phone.const why = pick && (pick.headline || pick.reason)-- ? `<div class="star-why">${pick.headline ? `<div class="star-why-head">${esc(pick.headline)}</div>` : ''}${pick.reason ? `<div class="star-why-body">${esc(pick.reason)}</div>` : ''}<div class="star-why-meta"><span><i class="fa-solid fa-wand-magic-sparkles"></i> ${pick.by === 'ai' ? 'Picked by OpenVibe\'s AI' : 'Picked on the numbers'}</span>${pick.next_at ? `<span class="star-why-next"><i class="fa-regular fa-clock"></i> Next star ${esc(_starCountdown(pick.next_at))}</span>` : ''}</div></div>`++ ? `<div class="star-why">${pick.headline ? `<div class="star-why-head">${esc(pick.headline)}</div>` : ''}${pick.reason ? `<div class="star-why-body">${esc(pick.reason)}</div>` : ''}<div class="star-why-meta"><span><i class="fa-solid fa-sparkles"></i> ${pick.by === 'ai' ? 'Picked by OpenVibe\'s AI' : 'Picked on the numbers'}</span>${pick.next_at ? `<span class="star-why-next"><i class="fa-regular fa-clock"></i> Next star ${esc(_starCountdown(pick.next_at))}</span>` : ''}</div></div>`: '';const chips = [foreign ? `<span class="star-chip pink">${esc(lang.flag)} Streams in ${esc(lang.name)}</span>` : '',@@@ -117,7 +117,7 @@ function _cardAiHTML(text, full) {// -webkit-line-clamp overflow can't clip it away; it sits at the bottom-right.const showToggle = hasMore || display.length > 100;return `<div class="card-ai-overview${showToggle ? '' : ' card-ai-static'}" role="button" tabindex="0" onclick="toggleCardAi(event,this)" onkeydown="if(event.key==='Enter'||event.key===' ')toggleCardAi(event,this)"${hasMore ? ` data-full="${esc(long)}"` : ''}>-- <div class="card-ai-clamp"><i class="fa-solid fa-wand-magic-sparkles"></i> <span class="card-ai-text">${esc(display)}</span></div>++ <div class="card-ai-clamp"><i class="fa-solid fa-sparkles"></i> <span class="card-ai-text">${esc(display)}</span></div>${showToggle ? '<i class="card-ai-toggle fa-solid fa-chevron-down" aria-label="Toggle overview"></i>' : ''}</div>`;}@@@ -1487,7 +1487,7 @@ async function _openChatInsightModal(opts) {<div class="user-ai-modal"><div class="user-ai-modal-head"><h3><i class="fa-solid ${opts.iconClass || 'fa-user-tag'}"></i> ${esc(opts.title || 'User')}</h3>-- <span class="gai-badge"><i class="fa-solid fa-wand-magic-sparkles"></i> AI</span>++ <span class="gai-badge"><i class="fa-solid fa-sparkles"></i> AI</span><button class="uai-close" onclick="document.getElementById('user-ai-modal-overlay').remove()"><i class="fa-solid fa-xmark"></i></button></div><div id="user-ai-modal-body"><div class="gai-empty">Loading insight…</div></div>@@@ -1523,7 +1523,7 @@ async function _openChatInsightModal(opts) {html += `<div class="uai-group-label"><i class="fa-solid fa-tower-broadcast"></i> As a streamer</div>`;html += `<p class="uai-sub uai-group-sub">Who they are as a streamer — from the AI analysis of their streams.</p>`;html += `-- ${_uaiCollapsible('Streamer overview', '<span class="uai-tag uai-tag-streamer">channel</span>', esc(st.overview || st.overview_short || ''), { icon: 'fa-wand-magic-sparkles' })}++ ${_uaiCollapsible('Streamer overview', '<span class="uai-tag uai-tag-streamer">channel</span>', esc(st.overview || st.overview_short || ''), { icon: 'fa-sparkles' })}${mems.length ? `<div class="uai-section"><h4><i class="fa-solid fa-timeline"></i> Recent stream moments</h4><div class="gai-timeline">${mems.map(m => `@@@ -1578,7 +1578,7 @@ function _uaiCollapsible(title, tagHtml, bodyHtml, opts = {}) {const preview = plain.length > 90 ? plain.slice(0, 90).trimEnd() + '…' : plain;return `<div class="uai-section uai-collapsible"><h4 class="uai-collapse-toggle" onclick="_uaiToggleSection(this)" role="button" tabindex="0">-- <i class="fa-solid ${opts.icon || 'fa-wand-magic-sparkles'}"></i> ${title} ${tagHtml || ''}++ <i class="fa-solid ${opts.icon || 'fa-sparkles'}"></i> ${title} ${tagHtml || ''}<i class="fa-solid fa-chevron-down uai-collapse-caret"></i></h4><div class="uai-collapse-preview">${esc(preview)}</div>@@@ -349,7 +349,7 @@ function _wsRenderPanel() {placeholder="What are you streaming?" maxlength="200"oninput="_wsMarkDirty()"><button type="button" class="gl-suggest" id="gl-suggest" onclick="_glSuggestTitles(this)" title="Three title ideas from your last streams on this slot">-- <i class="fa-solid fa-wand-magic-sparkles" aria-hidden="true"></i><span>Suggest</span>++ <i class="fa-solid fa-sparkles" aria-hidden="true"></i><span>Suggest</span></button><span id="bc-ws-save-status" class="bc-ws-save-status" role="status" aria-live="polite"></span></div>@@@ -3385,7 +3385,7 @@ function _wsShowRestreamForm(existing) {<p style="margin:0;font-size:0.82rem;color:var(--text-secondary)"><i class="fa-solid fa-robot" style="color:#4a9eff"></i> <strong>RobotStreamer</strong> uses a dedicated WebSocket connection (not RTMP). The token and robot below apply to <strong>this stream slot only</strong> — each slot can restream to a different robot.</p></div><div class="form-group">-- <label><i class="fa-solid fa-wand-magic-sparkles" style="color:#4a9eff"></i> Easiest: log in with RobotStreamer</label>++ <label><i class="fa-solid fa-sparkles" style="color:#4a9eff"></i> Easiest: log in with RobotStreamer</label><div style="display:flex;gap:8px"><input type="text" id="ws-rs-rs-username" class="form-input form-input-sm" style="flex:1" placeholder="RobotStreamer username" autocomplete="off"><input type="password" id="ws-rs-rs-password" class="form-input form-input-sm" style="flex:1" placeholder="Password" autocomplete="off">@@@ -3477,7 +3477,7 @@ function _wsShowRestreamForm(existing) {</div></div><div class="bc-ws-toggle-group bc-ws-pc-only">-- <div class="bc-ws-toggle-group-title"><i class="fa-solid fa-wand-magic-sparkles"></i> PowerChat overlay</div>++ <div class="bc-ws-toggle-group-title"><i class="fa-solid fa-sparkles"></i> PowerChat overlay</div><div class="bc-ws-toggle-grid"><label class="bc-toggle-label bc-ws-toggle-chip" title="Forward this platform's relayed chat to your PowerChat overlay (needs Chat relay)"><input type="checkbox" id="ws-rs-powerchat-relay" ${(!existing || existing.powerchat_relay !== false) ? 'checked' : ''}>@@@ -4878,7 +4878,7 @@ function _renderCameraSwitcherChoices(devices, activeCameraId) {options.forEach((device, index) => {const current = (activeCameraId || 'default') === device.deviceId;const meta = device._default-- ? { icon: 'fa-solid fa-wand-magic-sparkles', title: 'Default Camera', detail: 'Let the browser choose the preferred camera for this device.' }++ ? { icon: 'fa-solid fa-sparkles', title: 'Default Camera', detail: 'Let the browser choose the preferred camera for this device.' }: _describeCameraRole(device, index - 1);const titleText = device._default ? 'System Default Camera' : (device.label || meta.title);const subtitleText = device._default ? meta.detail : `${meta.title} • ${meta.detail}`;@@@ -394,7 +394,7 @@</div><div class="ctx-actions">${replyBtn}-- <button class="ctx-btn" onclick="dismissContextMenu();window.openRelayUserChatInsight&&openRelayUserChatInsight('${esc(platform)}','${esc(externalUsername)}','${esc(displayPlatform)}')"><i class="fa-solid fa-wand-magic-sparkles"></i> AI Insight</button>++ <button class="ctx-btn" onclick="dismissContextMenu();window.openRelayUserChatInsight&&openRelayUserChatInsight('${esc(platform)}','${esc(externalUsername)}','${esc(displayPlatform)}')"><i class="fa-solid fa-sparkles"></i> AI Insight</button>${cu && cu.id && ctx.currentStreamData && ctx.currentStreamData.user_id === cu.id ? `<button class="ctx-btn" onclick="dismissContextMenu();window.aivCloneChatter&&aivCloneChatter('relay','${esc(platform)}:${esc(externalUsername)}','${esc(externalUsername)}')"><i class="fa-solid fa-clone"></i> AI Clone</button>` : ''}${cap.view_all_logs ? `<button class="ctx-btn" onclick="dismissContextMenu();ctxViewRelayLogs('${esc(platform)}','${esc(externalUsername)}')"><i class="fa-solid fa-clock-rotate-left"></i> Chat Logs</button>` : ''}${modBtns ? '<div class="ctx-divider"></div>' + modBtns : ''}@@@ -427,7 +427,7 @@<div class="ctx-actions">${replyBtn}<button class="ctx-btn" data-username="${esc(username)}" onclick="ctxWhisper(this.dataset.username)"><i class="fa-solid fa-comment"></i> Message</button>-- <button class="ctx-btn" data-username="${esc(username)}" onclick="dismissContextMenu();window.openAnonChatInsight&&openAnonChatInsight(this.dataset.username)"><i class="fa-solid fa-wand-magic-sparkles"></i> AI Insight</button>++ <button class="ctx-btn" data-username="${esc(username)}" onclick="dismissContextMenu();window.openAnonChatInsight&&openAnonChatInsight(this.dataset.username)"><i class="fa-solid fa-sparkles"></i> AI Insight</button>${cap.view_all_logs ? `<button class="ctx-btn" data-username="${esc(username)}" onclick="dismissContextMenu();ctxViewAnonLogs(this.dataset.username)"><i class="fa-solid fa-clock-rotate-left"></i> Chat Logs</button>` : ''}${modBtns}${banBtns}@@@ -492,7 +492,7 @@<button class="ctx-btn" data-username="${esc(username)}" onclick="ctxWhisper(this.dataset.username)"><i class="fa-solid fa-comment"></i> Message</button>${cu && cu.id && cu.id !== p.id ? `<button class="ctx-btn" data-username="${esc(username)}" data-uid="${esc(String(p.id))}" onclick="ctxCallUser(this.dataset.username, this.dataset.uid)"><i class="fa-solid fa-phone"></i> Call this user</button>` : ''}<button class="ctx-btn" data-username="${esc(username)}" onclick="ctxViewChannel(this.dataset.username)"><i class="fa-solid fa-user"></i> Channel</button>-- ${p.id ? `<button class="ctx-btn" data-username="${esc(username)}" data-uid="${esc(String(p.id))}" onclick="dismissContextMenu();window.openUserChatInsight&&openUserChatInsight(this.dataset.uid, this.dataset.username)"><i class="fa-solid fa-wand-magic-sparkles"></i> AI Insight</button>` : ''}++ ${p.id ? `<button class="ctx-btn" data-username="${esc(username)}" data-uid="${esc(String(p.id))}" onclick="dismissContextMenu();window.openUserChatInsight&&openUserChatInsight(this.dataset.uid, this.dataset.username)"><i class="fa-solid fa-sparkles"></i> AI Insight</button>` : ''}${p.id && cu && cu.id && ctx.currentStreamData && ctx.currentStreamData.user_id === cu.id ? `<button class="ctx-btn" data-username="${esc(username)}" data-uid="${esc(String(p.id))}" onclick="dismissContextMenu();window.aivCloneChatter&&aivCloneChatter('user', this.dataset.uid, this.dataset.username)"><i class="fa-solid fa-clone"></i> AI Clone</button>` : ''}${cap.view_all_logs ? `<button class="ctx-btn" data-username="${esc(username)}" data-uid="${esc(String(p.id))}" onclick="ctxViewLogs(this.dataset.username, this.dataset.uid)"><i class="fa-solid fa-clock-rotate-left"></i> Chat Logs</button>` : ''}${cap.manage_users ? `<div class="ctx-rename-group">@@@ -2456,7 +2456,7 @@ function buildChatMessageEl(msg, opts = {}) {? `<div class="clip-chat-thumb"><img src="${esc(clip.thumbnail_url)}" alt="" loading="lazy">${dur ? `<span class="clip-chat-dur">${esc(dur)}</span>` : ''}<span class="clip-chat-play"><i class="fa-solid fa-play"></i></span></div>`: `<div class="clip-chat-thumb clip-chat-thumb-ph"><i class="fa-solid fa-scissors"></i></div>`;const label = isAuto-- ? `<div class="clip-chat-label"><i class="fa-solid fa-wand-magic-sparkles"></i> AI clip</div>`++ ? `<div class="clip-chat-label"><i class="fa-solid fa-sparkles"></i> AI clip</div>`: `<div class="clip-chat-label"><i class="fa-solid fa-scissors"></i> New clip</div>`;const creator = esc(clip.creator || msg.username || 'someone');const avatar = clip.creator_avatar@@@ -3496,6 +3496,13 @@ function setChatReply(replyData) {_chatReplyTo = replyData;// Show/update reply bar on all chat input areasdocument.querySelectorAll('.chat-input-area').forEach(area => {++ // The shared box says it inside itself, joined to it, with its own × (ask 371, 11-13: "more connected to the++ // actual composer"); clearing it there clears the reply here.++ const ovc = area.querySelector('form.ovc-v2')?.__ovc;++ if (ovc && typeof ovc.reply === 'function') {++ ovc.reply({ name: replyData.username, text: _spoilFree(replyData.message || ''), data: replyData, onClear: () => { if (_chatReplyTo === replyData) _chatReplyTo = null; } });++ return;++ }let bar = area.querySelector('.chat-reply-bar:not(.chat-edit-bar)');if (!bar) {bar = document.createElement('div');@@@ -3520,7 +3527,9 @@ function setChatReply(replyData) {* Clear the reply target — hides the reply bar.*/function clearChatReply() {++ const was = _chatReplyTo;_chatReplyTo = null;++ if (was) document.querySelectorAll('.chat-input-area form.ovc-v2').forEach(f => { if (f.__ovc && f.classList.contains('is-replying')) f.__ovc.clearBar(); });document.querySelectorAll('.chat-reply-bar:not(.chat-edit-bar)').forEach(bar => {bar.style.display = 'none';});@@@ -4,13 +4,36 @@Loaded after /shared/ov-composer.js; without it the old textarea stays. */(function () {'use strict';++ /* Live's tools in the shared box (ask 371, 2026-11-13: an emoji button and a "+" in the box, and again in the row under++ it). The box holds what goes into a message: Live's emote picker (the channel's emotes and every emoji) in its row,++ and in its + sheet a GIF and adding a channel emote or sound. The row under the box keeps the channel's own actions++ (sound, coins, tips). Each tool finds its box's textarea by id, as the pickers always have. */++ var SMILE = 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM8.5 14.2s1.3 1.8 3.5 1.8 3.5-1.8 3.5-1.8M9.2 9.6h.01M14.8 9.6h.01';++ var GIF = 'M4 7a3 3 0 0 1 3-3h10a3 3 0 0 1 3 3v10a3 3 0 0 1-3 3H7a3 3 0 0 1-3-3zM10.2 10H8.6a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h1.6v-1.7M12.6 10v4M15.2 14v-4h2.2M15.2 12h1.8';++ var ADD = 'M20 12a8 8 0 1 1-8-8M8.6 14.2s1.2 1.6 3.4 1.6 3.4-1.6 3.4-1.6M9.3 10h.01M14.7 10h.01M18.5 2.5v5M16 5h5';++ function inputOf(form) { var t = form && form.querySelector('textarea'); return t ? t.id : ''; }++ function registerTools() {++ var C = window.OpenVibeComposer;++ if (!C || typeof C.tool !== 'function' || registerTools.done) return;++ registerTools.done = true;++ C.tool('emote', { label: 'Emotes and emoji', path: SMILE, run: function (ctx, button, form) {++ if (typeof window.toggleEmotePicker === 'function') window.toggleEmotePicker(inputOf(form || (ctx && ctx.form)));++ } });++ C.tool('gif', { label: 'GIF', path: GIF, extra: true, run: function (ctx, button, form) {++ if (typeof window.toggleGifPicker === 'function') window.toggleGifPicker(inputOf(form || (ctx && ctx.form)));++ } });++ C.tool('emote-add', { label: 'Add an emote or sound', path: ADD, extra: true, run: function () {++ if (typeof window.openChannelUploadModal === 'function') window.openChannelUploadModal();++ } });++ }function upgrade(row) {if (!row || row.dataset.ovcMounted || !window.OpenVibeComposer || !window.OpenVibeComposer.mount) return;++ registerTools();var old = row.querySelector('textarea');if (!old) return;var id = old.id, max = Number(old.getAttribute('maxlength')) || 500, placeholder = old.placeholder || 'Send a message...';var c = window.OpenVibeComposer.mount(document.createElement('div'), {-- preset: 'chat', maxLength: max, placeholder: placeholder, label: placeholder, tools: ['emoji'],++ preset: 'chat', maxLength: max, placeholder: placeholder, label: placeholder, tools: ['emote'],onSubmit: function () {var before = c.text.value;if (id === 'fcw-chat-input' && typeof window.fcwSendChat === 'function') window.fcwSendChat();@@@ -34,7 +34,7 @@types: ['all', 'clips', 'shots', 'recaps'],titles: { all: 'AI Moments', clips: 'AI auto-clips', shots: 'AI moments', recaps: 'AI recaps' },empty: {-- all: ['fa-wand-magic-sparkles', 'No AI moments yet', 'The AI clips standout moments and writes recaps after streams.'],++ all: ['fa-sparkles', 'No AI moments yet', 'The AI clips standout moments and writes recaps after streams.'],clips: ['fa-scissors', 'No auto-clips yet', 'When chat erupts during a stream, the AI cuts a clip of it.'],shots: ['fa-image', 'No AI moments yet', 'The AI picks standout frames from recent streams.'],recaps: ['fa-clipboard-list', 'No AI recaps yet', 'After a long enough stream the AI writes a recap.'],@@@ -129,7 +129,7 @@});return img;}-- const aiBadge = () => el('span', { class: 'cf-ai', title: 'Made by AI' }, icon('fa-wand-magic-sparkles'), 'AI');++ const aiBadge = () => el('span', { class: 'cf-ai', title: 'Made by AI' }, icon('fa-sparkles'), 'AI');// ── cards ──────────────────────────────────────────────────────────────────────────────function meta(it, extra) {@@@ -220,7 +220,7 @@const stats = el('span', { class: 'cf-recap-stats' });if (it.duration_seconds) stats.append(el('span', null, el('b', { text: duration(it.duration_seconds) }), ' live'));if (it.views) stats.append(el('span', null, el('b', { text: compact(it.views) }), ' peak'));-- band.append(el('span', { class: 'cf-recap-head' }, el('span', { class: 'cf-recap-label' }, icon('fa-wand-magic-sparkles'), ' AI recap'), el('span', { class: 'cf-recap-sub', text: 'After-show report' }), stats));++ band.append(el('span', { class: 'cf-recap-head' }, el('span', { class: 'cf-recap-label' }, icon('fa-sparkles'), ' AI recap'), el('span', { class: 'cf-recap-sub', text: 'After-show report' }), stats));const body = el('div', { class: 'cf-body' }, spa(it.href, { class: 'cf-title', text: it.title }));if (it.stream_title && it.stream_title !== it.title) body.append(el('div', { class: 'cf-streamtitle', text: it.stream_title }));body.append(meta(it));@@@ -1150,7 +1150,7 @@ function renderDashTokenPresetButtons() {<div style="display:flex;flex-wrap:wrap;gap:8px">${dashTokenPresets.map((preset) => `<button type="button" class="btn btn-small btn-outline" onclick="applyDashTokenPreset('${preset.id}')">-- <i class="fa-solid fa-wand-magic-sparkles"></i> ${esc(preset.label)}++ <i class="fa-solid fa-sparkles"></i> ${esc(preset.label)}</button>`).join('')}</div><p class="muted" id="token-preset-hint" style="font-size:0.78rem;margin-top:8px">Use a preset to preselect the scopes needed for a common integration.</p>`;@@@ -620,7 +620,8 @@ function closeEmotePicker() {document.addEventListener('pointerdown', (e) => {if (!_emotePickerOpen) return;const t = e.target && e.target.closest ? e.target : null;-- if (t && (t.closest('.emote-picker') || t.closest('.emote-picker-btn'))) return;++ // Its own button toggles it (the shared box's emote tool, or an old row's): a press there is left to that button.++ if (t && (t.closest('.emote-picker') || t.closest('.emote-picker-btn') || t.closest('.ovc-t-emote'))) return;closeEmotePicker();}, true);document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && _emotePickerOpen) closeEmotePicker(); });@@@ -85,7 +85,7 @@ function _getGifPicker() {document.addEventListener('pointerdown', (event) => {if (!_gifPickerOpen || !_gifPickerEl) return;const t = event.target;-- if (_gifPickerEl.contains(t) || (t && t.closest && t.closest('.gif-btn'))) return;++ if (_gifPickerEl.contains(t) || (t && t.closest && (t.closest('.gif-btn') || t.closest('.ovc-t-gif') || t.closest('.ovc-tray-i')))) return;closeGifPicker();}, true);document.addEventListener('keydown', (event) => {@@@ -139,9 +139,9 @@el.querySelectorAll('[data-pick]').forEach(x => x.classList.remove('on')); b.classList.add('on');if (b.dataset.pick === 'new') { D.slot = null; form.classList.remove('is-hidden'); el.querySelector('#ovg-title-in').focus(); }else { D.slot = D.slots.find(s => String(s.id) === b.dataset.pick) || null; form.classList.add('is-hidden'); }-- const n = document.querySelector('#ovg-foot [data-act="next"]'); if (n) n.innerHTML = D.slot ? 'Next <i class="fa-solid fa-arrow-right"></i>' : '<i class="fa-solid fa-wand-magic-sparkles"></i> Create my stream';++ const n = document.querySelector('#ovg-foot [data-act="next"]'); if (n) n.innerHTML = D.slot ? 'Next <i class="fa-solid fa-arrow-right"></i>' : '<i class="fa-solid fa-sparkles"></i> Create my stream';}));-- const n = document.querySelector('#ovg-foot [data-act="next"]'); if (n && !D.slot) n.innerHTML = D.slots.length ? 'Pick a stream above' : '<i class="fa-solid fa-wand-magic-sparkles"></i> Create my stream';++ const n = document.querySelector('#ovg-foot [data-act="next"]'); if (n && !D.slot) n.innerHTML = D.slots.length ? 'Pick a stream above' : '<i class="fa-solid fa-sparkles"></i> Create my stream';},validate: async () => {if (D.slot) { D.method = D.slot.streaming_method || 'browser'; D.mode = D.slot.browser_mode || 'camera'; await loadEndpoint(); await loadDests(); return true; }@@@ -519,7 +519,7 @@<div class="ovg-join-cta"><a class="btn btn-primary btn-lg ovg-join-go" href="/api/auth/sso/login"><i class="fa-solid fa-user-plus"></i> Create your free account</a><a class="btn btn-outline ovg-join-in" href="/api/auth/sso/login"><i class="fa-solid fa-right-to-bracket"></i> I already have one</a>-- <button type="button" class="ovg-join-tour"><i class="fa-solid fa-wand-magic-sparkles"></i> Just show me around first</button>++ <button type="button" class="ovg-join-tour"><i class="fa-solid fa-sparkles"></i> Just show me around first</button></div></div>`;el.querySelector('.ovg-join-tour').addEventListener('click', () => openSiteTour());@@@ -171,7 +171,7 @@${up ? `<span class="hf-up"><i class="fa-solid fa-clock"></i> ${escText(up)}</span>` : ''}</a></div>-- ${now ? `<div class="hf-now"><span class="hf-now-head"><i class="fa-solid fa-wand-magic-sparkles"></i> Right now <span>${m.captured_at ? escText(typeof timeAgo === 'function' ? timeAgo(m.captured_at) : '') : ''}</span></span>${escText(now)}</div>` : ''}++ ${now ? `<div class="hf-now"><span class="hf-now-head"><i class="fa-solid fa-sparkles"></i> Right now <span>${m.captured_at ? escText(typeof timeAgo === 'function' ? timeAgo(m.captured_at) : '') : ''}</span></span>${escText(now)}</div>` : ''}${overview ? `<div class="hf-overview">${escText(overview)}</div>` : ''}<div class="hf-actions"><a class="hf-watch" href="${escText(chan)}" onclick="return handleLinkClick(event, '${escText(chan)}')"><i class="fa-solid fa-play"></i> Watch & chat</a>@@@ -30,7 +30,7 @@{ id: 'home', page: 'home', label: 'Home', href: '/', icon: 'fa-house' },// VODs, clips and pastes people made are one feed; what the AI made is the other.{ id: 'content', page: 'content', label: 'Content', href: '/content', icon: 'fa-photo-film' },-- { id: 'moments', page: 'moments', label: 'Moments', href: '/moments', icon: 'fa-wand-magic-sparkles' },++ { id: 'moments', page: 'moments', label: 'Moments', href: '/moments', icon: 'fa-sparkles' },{ id: 'chat', page: 'chat', label: 'Chat', href: '/chat', icon: 'fa-comments' },{ id: 'rundown', page: 'rundown', label: 'Rundown', href: '/rundown', icon: 'fa-newspaper' },{ id: 'broadcast', page: 'broadcast', elId: 'nav-broadcast', label: 'Go Live', href: '/broadcast', icon: 'fa-tower-broadcast', dot: false, dotId: 'nav-live-dot',@@@ -83,7 +83,7 @@ function renderPasteCard(p) {<span class="paste-card-time">${timeAgo}</span></div></div>-- ${(typeof _cardAiHTML === 'function') ? _cardAiHTML(p.ai_summary) : ((p.ai_summary && p.ai_summary.trim()) ? `<div class="card-ai-overview"><i class="fa-solid fa-wand-magic-sparkles"></i> ${escapeHtml(p.ai_summary)}</div>` : '')}++ ${(typeof _cardAiHTML === 'function') ? _cardAiHTML(p.ai_summary) : ((p.ai_summary && p.ai_summary.trim()) ? `<div class="card-ai-overview"><i class="fa-solid fa-sparkles"></i> ${escapeHtml(p.ai_summary)}</div>` : '')}</div></a>`;// Wrap for admin bulk-select (ctrl/shift multi-select + bulk delete) when enabled.@@@ -344,7 +344,7 @@ async function loadPasteViewer(slug) {try { _aiTags = p.ai_tags ? JSON.parse(p.ai_tags) : []; } catch { _aiTags = []; }const aiHtml = p.ai_summary ? `<div class="paste-ai-analysis">-- <div class="paste-ai-title"><i class="fa-solid fa-wand-magic-sparkles"></i> AI Analysis</div>++ <div class="paste-ai-title"><i class="fa-solid fa-sparkles"></i> AI Analysis</div><div class="paste-ai-summary">${escapeHtml(p.ai_summary)}</div>${_aiTags.length ? `<div class="paste-ai-tags">${_aiTags.map(t => `<span class="paste-ai-tag">${escapeHtml(String(t))}</span>`).join('')}</div>` : ''}</div>` : '';@@@ -69,7 +69,7 @@<header class="recap-head"><div class="recap-grade" title="${esc(g.label)}"><span>${esc(w.grade || 'B')}</span><small>${esc(g.label)}</small></div><div class="recap-head-body">-- <div class="recap-kicker"><i class="fa-solid fa-clipboard-list"></i> After-show report ${r.ai ? '<span class="recap-ai"><i class="fa-solid fa-wand-magic-sparkles"></i> written by OpenVibe AI</span>' : ''}</div>++ <div class="recap-kicker"><i class="fa-solid fa-clipboard-list"></i> After-show report ${r.ai ? '<span class="recap-ai"><i class="fa-solid fa-sparkles"></i> written by OpenVibe AI</span>' : ''}</div><h1 class="recap-headline">${esc(w.headline || s.title)}</h1><div class="recap-by">${av(u, 34)}<a class="recap-by-name" ${go(path)}>${esc(u.display_name)}</a><span class="muted">streamed</span><b>${esc(s.title)}</b><span class="recap-by-meta">${esc(when(s.started_at))} · ${dur(s.duration_seconds)}${s.category ? ` · ${esc(s.category)}` : ''}</span></div>${(w.tags || []).length ? `<div class="recap-tags">${w.tags.map(t => `<span>#${esc(t)}</span>`).join('')}</div>` : ''}@@@ -17,11 +17,15 @@- a PIP lives in the player (docked) or over the page (floating, position: fixed, it stays on screen while the pagescrolls); it follows the finger or the mouse with a transform each frame and lands with a spring (FLIP), droppedover the player it docks, anywhere else it floats; a flick carries on the way it was thrown;-- - thrown past the side of the screen (or of the player) it tucks into that side, a tab with its picture still going-- at the edge, and a tap brings it out ("get out of the way" without hiding it);-- - it is never smaller than a 150 px wide 16:9 box on a phone; two fingers pinch it, the corner sizes it, a double-- tap (or click) toggles small and large; with focus the arrows move it, + and − size it, M mutes, T tucks, F pops it-- out of the player or back in, Home puts it back where the streamer put it.++ - thrown past the side of the screen (or of the player) it tucks into that side: it folds into a small handle at++ the edge (openvibe-shared 4.235.0; owner 11-13: "a small button that lets you pop it in and out"), the handle++ slides up and down its side, a tap opens it where it was and a pull brings it out under the finger;++ - it is never smaller than a 150 px wide 16:9 box on a phone and grows to the whole width of the window (past the++ player it floats over the page); two fingers pinch it, the corner sizes it, a double tap (or click) toggles small++ and large (on a phone, the screen's width); with focus the arrows move it, + and − size it, M mutes, T tucks, F++ pops it out of the player or back in, Home puts it back where the streamer put it;++ - popped out of the browser (its own window on top of everything), the box in the page steps aside until that++ window closes, so the picture is not shown twice.Waiting (owner: "this black thing that's just stuck there … use the OV animations"): until its first frame, andwhenever its frames stop for 4 s, it shows the mark at work over its last picture, and tries again by itself after15 s and on a backoff when its connection drops (2, 4, 8, 15, 30 s); a source that ended leaves with an animation.@@@ -38,7 +42,7 @@const LS_KEY = (slot, sourceMsid) => `ov-pip-${slot}-${sourceMsid}`;const LS_V1 = (streamId, sourceMsid) => `ov-pip-geom-${streamId}-${sourceMsid}`; // per session, before 2026-11-11-- const MIN_W = 0.08, MAX_W = 0.60;++ const MIN_W = 0.08, MAX_W = 1; // a viewer may make it as wide as the window (the streamer's default stays ≤ 0.6)const POLL_MS = 30000;const BASE = 16 / 9; // the shape `w` is measured forconst MAX_H = 0.85; // of the frame's height, whatever the shape@@@ -202,7 +206,7 @@<button type="button" data-act="tuck" title="Tuck ${escapeHtml(label)} to the side" aria-label="Tuck ${escapeHtml(label)} to the side"><i class="fa-solid fa-angles-right"></i></button></span></div>-- <button type="button" class="ov-pip-tab" data-act="untuck" data-ov-float-tab title="Bring ${escapeHtml(label)} back" aria-label="Bring ${escapeHtml(label)} back"><span class="ov-pip-tab-l"><i class="fa-solid fa-chevron-left"></i></span><span class="ov-pip-tab-r"><i class="fa-solid fa-chevron-right"></i></span></button>++ <button type="button" class="ov-pip-tab" data-ov-float-tab title="Bring ${escapeHtml(label)} back" aria-label="Bring ${escapeHtml(label)} back"><span class="ov-pip-tab-l"><i class="fa-solid fa-chevron-left"></i></span><span class="ov-pip-tab-r"><i class="fa-solid fa-chevron-right"></i></span></button><div class="ov-pip-resize" data-ov-float-resize title="Drag to resize" aria-hidden="true"></div>`;o.el = el;o.container = container;@@@ -233,8 +237,6 @@minPx: MIN_PX,bigW: BIG_W,tabPx: TAB_PX,-- tab: false,-- resize: false,hintClass: 'ov-pip-dock-hint',ignore: '[data-act], .ov-pip-resize, [data-ov-float-resize], a',onChange(geom) { o.geom = geom; saveGeom(o); },@@@ -251,41 +253,29 @@container.appendChild(el);}wireMuteKey(o);-- wireResize(o);++ wirePopout(o);setTimeout(() => el.classList.remove('is-entering'), 420);}-- /** The corner grip: the shared engine owns pinch and the keys; this is the same sizing on the PIP's own handle. */-- function wireResize(o) {-- const el = o.el;-- const handle = el.querySelector('.ov-pip-resize');-- let start = null;-- handle.addEventListener('pointerdown', (e) => {-- if (e.button > 0) return;-- const b = o.float.metrics();-- start = { px: e.clientX, py: e.clientY, w: o.float.geom().w, rw: b.rw };-- el.classList.add('is-resizing');-- try { handle.setPointerCapture(e.pointerId); } catch { /* */ }-- e.preventDefault();-- e.stopPropagation();-- });-- handle.addEventListener('pointermove', (e) => {-- if (!start || !start.rw) return;-- // The handle's diagonal pull is the box's own width; `w` is the 16:9 width of the same area.-- const k = Math.sqrt((o.aspect || BASE) / BASE) || 1;-- const pull = ((e.clientX - start.px) + (e.clientY - start.py) * (o.aspect || BASE)) / 2;-- o.float.set({ w: clamp(start.w + pull / start.rw / k, MIN_W, MAX_W, start.w) });-- });-- const end = (e) => {-- if (!start) return;-- start = null;-- el.classList.remove('is-resizing');-- try { handle.releasePointerCapture(e.pointerId); } catch { /* */ }-- o.float.settle();-- o.float.save();++ /**++ * Out of the browser: while the browser's own picture-in-picture window shows this PIP, the box in the page steps++ * aside (owner, 11-13: "it just goes twice like this and ugly"), and comes back when that window closes.++ */++ function wirePopout(o) {++ const v = o.el.querySelector('.ov-pip-video');++ const btn = o.el.querySelector('[data-act="popout"]');++ const set = (out) => {++ o.el.classList.toggle('is-popped', out);++ if (btn) btn.setAttribute('aria-pressed', String(out));++ if (!out && !reduced()) {++ o.el.classList.remove('is-entering');++ void o.el.offsetWidth;++ o.el.classList.add('is-entering');++ setTimeout(() => o.el && o.el.classList.remove('is-entering'), 420);++ }};-- handle.addEventListener('pointerup', end);-- handle.addEventListener('pointercancel', end);++ v.addEventListener('enterpictureinpicture', () => set(true));++ v.addEventListener('leavepictureinpicture', () => set(false));}/** With focus, M mutes; the engine owns the arrows, + and −, T, F and Home. */@@@ -321,7 +311,6 @@}if (!o.float) return;if (what === 'tuck') o.float.tuck();-- else if (what === 'untuck') o.float.untuck();else if (what === 'reset') o.float.reset();else if (what === 'float') { o.float.toggleMode(); }else if (what === 'mute') {@@@ -409,7 +409,7 @@ function _ensurePlayerLoaderStructure(placeholder) {<div class="player-loader-expanded"><p class="player-loader-detail"></p><p class="player-loader-hint">-- <i class="fa-solid fa-wand-magic-sparkles"></i>++ <i class="fa-solid fa-sparkles"></i><span></span></p><div class="player-loader-actions">@@@ -551,7 +551,7 @@ button{font:inherit}<div class="vital"><div class="vlabel"><i class="fa-solid fa-scissors"></i> Clips</div><div class="vnum" id="v-clips">–</div><div class="vdelta zero" id="v-clips-d"> </div></div><div class="vital"><div class="vlabel"><i class="fa-solid fa-paste"></i> Pastes</div><div class="vnum" id="v-pastes">–</div><div class="vdelta zero" id="v-pastes-d"> </div></div><div class="vital"><div class="vlabel"><i class="fa-solid fa-hourglass-half"></i> Stream hrs</div><div class="vnum" id="v-hours">–</div><div class="vdelta zero" id="v-hours-d"> </div></div>-- <div class="vital"><div class="vlabel"><i class="fa-solid fa-wand-magic-sparkles"></i> AI moments</div><div class="vnum" id="v-ai">–</div><div class="vdelta zero" id="v-ai-d"> </div></div>++ <div class="vital"><div class="vlabel"><i class="fa-solid fa-sparkles"></i> AI moments</div><div class="vnum" id="v-ai">–</div><div class="vdelta zero" id="v-ai-d"> </div></div><div class="vital"><div class="vlabel"><i class="fa-solid fa-message"></i> Messages</div><div class="vnum" id="v-msgs">–</div><div class="vdelta zero" id="v-msgs-d"> </div></div><div class="vital"><div class="vlabel"><i class="fa-solid fa-users"></i> OpenVibes</div><div class="vnum" id="v-users">–</div><div class="vdelta zero" id="v-users-d"> </div></div></section>@@@ -74,7 +74,7 @@ html[data-ov-motion=off] .al::after{display: none}sub: { icon: 'fa-star', say: 'subscribed' },raid: { icon: 'fa-users', say: 'raided' },goal: { icon: 'fa-trophy', say: 'Goal reached' },-- element: { icon: 'fa-wand-magic-sparkles', say: 'pressed' },++ element: { icon: 'fa-sparkles', say: 'pressed' },};var queue = [], showing = false, seen = new Set();function el(tag, cls, kids) { var n = document.createElement(tag); if (cls) n.className = cls; (kids || []).forEach(function (k) { if (k != null) n.append(k); }); return n; }@@@ -191,15 +191,6 @@</button></div><div class="chat-tools-row">-- <button class="emote-picker-btn" onclick="toggleEmotePicker('pc-input')" title="Emotes">-- <i class="fa-solid fa-face-smile"></i>-- </button>-- <button class="emote-picker-btn gif-btn" onclick="toggleGifPicker('pc-input')" title="GIFs">-- <span class="gif-btn-text">GIF</span>-- </button>-- <button class="emote-picker-btn" onclick="if(typeof openChannelUploadModal==='function')openChannelUploadModal()" title="Add channel emote / sound">-- <i class="fa-solid fa-plus"></i>-- </button><button class="chat-tts-toggle tts-active" id="pc-tts-btn" onclick="toggleChatTTS(this)" title="TTS On (click to mute)"><i class="fa-solid fa-volume-high"></i></button>@@@ -246,7 +246,7 @@<!-- AI & transcription --><div class="section">-- <h2><i class="fa-solid fa-wand-magic-sparkles"></i> AI & Transcription</h2>++ <h2><i class="fa-solid fa-sparkles"></i> AI & Transcription</h2><p>OpenVibe.Live has optional AI features that generate overviews, memories, and summaries of streams, VODs, clips, and pastes to make them easier to browse.</p><p><strong>Audio transcription runs locally on our own server</strong> (whisper.cpp) — your stream audio is <strong>not</strong> sent to a third-party transcription API. Frame analysis for stream memories may use an AI provider, gated by a strict daily budget and only on content you've made available on the platform.</p><p>Generated transcripts, timestamps, and overviews are stored alongside the content they describe and follow the same visibility you chose for that content. If you delete a VOD, clip, or paste, its AI-generated data goes with it.</p>nnew file mode 100644@@@ -0,0 +1,35 @@++'use strict';++// The owner's phone, 2026-11-13 (ask 371): the chat box holds what goes into a message (Live's emote picker in its++// row; a GIF and adding an emote or sound in its + sheet) and the row under it only the channel's own actions, so++// nothing is there twice; a reply is said inside the box; the global chat's AI line is a ticker that steps aside when++// it is old; a section's glyph is drawn inside its tile, not to its edges; AI wears the sparkles.++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const read = (p) => fs.readFileSync(path.join(__dirname, '..', p), 'utf8');++++const composer = read('public/js/composer.js');++assert.ok(/C\.tool\('emote', \{ label: 'Emotes and emoji'/.test(composer) && /tools: \['emote'\]/.test(composer), 'the box\'s row has Live\'s emote picker');++assert.ok(/C\.tool\('gif', \{ label: 'GIF', path: GIF, extra: true/.test(composer) && /C\.tool\('emote-add', \{ label: 'Add an emote or sound', path: ADD, extra: true/.test(composer),++ 'its + sheet has a GIF and adding an emote or sound');++for (const f of ['public/fragments/channel.html', 'public/fragments/chat.html', 'public/fragments/broadcast.html', 'public/popout-chat.html']) {++ const html = read(f);++ assert.ok(!/onclick="toggleEmotePicker\(|onclick="toggleGifPicker\(|title="Add channel emote/.test(html), `${f}: no emote, GIF or add button under the box (they are in it)`);++}++assert.ok(/t\.closest\('\.ovc-t-emote'\)/.test(read('public/js/emotes.js')), 'a press on the box\'s emote button is left to it');++assert.ok(/t\.closest\('\.ovc-t-gif'\) \|\| t\.closest\('\.ovc-tray-i'\)/.test(read('public/js/gif-picker.js')), 'and on the sheet\'s GIF');++++const chat = read('public/js/chat.js');++assert.ok(/const ovc = area\.querySelector\('form\.ovc-v2'\)\?\.__ovc;\n\s*if \(ovc && typeof ovc\.reply === 'function'\) \{\n\s*ovc\.reply\(\{ name: replyData\.username/.test(chat), 'a reply is said inside the shared box');++assert.ok(/f\.__ovc\.clearBar\(\)/.test(chat), 'and cleared there');++++const page = read('public/js/app-chatpage.js');++assert.ok(/_gaiAgeMs\(insight\.updated_at\) > 12 \* 3600e3/.test(page), 'a read older than 12 hours steps aside');++assert.ok(/function _gaiTicker\(el, text\)/.test(page) && /el\.matches\(':hover'\) \|\| el\.closest\('\.global-ai-strip\.open'\)/.test(page), 'a ticker that waits under a pointer and while open');++const css = read('public/css/features/chatpage.css');++assert.ok(css.includes('@keyframes gaiRoll') && css.includes('@keyframes gaiPan') && css.includes('html[data-ov-motion="off"] .global-ai-strip .gai-t { animation: none !important; }'), 'it rolls and pans, and not with motion off');++++assert.ok(read('public/css/features/channel.css').includes('#page-channel .ch-sec-ic > svg { width: 62%; height: 62%; }'), 'a section\'s glyph inside its tile');++const wand = ['public/js/app-channel.js', 'public/js/chat.js', 'public/fragments/chat.html', 'public/fragments/channel.html'].filter((f) => read(f).includes('fa-wand-magic-sparkles'));++assert.deepStrictEqual(wand, [], 'AI wears the sparkles, not a wand');++console.log('chat-box-and-ticker: one emote button, the extras in the +, a reply in the box, a ticker that steps aside, glyphs in their tiles');@@@ -21,9 +21,9 @@ assert.ok(/> \.chat-sidebar > :is\(\.chat-settings-panel, \.chat-users-panel\) \assert.ok(/> \.chat-sidebar \.chat-header \{ padding-top: 4px; padding-bottom: 4px; min-height: 44px; \}/.test(css), 'a compact header');assert.ok(/> \.chat-sidebar > \.chat-grip \{ position: absolute; top: 0; height: 14px;/.test(css), 'the grip over its top edge, no row of its own');const js = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'app-channel.js'), 'utf8');--assert.ok(/function _wireChannelMoreLayer\(menu\)/.test(js) && /pop\.setAttribute\('popover', 'manual'\)/.test(js) && /_wireChannelMoreLayer\(menu\);\n menu\.open = false;/.test(js),-- 'the ⋯ menu opens in the top layer, placed on the screen (a phone\'s live layout clipped it)');--assert.ok(css.includes('#page-channel .ch-more-menu.is-layer { position: fixed;'), 'and is fixed there');++assert.ok(/function _wireChannelMoreLayer\(menu\)/.test(js) && /window\.OVSurfaces\.popover\(\{ el: menu, trigger: summary, pop, place: 'bottom-end'/.test(js) && /_wireChannelMoreLayer\(menu\);\n menu\.open = false;/.test(js),++ 'the ⋯ menu is the network\'s layer: anchored to its button in the top layer (a phone\'s live layout clipped it)');++assert.ok(!/_wireChannelMoreLayer[\s\S]{0,1500}showPopover/.test(js), 'Live keeps no placement of its own');assert.ok(css.includes('.ch-bot-tabs button > :is(i, svg) { flex: none; display: inline-grid; place-items: center; width: 20px; height: 20px;'),'every tab icon fills the same box (the robot was drawn smaller than the chat bubble)');const html = fs.readFileSync(path.join(__dirname, '..', 'public', 'fragments', 'channel.html'), 'utf8');@@@ -83,7 +83,7 @@ assert.ok(/\.gl-chips \{ display: grid; grid-template-columns: repeat\(2, minmaxassert.ok(panel.includes('<span class="gl-chip-k">Address</span><span class="gl-chip-v" id="gl-link">') && panel.includes('class="gl-chip gl-chip--link" onclick="_glCopyLink()"'), 'the address tile copies');assert.ok((panel.match(/class="gl-chip-i" aria-hidden="true"/g) || []).length === 4, 'each tile has its icon in a tile of its own');// Suggest sits against the title with the network's glyph, not an emoji.--assert.ok(panel.includes('<i class="fa-solid fa-wand-magic-sparkles" aria-hidden="true"></i><span>Suggest</span>') && !/gl-suggest[\s\S]{0,200}\\u2728/.test(panel), 'Suggest wears the network glyph');++assert.ok(panel.includes('<i class="fa-solid fa-sparkles" aria-hidden="true"></i><span>Suggest</span>') && !/gl-suggest[\s\S]{0,200}\\u2728/.test(panel), 'Suggest wears the network glyph');assert.ok(/\.gl-title-row \.gl-title \{ flex: 0 1 auto; width: auto; max-width: 100%; field-sizing: content;/.test(css), 'the title is as wide as its words, so Suggest follows them');// The head: Streamer setup at the row's end, and the line under it never leaves one word alone.assert.ok(frag.includes('<span class="gl-head-t">Go Live</span> <button type="button" class="glw-open-btn" onclick="openSetupHub()"'), 'the head');@@@ -102,8 +102,10 @@ console.log('OK H: overlay is a full independent viewer session, attached and deassert.ok(/localStorage\.setItem\(LS_KEY\(o\.slot, o\.pip\.source_msid\)/.test(pip), 'the place persists per slot and per PIP');assert.ok(/account\.put\(`\$\{o\.slot\}:\$\{o\.pip\.source_msid\}`, o\.geom\)/.test(pip), 'and in the account when signed in');assert.ok(/localStorage\.getItem\(LS_V1\(streamId, pip\.source_msid\)\)/.test(pip), 'a place saved per session before still counts');--assert.ok(/data-act="untuck"/.test(pip) && read('public/css/features/pip.css').includes('.ov-pip.is-tucked .ov-pip-tab { display: grid; }'),-- 'a PIP tucked aside keeps its tab on screen to bring it back');++assert.ok(/class="ov-pip-tab" data-ov-float-tab/.test(pip) && read('public/css/features/pip.css').includes('.ov-pip.is-tucked .ov-pip-tab { display: grid;'),++ 'a PIP tucked aside keeps its handle on screen to bring it back');++assert.ok(/enterpictureinpicture/.test(pip) && /leavepictureinpicture/.test(pip) && read('public/css/features/pip.css').includes('.ov-pip.is-popped {'),++ 'popped out of the browser, the box in the page steps aside until that window closes');assert.ok(/muted: pip\.muted !== false/.test(pip),'the overlay starts as the streamer chose, muted unless they said otherwise');assert.ok(/is-offline/.test(pip), 'a camera going offline must degrade to a badge, not a broken overlay');
@@ -184,5 +184,5 @@ Minimum `.env`: `BASE_URL`, `JWT_SECRET`, `OV_NETWORK_URL`, `OV_NETWORK_INTERNAL
@@ -184,5 +184,5 @@ Minimum `.env`: `BASE_URL`, `JWT_SECRET`, `OV_NETWORK_URL`, `OV_NETWORK_INTERNAL
<!-- versions:start -->
<!-- versions:start -->
- openvibe-contracts: v0.419.0
- openvibe-contracts: v0.419.0
- openvibe-sdk: v0.161.0
- openvibe-sdk: v0.161.0
-- openvibe-shared: v4.233.0
+- openvibe-shared: v4.235.0
<!-- versions:end -->
<!-- versions:end -->
@@ -7,7 +7,7 @@
@@ -7,7 +7,7 @@
"domain": "openvibe.live (www, ingest, whip)",
"domain": "openvibe.live (www, ingest, whip)",
"contracts": "openvibe-contracts v0.419.0",
"contracts": "openvibe-contracts v0.419.0",
"sdk": "openvibe-sdk v0.161.0",
"sdk": "openvibe-sdk v0.161.0",
- "shared": "openvibe-shared v4.233.0",
+ "shared": "openvibe-shared v4.235.0",
"capabilities": [
"capabilities": [
"live.chat_context.read",
"live.chat_context.read",
"live.chat_effects.write",
"live.chat_effects.write",
@@ -26,7 +26,7 @@
@@ -26,7 +26,7 @@
"onnxruntime-node": "^1.20.0",
"onnxruntime-node": "^1.20.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.419.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.419.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.161.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.161.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.233.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.235.0",
"pg": "^8.23.1",
"pg": "^8.23.1",
"sanitize-html": "^2.17.7",
"sanitize-html": "^2.17.7",
"sdp-transform": "^3.0.0",
"sdp-transform": "^3.0.0",
@@ -3417,9 +3417,9 @@
@@ -3417,9 +3417,9 @@
}
}
},
},
"node_modules/openvibe-shared": {
"node_modules/openvibe-shared": {
- "version": "4.233.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.233.0",
- "integrity": "sha512-kaNnKiQfMIVB5SasCeJX03Z+N/T3n/3Qklh4Be9EdCFz3PjAjUrPTqXOAgy1WMj2iuFXEODy5CLhonN7eLIOHw==",
+ "version": "4.235.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.235.0",
+ "integrity": "sha512-szSdbilNQWBPuZAUinpmtInKtyalGDzhy+Lso3f8uJ/wiJgR0msDL5E5gvcnajExWdygK48QWdycj9FGIUYSng==",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"clean-css": "^5.3.3",
"clean-css": "^5.3.3",
@@ -46,7 +46,7 @@
@@ -46,7 +46,7 @@
"onnxruntime-node": "^1.20.0",
"onnxruntime-node": "^1.20.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.419.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.419.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.161.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.161.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.233.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.235.0",
"pg": "^8.23.1",
"pg": "^8.23.1",
"sanitize-html": "^2.17.7",
"sanitize-html": "^2.17.7",
"sdp-transform": "^3.0.0",
"sdp-transform": "^3.0.0",
@@ -33,7 +33,7 @@
@@ -33,7 +33,7 @@
".btn-primary",
".btn-primary",
".btn-small",
".btn-small",
".card-ai-clamp",
".card-ai-clamp",
- ".card-ai-clamp i.fa-wand-magic-sparkles",
+ ".card-ai-clamp i.fa-sparkles",
".card-ai-overview",
".card-ai-overview",
".card-ai-static",
".card-ai-static",
".card-ai-toggle",
".card-ai-toggle",
@@ -1926,14 +1926,8 @@ html[data-ov-motion=off] #page-channel .ch-tab-panel{scroll-margin-top: calc(var
@@ -1926,14 +1926,8 @@ html[data-ov-motion=off] #page-channel .ch-tab-panel{scroll-margin-top: calc(var
#page-channel .ch-more-item i { width: 17px; color: var(--text-secondary); }
#page-channel .ch-more-item i { width: 17px; color: var(--text-secondary); }
#page-channel .ch-more-item[hidden], #page-channel .ch-more-mod[hidden] { display: none; }
#page-channel .ch-more-item[hidden], #page-channel .ch-more-mod[hidden] { display: none; }
#page-channel .ch-more-mod { border-top: 1px solid var(--border); margin-top: 5px; padding-top: 5px; }
#page-channel .ch-more-mod { border-top: 1px solid var(--border); margin-top: 5px; padding-top: 5px; }
-/* Open, it is in the top layer and placed on the screen by app-channel.js (a phone's live layout clipped it). */
-#page-channel .ch-more-menu.is-layer { position: fixed; inset: auto; margin: 0; right: auto; max-height: calc(100dvh - 16px); overflow-y: auto; color: var(--text-primary);
- transform-origin: top right; animation: chMoreIn var(--ov-dur-2, .16s) var(--ov-ease-out, cubic-bezier(.2, .9, .25, 1)); }
-#page-channel .ch-more-menu.is-layer.is-above { transform-origin: bottom right; }
-@keyframes chMoreIn { from { opacity: 0; transform: translateY(-4px) scale(.96); } }
-#page-channel .ch-more-menu.is-layer.is-above { animation-name: chMoreUp; }
-@keyframes chMoreUp { from { opacity: 0; transform: translateY(4px) scale(.96); } }
-html[data-ov-motion="off"] #page-channel .ch-more-menu.is-layer { animation: none; }
+/* Open, the network's layer anchors it to its button in the top layer (OVSurfaces, app-channel.js). */
+#page-channel .ch-more-menu[data-ov-anchored] { color: var(--text-primary); }
#page-channel .ch-more-heading { display: block; padding: 5px 9px; color: var(--text-muted); font-size: 11px; font-weight: 750; text-transform: uppercase; letter-spacing: .05em; }
#page-channel .ch-more-heading { display: block; padding: 5px 9px; color: var(--text-muted); font-size: 11px; font-weight: 750; text-transform: uppercase; letter-spacing: .05em; }
.ch-action-dialog { width: min(420px, calc(100vw - 32px)); box-sizing: border-box; border: 1px solid var(--border); border-radius: 14px; padding: 22px; background: var(--bg-card); color: var(--text-primary); }
.ch-action-dialog { width: min(420px, calc(100vw - 32px)); box-sizing: border-box; border: 1px solid var(--border); border-radius: 14px; padding: 22px; background: var(--bg-card); color: var(--text-primary); }
.ch-action-dialog::backdrop { background: rgba(0,0,0,.65); }
.ch-action-dialog::backdrop { background: rgba(0,0,0,.65); }
@@ -2252,6 +2246,9 @@ html[data-ov-motion="off"] #page-channel .ch-rec-dot { animation: none; }
@@ -2252,6 +2246,9 @@ html[data-ov-motion="off"] #page-channel .ch-rec-dot { animation: none; }
#page-channel .ch-sec-ic { display: inline-grid; place-items: center; flex: none; width: 30px; height: 30px; border-radius: 9px; font-size: .9rem;
#page-channel .ch-sec-ic { display: inline-grid; place-items: center; flex: none; width: 30px; height: 30px; border-radius: 9px; font-size: .9rem;
color: var(--accent-light, var(--accent)); background: color-mix(in srgb, var(--accent) 14%, transparent); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 22%, transparent); }
color: var(--accent-light, var(--accent)); background: color-mix(in srgb, var(--accent) 14%, transparent); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 22%, transparent); }
#page-channel svg.ch-sec-ic { padding: 7px; box-sizing: border-box; }
#page-channel svg.ch-sec-ic { padding: 7px; box-sizing: border-box; }
+/* The glyph inside the tile, not the tile: an OV glyph draws its picture at 100% of its element, and here the element
+ is the tile, so every glyph ran to the tile's edges (ask 371, 11-13: "a tiny bit of padding"). 62% of it. */
+#page-channel .ch-sec-ic > svg { width: 62%; height: 62%; }
/* A part of a section (AI Moments in Clips, About's latest videos and popular clips): the same tile, a size down. */
/* A part of a section (AI Moments in Clips, About's latest videos and popular clips): the same tile, a size down. */
#page-channel .ch-sub-h { display: flex; align-items: center; gap: 9px; margin: 22px 0 6px; font-size: 1rem; font-weight: 800; line-height: 1.2; letter-spacing: -.005em; text-transform: none; color: var(--text-primary); }
#page-channel .ch-sub-h { display: flex; align-items: center; gap: 9px; margin: 22px 0 6px; font-size: 1rem; font-weight: 800; line-height: 1.2; letter-spacing: -.005em; text-transform: none; color: var(--text-primary); }
#page-channel .ch-sub-h > .ch-sec-ic { width: 26px; height: 26px; border-radius: 8px; font-size: .78rem; }
#page-channel .ch-sub-h > .ch-sec-ic { width: 26px; height: 26px; border-radius: 8px; font-size: .78rem; }
@@ -16,6 +16,16 @@
@@ -16,6 +16,16 @@
}
}
.global-ai-strip:hover { background: linear-gradient(90deg, color-mix(in srgb, var(--accent) 20%, transparent), transparent); }
.global-ai-strip:hover { background: linear-gradient(90deg, color-mix(in srgb, var(--accent) 20%, transparent), transparent); }
.gai-strip-caret { flex-shrink: 0; opacity: 0.6; transition: transform var(--ov-dur-2, 0.2s); }
.gai-strip-caret { flex-shrink: 0; opacity: 0.6; transition: transform var(--ov-dur-2, 0.2s); }
+/* The ticker (app-chatpage.js): a sentence rolls up into place; one longer than the strip pans to its end, then holds. */
+.global-ai-strip .gai-strip-text { position: relative; text-overflow: clip; }
+.global-ai-strip .gai-t { display: inline-block; white-space: nowrap; }
+.global-ai-strip .gai-strip-text.gai-roll .gai-t { animation: gaiRoll var(--ov-dur-3, .42s) var(--ov-ease-out, cubic-bezier(.2, .9, .25, 1)); }
+@keyframes gaiRoll { from { opacity: 0; transform: translateY(70%); } }
+.global-ai-strip .gai-strip-text.gai-pan .gai-t { animation: gaiPan var(--gai-pan-s, 4s) ease-in-out 1.4s both; }
+.global-ai-strip .gai-strip-text.gai-roll.gai-pan .gai-t { animation: gaiRoll var(--ov-dur-3, .42s) var(--ov-ease-out, ease-out), gaiPan var(--gai-pan-s, 4s) ease-in-out 1.6s both; }
+@keyframes gaiPan { to { transform: translateX(var(--gai-shift, 0)); } }
+.global-ai-strip:not(.open) .gai-strip-text { -webkit-mask-image: linear-gradient(90deg, #000 calc(100% - 18px), transparent); mask-image: linear-gradient(90deg, #000 calc(100% - 18px), transparent); }
+html[data-ov-motion="off"] .global-ai-strip .gai-t { animation: none !important; }
.global-ai-strip.open .gai-strip-caret { transform: rotate(180deg); }
.global-ai-strip.open .gai-strip-caret { transform: rotate(180deg); }
.global-ai-panel {
.global-ai-panel {
@@ -2,8 +2,9 @@
@@ -2,8 +2,9 @@
One stylesheet for every PIP (2026-11-11, owner: "move it anywhere … more solid feeling and more responsive … use the
One stylesheet for every PIP (2026-11-11, owner: "move it anywhere … more solid feeling and more responsive … use the
OV animations"). A PIP sits in the player (absolute, its place in fractions of the player) or over the page
OV animations"). A PIP sits in the player (absolute, its place in fractions of the player) or over the page
(.is-floating: fixed, fractions of the window, above the page and below the navbar's menus); while it is moved it is
(.is-floating: fixed, fractions of the window, above the page and below the navbar's menus); while it is moved it is
- lifted (.is-lifted: fixed, moved by a transform each frame), and it lands with a spring. Tucked into a side, only its
- tab stays on screen (the `translate` property; `transform` is the landing's). Its waits are the mark at work. */
+ lifted (.is-lifted: fixed, moved by a transform each frame), and it lands with a spring. Tucked into a side it folds
+ into a small handle at the edge (openvibe-shared 4.235.0's clip; the handle is .ov-pip-tab). Its waits are the mark
+ at work. */
.ov-pip {
.ov-pip {
container: ovpip / inline-size;
container: ovpip / inline-size;
@@ -21,8 +22,10 @@
@@ -21,8 +22,10 @@
-webkit-tap-highlight-color: transparent;
-webkit-tap-highlight-color: transparent;
contain: layout paint;
contain: layout paint;
transition: box-shadow var(--ov-dur-2, .15s) var(--ov-ease-out, ease), border-color var(--ov-dur-2, .15s) var(--ov-ease-out, ease),
transition: box-shadow var(--ov-dur-2, .15s) var(--ov-ease-out, ease), border-color var(--ov-dur-2, .15s) var(--ov-ease-out, ease),
- translate var(--ov-dur-3, .3s) var(--ov-ease-spring, cubic-bezier(.2, 1.25, .3, 1)), opacity var(--ov-dur-2, .2s);
+ opacity var(--ov-dur-2, .2s), visibility 0s;
}
}
+/* Out of the browser in its own window: the box in the page steps aside until that window closes. */
+.ov-pip.is-popped { opacity: 0; visibility: hidden; pointer-events: none; transition: opacity var(--ov-dur-2, .2s), visibility 0s linear var(--ov-dur-2, .2s); }
.ov-pip.is-floating { position: fixed; z-index: 900; }
.ov-pip.is-floating { position: fixed; z-index: 900; }
.ov-pip.is-lifted { position: fixed; z-index: 905; will-change: transform; transition: none; }
.ov-pip.is-lifted { position: fixed; z-index: 905; will-change: transform; transition: none; }
.ov-pip:hover { border-color: rgba(255, 255, 255, .3); }
.ov-pip:hover { border-color: rgba(255, 255, 255, .3); }
@@ -102,20 +105,29 @@
@@ -102,20 +105,29 @@
.ov-pip-state-t { max-width: 100%; overflow-wrap: anywhere; text-shadow: 0 1px 3px rgba(0, 0, 0, .6); }
.ov-pip-state-t { max-width: 100%; overflow-wrap: anywhere; text-shadow: 0 1px 3px rgba(0, 0, 0, .6); }
.ov-pip[data-state="offline"] .ov-pip-state-t { color: color-mix(in srgb, #fbbf24 70%, #fff); }
.ov-pip[data-state="offline"] .ov-pip-state-t { color: color-mix(in srgb, #fbbf24 70%, #fff); }
-/* Tucked into a side: only the tab and an edge of its picture stay on screen; a tap brings it back. */
-.ov-pip.is-tucked-left { translate: calc(-100% + 30px) 0; }
-.ov-pip.is-tucked-right { translate: calc(100% - 30px) 0; }
-.ov-pip.is-tucked .ov-pip-bar, .ov-pip.is-tucked .ov-pip-resize, .ov-pip.is-tucked .ov-pip-state-t { opacity: 0; pointer-events: none; }
-.ov-pip.is-tucked .ov-pip-video { filter: brightness(.75); }
+/* Tucked into a side: it folds into a small handle at the edge (the engine's clip), its picture still going behind the
+ glass; a tap opens it, a drag up or down slides it along the side, a pull brings it out. */
+.ov-pip.is-tucked { border-color: transparent; }
+.ov-pip.is-tucked .ov-pip-bar, .ov-pip.is-tucked .ov-pip-resize, .ov-pip.is-tucked .ov-pip-state { opacity: 0; pointer-events: none; }
.ov-pip-tab {
.ov-pip-tab {
- position: absolute; top: 0; bottom: 0; z-index: 4; width: 30px; display: none; place-items: center; padding: 0; cursor: pointer;
- border: 0; color: #fff; font-size: .85rem;
- background: linear-gradient(90deg, rgba(0, 0, 0, .55), rgba(0, 0, 0, .25));
- -webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px);
+ position: absolute; top: 50%; z-index: 4; display: none; place-items: center; padding: 0; cursor: pointer; touch-action: none;
+ width: var(--ov-float-tab-w, 30px); height: min(var(--ov-float-tab-h, 64px), 100%); translate: 0 -50%;
+ border: 0; color: #fff; font-size: .9rem;
+ background: color-mix(in srgb, rgba(10, 12, 18, .58) 82%, var(--accent, #3b82f6));
+ -webkit-backdrop-filter: blur(10px) saturate(1.25); backdrop-filter: blur(10px) saturate(1.25);
}
}
-.ov-pip.is-tucked .ov-pip-tab { display: grid; }
-.ov-pip.is-tucked-left .ov-pip-tab { right: 0; }
-.ov-pip.is-tucked-right .ov-pip-tab { left: 0; background: linear-gradient(270deg, rgba(0, 0, 0, .55), rgba(0, 0, 0, .25)); }
+.ov-pip.is-tucked .ov-pip-tab { display: grid; animation: ovPipTabIn var(--ov-dur-3, .32s) var(--ov-ease-out, ease-out); }
+@keyframes ovPipTabIn { from { opacity: 0; } }
+.ov-pip.is-tucked-left .ov-pip-tab { left: 0; border-radius: 0 12px 12px 0; box-shadow: inset -1px 0 0 rgba(255, 255, 255, .22); }
+.ov-pip.is-tucked-right .ov-pip-tab { right: 0; border-radius: 12px 0 0 12px; box-shadow: inset 1px 0 0 rgba(255, 255, 255, .22); }
+.ov-pip-tab:hover i, .ov-pip-tab:focus-visible i { transform: scale(1.18); }
+.ov-pip-tab i { transition: transform var(--ov-dur-1, .12s) var(--ov-ease-spring, ease); }
+/* Its live dot: the picture is still going behind the handle. */
+.ov-pip-tab::after {
+ content: ""; position: absolute; top: 9px; left: 50%; width: 6px; height: 6px; margin-left: -3px; border-radius: 50%;
+ background: #ef4444; box-shadow: 0 0 0 2px rgba(239, 68, 68, .25);
+}
+.ov-pip:not([data-state="live"]) .ov-pip-tab::after { background: rgba(255, 255, 255, .5); box-shadow: none; }
.ov-pip-tab-l, .ov-pip-tab-r { display: none; }
.ov-pip-tab-l, .ov-pip-tab-r { display: none; }
.ov-pip.is-tucked-right .ov-pip-tab-l, .ov-pip.is-tucked-left .ov-pip-tab-r { display: grid; place-items: center; }
.ov-pip.is-tucked-right .ov-pip-tab-l, .ov-pip.is-tucked-left .ov-pip-tab-r { display: grid; place-items: center; }
@@ -143,6 +155,9 @@
@@ -143,6 +155,9 @@
.ov-pip { border-radius: 12px; }
.ov-pip { border-radius: 12px; }
}
}
+/* While it moves, the glass under its buttons is plain (a blur repainted every frame made a phone's drag stutter). */
+.ov-pip.is-lifted .ov-pip-actions button, .ov-pip.is-lifted .ov-pip-state { -webkit-backdrop-filter: none; backdrop-filter: none; }
+
/* Motion off: no spring and no fade, it is simply where it goes. */
/* Motion off: no spring and no fade, it is simply where it goes. */
html[data-ov-motion="off"] .ov-pip, html[data-ov-motion="off"] .ov-pip-bar { transition: none; animation: none; }
html[data-ov-motion="off"] .ov-pip, html[data-ov-motion="off"] .ov-pip-bar { transition: none; animation: none; }
@media (prefers-reduced-motion: reduce) {
@media (prefers-reduced-motion: reduce) {
@@ -4729,7 +4729,7 @@ body :is(.modal-overlay, .bc-ws-confirm-overlay, .bc-modal, .bc-perm-overlay, .b
@@ -4729,7 +4729,7 @@ body :is(.modal-overlay, .bc-ws-confirm-overlay, .bc-modal, .bc-perm-overlay, .b
}
}
.gai-strip-text { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.gai-strip-text { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.gai-panel-head h3 { margin: 0; font-size: 1.05rem; display: flex; align-items: center; gap: 8px; }
.gai-panel-head h3 { margin: 0; font-size: 1.05rem; display: flex; align-items: center; gap: 8px; }
-.gai-panel-head h3 .fa-wand-magic-sparkles { color: var(--accent); }
+.gai-panel-head h3 .fa-sparkles { color: var(--accent); }
.gai-col h4 { margin: 0 0 10px; font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.03em; color: var(--text-secondary); display: flex; align-items: center; gap: 6px; }
.gai-col h4 { margin: 0 0 10px; font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.03em; color: var(--text-secondary); display: flex; align-items: center; gap: 6px; }
/* Timeline browser: head (title + search), period chips, scrollable list with day dividers. */
/* Timeline browser: head (title + search), period chips, scrollable list with day dividers. */
.gai-tl-head h4 { margin: 0; }
.gai-tl-head h4 { margin: 0; }
@@ -9098,7 +9098,7 @@ html[data-theme-mode="light"] .hero h1 { background-image: linear-gradient(90deg
@@ -9098,7 +9098,7 @@ html[data-theme-mode="light"] .hero h1 { background-image: linear-gradient(90deg
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
}
}
.card-ai-overview { position: relative; }
.card-ai-overview { position: relative; }
-.card-ai-clamp i.fa-wand-magic-sparkles { opacity: 0.85; margin-right: 2px; }
+.card-ai-clamp i.fa-sparkles { opacity: 0.85; margin-right: 2px; }
/* Reserve space at the end so the chevron never overlaps the text. */
/* Reserve space at the end so the chevron never overlaps the text. */
.card-ai-clamp { padding-right: 16px; }
.card-ai-clamp { padding-right: 16px; }
.card-ai-overview.expanded .card-ai-clamp { -webkit-line-clamp: unset; overflow: visible; }
.card-ai-overview.expanded .card-ai-clamp { -webkit-line-clamp: unset; overflow: visible; }
@@ -1157,12 +1157,6 @@ WantedBy=multi-user.target</code>
@@ -1157,12 +1157,6 @@ WantedBy=multi-user.target</code>
</button>
</button>
</div>
</div>
<div class="chat-tools-row">
<div class="chat-tools-row">
- <button class="emote-picker-btn" onclick="toggleEmotePicker('bc-chat-input')" title="Emotes">
- <i class="fa-solid fa-face-smile"></i>
- </button>
- <button class="emote-picker-btn gif-btn" onclick="toggleGifPicker('bc-chat-input')" title="GIFs">
- <span class="gif-btn-text">GIF</span>
- </button>
<button class="chat-tts-toggle tts-active" onclick="toggleChatTTS(this)" title="TTS On (click to mute)">
<button class="chat-tts-toggle tts-active" onclick="toggleChatTTS(this)" title="TTS On (click to mute)">
<i class="fa-solid fa-volume-high"></i>
<i class="fa-solid fa-volume-high"></i>
</button>
</button>
@@ -244,15 +244,6 @@
@@ -244,15 +244,6 @@
</button>
</button>
</div>
</div>
<div class="chat-tools-row">
<div class="chat-tools-row">
- <button class="emote-picker-btn" onclick="toggleEmotePicker('chat-input')" title="Emotes">
- <i class="fa-solid fa-face-smile"></i>
- </button>
- <button class="emote-picker-btn gif-btn" onclick="toggleGifPicker('chat-input')" title="GIFs">
- <span class="gif-btn-text">GIF</span>
- </button>
- <button class="emote-picker-btn" onclick="openChannelUploadModal()" title="Add channel emote / sound">
- <i class="fa-solid fa-plus"></i>
- </button>
<button class="emote-picker-btn chat-limits-btn" style="display:none" onclick="toggleChatLimitsPopover(this)" title="Chat limits (message + TTS length)">
<button class="emote-picker-btn chat-limits-btn" style="display:none" onclick="toggleChatLimitsPopover(this)" title="Chat limits (message + TTS length)">
<i class="fa-solid fa-sliders"></i>
<i class="fa-solid fa-sliders"></i>
</button>
</button>
@@ -369,15 +360,6 @@
@@ -369,15 +360,6 @@
</button>
</button>
</div>
</div>
<div class="chat-tools-row">
<div class="chat-tools-row">
- <button class="emote-picker-btn" onclick="toggleEmotePicker('offline-chat-input')" title="Emotes">
- <i class="fa-solid fa-face-smile"></i>
- </button>
- <button class="emote-picker-btn gif-btn" onclick="toggleGifPicker('offline-chat-input')" title="GIFs">
- <span class="gif-btn-text">GIF</span>
- </button>
- <button class="emote-picker-btn" onclick="openChannelUploadModal()" title="Add channel emote / sound">
- <i class="fa-solid fa-plus"></i>
- </button>
<button class="chat-tts-toggle tts-active" onclick="toggleChatTTS(this)" title="TTS On (click to mute)">
<button class="chat-tts-toggle tts-active" onclick="toggleChatTTS(this)" title="TTS On (click to mute)">
<i class="fa-solid fa-volume-high"></i>
<i class="fa-solid fa-volume-high"></i>
</button>
</button>
@@ -457,7 +439,7 @@
@@ -457,7 +439,7 @@
<div id="ch-clips-of-pagination" class="pastes-pagination" style="display:none"></div>
<div id="ch-clips-of-pagination" class="pastes-pagination" style="display:none"></div>
<!-- What the AI cut from these streams, after what people made (roadmap 33.6). -->
<!-- What the AI cut from these streams, after what people made (roadmap 33.6). -->
<div class="ch-ai-clips" id="ch-ai-clips-section" hidden>
<div class="ch-ai-clips" id="ch-ai-clips-section" hidden>
- <h3 class="ch-sub-h"><i class="fa-solid fa-wand-magic-sparkles ch-sec-ic" aria-hidden="true"></i>AI Moments</h3>
+ <h3 class="ch-sub-h"><i class="fa-solid fa-sparkles ch-sec-ic" aria-hidden="true"></i>AI Moments</h3>
<p class="ch-panel-hint">Clips OpenVibe's AI cut automatically when chat reacted. No one clipped these.</p>
<p class="ch-panel-hint">Clips OpenVibe's AI cut automatically when chat reacted. No one clipped these.</p>
<div class="stream-grid" id="ch-ai-clips-grid"></div>
<div class="stream-grid" id="ch-ai-clips-grid"></div>
<div id="ch-ai-clips-pagination" class="pastes-pagination" style="display:none"></div>
<div id="ch-ai-clips-pagination" class="pastes-pagination" style="display:none"></div>
@@ -124,7 +124,7 @@
@@ -124,7 +124,7 @@
</div>
</div>
</div>
</div>
<div class="global-ai-strip" id="global-ai-strip" style="display:none" onclick="toggleGlobalAiPanel()" title="AI read of the chat — click for the full insight & timeline">
<div class="global-ai-strip" id="global-ai-strip" style="display:none" onclick="toggleGlobalAiPanel()" title="AI read of the chat — click for the full insight & timeline">
- <span class="gai-badge"><i class="fa-solid fa-wand-magic-sparkles"></i> AI</span>
+ <span class="gai-badge"><i class="fa-solid fa-sparkles"></i> AI</span>
<span class="gai-strip-text" id="global-ai-strip-text"></span>
<span class="gai-strip-text" id="global-ai-strip-text"></span>
<i class="fa-solid fa-chevron-down gai-strip-caret" id="global-ai-strip-caret"></i>
<i class="fa-solid fa-chevron-down gai-strip-caret" id="global-ai-strip-caret"></i>
</div>
</div>
@@ -155,12 +155,6 @@
@@ -155,12 +155,6 @@
</button>
</button>
</div>
</div>
<div class="chat-tools-row">
<div class="chat-tools-row">
- <button class="emote-picker-btn" onclick="toggleEmotePicker('global-chat-input')" title="Emotes">
- <i class="fa-solid fa-face-smile"></i>
- </button>
- <button class="emote-picker-btn gif-btn" onclick="toggleGifPicker('global-chat-input')" title="GIFs">
- <span class="gif-btn-text">GIF</span>
- </button>
</div>
</div>
</div>
</div>
</div>
</div>
@@ -169,7 +163,7 @@
@@ -169,7 +163,7 @@
<!-- ── Global Chat AI insight + timeline (below the layout) ── -->
<!-- ── Global Chat AI insight + timeline (below the layout) ── -->
<section class="global-ai-panel" id="global-ai-panel" style="display:none">
<section class="global-ai-panel" id="global-ai-panel" style="display:none">
<div class="gai-panel-head">
<div class="gai-panel-head">
- <h3><i class="fa-solid fa-wand-magic-sparkles"></i> Global Chat — AI Insight</h3>
+ <h3><i class="fa-solid fa-sparkles"></i> Global Chat — AI Insight</h3>
<span class="gai-meta" id="global-ai-meta"></span>
<span class="gai-meta" id="global-ai-meta"></span>
<button class="gai-collapse-btn" onclick="toggleGlobalAiPanel()" title="Collapse"><i class="fa-solid fa-chevron-up"></i></button>
<button class="gai-collapse-btn" onclick="toggleGlobalAiPanel()" title="Collapse"><i class="fa-solid fa-chevron-up"></i></button>
</div>
</div>
@@ -5,7 +5,7 @@
@@ -5,7 +5,7 @@
<p class="cf-sub">Streams, clips and pastes made by people on OpenVibe.</p>
<p class="cf-sub">Streams, clips and pastes made by people on OpenVibe.</p>
</div>
</div>
<div class="cf-head-actions">
<div class="cf-head-actions">
- <a class="cf-crosslink" href="/moments" data-cf-link><i class="fa-solid fa-wand-magic-sparkles" aria-hidden="true"></i> AI Moments</a>
+ <a class="cf-crosslink" href="/moments" data-cf-link><i class="fa-solid fa-sparkles" aria-hidden="true"></i> AI Moments</a>
<button type="button" class="btn btn-small btn-outline cf-new-paste" data-cf-new-paste hidden><i class="fa-solid fa-plus" aria-hidden="true"></i> New paste</button>
<button type="button" class="btn btn-small btn-outline cf-new-paste" data-cf-new-paste hidden><i class="fa-solid fa-plus" aria-hidden="true"></i> New paste</button>
</div>
</div>
</header>
</header>
@@ -26,7 +26,7 @@
@@ -26,7 +26,7 @@
<button class="ch-tab active" data-cdtab="videos" onclick="switchDashContentTab('videos', this)"><i class="fa-solid fa-video"></i> Videos</button>
<button class="ch-tab active" data-cdtab="videos" onclick="switchDashContentTab('videos', this)"><i class="fa-solid fa-video"></i> Videos</button>
<button class="ch-tab" data-cdtab="streamclips" onclick="switchDashContentTab('streamclips', this)"><i class="fa-solid fa-scissors"></i> Clips of My Stream</button>
<button class="ch-tab" data-cdtab="streamclips" onclick="switchDashContentTab('streamclips', this)"><i class="fa-solid fa-scissors"></i> Clips of My Stream</button>
<button class="ch-tab" data-cdtab="myclips" onclick="switchDashContentTab('myclips', this)"><i class="fa-solid fa-film"></i> My Clips</button>
<button class="ch-tab" data-cdtab="myclips" onclick="switchDashContentTab('myclips', this)"><i class="fa-solid fa-film"></i> My Clips</button>
- <button class="ch-tab" data-cdtab="aimoments" onclick="switchDashContentTab('aimoments', this)"><i class="fa-solid fa-wand-magic-sparkles"></i> AI Moments<span class="dash-subtab-count" id="dash-ai-moments-count" hidden></span></button>
+ <button class="ch-tab" data-cdtab="aimoments" onclick="switchDashContentTab('aimoments', this)"><i class="fa-solid fa-sparkles"></i> AI Moments<span class="dash-subtab-count" id="dash-ai-moments-count" hidden></span></button>
<button class="ch-tab" data-cdtab="pastes" onclick="switchDashContentTab('pastes', this)"><i class="fa-solid fa-paste"></i> Pastes</button>
<button class="ch-tab" data-cdtab="pastes" onclick="switchDashContentTab('pastes', this)"><i class="fa-solid fa-paste"></i> Pastes</button>
</div>
</div>
<div class="dash-subpanel active" id="dash-content-videos"><div class="dash-grid" id="dash-grid-content-videos"></div></div>
<div class="dash-subpanel active" id="dash-content-videos"><div class="dash-grid" id="dash-grid-content-videos"></div></div>
@@ -134,7 +134,7 @@
@@ -134,7 +134,7 @@
</label>
</label>
<ul class="dash-el-list" id="dash-el-list" aria-label="Your buttons"></ul>
<ul class="dash-el-list" id="dash-el-list" aria-label="Your buttons"></ul>
<p class="dash-el-actions">
<p class="dash-el-actions">
- <a class="btn btn-primary btn-small" href="https://openvibe.design/create?kind=element" target="_blank" rel="noopener"><i class="fa-solid fa-wand-magic-sparkles" aria-hidden="true"></i> Make one with AI</a>
+ <a class="btn btn-primary btn-small" href="https://openvibe.design/create?kind=element" target="_blank" rel="noopener"><i class="fa-solid fa-sparkles" aria-hidden="true"></i> Make one with AI</a>
<a class="btn btn-small" href="https://openvibe.design/gallery" target="_blank" rel="noopener"><i class="fa-solid fa-grip" aria-hidden="true"></i> Free ones in the gallery</a>
<a class="btn btn-small" href="https://openvibe.design/gallery" target="_blank" rel="noopener"><i class="fa-solid fa-grip" aria-hidden="true"></i> Free ones in the gallery</a>
<a class="btn btn-small" id="dash-el-see" href="/" hidden><i class="fa-solid fa-eye" aria-hidden="true"></i> See them on your channel</a>
<a class="btn btn-small" id="dash-el-see" href="/" hidden><i class="fa-solid fa-eye" aria-hidden="true"></i> See them on your channel</a>
</p>
</p>
@@ -193,7 +193,7 @@
@@ -193,7 +193,7 @@
<!-- Vibe on stream (server/ai/vibe-voice.js): the streamer's call word wakes OpenVibe's assistant -->
<!-- Vibe on stream (server/ai/vibe-voice.js): the streamer's call word wakes OpenVibe's assistant -->
<div class="dash-card dash-card-wide" id="dash-card-vibe">
<div class="dash-card dash-card-wide" id="dash-card-vibe">
- <h3><i class="fa-solid fa-wand-magic-sparkles"></i> Vibe on stream <span class="muted">your call word</span></h3>
+ <h3><i class="fa-solid fa-sparkles"></i> Vibe on stream <span class="muted">your call word</span></h3>
<p class="muted" style="font-size:0.9rem">Say your call word on stream and Vibe, OpenVibe's assistant, jumps in. <b>"<span data-vibe-word>hey vibe</span>, clip that"</b> clips the last 45 seconds and posts the link. Anything else, like <b>"<span data-vibe-word>hey vibe</span>, what was the score last night?"</b>, gets an answer in your chat.</p>
<p class="muted" style="font-size:0.9rem">Say your call word on stream and Vibe, OpenVibe's assistant, jumps in. <b>"<span data-vibe-word>hey vibe</span>, clip that"</b> clips the last 45 seconds and posts the link. Anything else, like <b>"<span data-vibe-word>hey vibe</span>, what was the score last night?"</b>, gets an answer in your chat.</p>
<form class="vibe-form" id="dash-vibe-form">
<form class="vibe-form" id="dash-vibe-form">
<label class="vibe-switch"><input type="checkbox" name="enabled"> <span>Listen for my call word</span></label>
<label class="vibe-switch"><input type="checkbox" name="enabled"> <span>Listen for my call word</span></label>
@@ -333,7 +333,7 @@
@@ -333,7 +333,7 @@
Clips
Clips
</label>
</label>
<button class="btn btn-small btn-danger" onclick="dashBulkActionByAge()">
<button class="btn btn-small btn-danger" onclick="dashBulkActionByAge()">
- <i class="fa-solid fa-wand-magic-sparkles"></i> Apply
+ <i class="fa-solid fa-sparkles"></i> Apply
</button>
</button>
</div>
</div>
<div id="dash-vods-list" class="vod-grid"></div>
<div id="dash-vods-list" class="vod-grid"></div>
@@ -365,7 +365,7 @@
@@ -365,7 +365,7 @@
<!-- AI Moments of my stream: auto-clips, kept apart from the clips people took (roadmap 33.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">
<div class="dash-card dash-card-wide" id="dash-card-aimoments">
- <h3 style="margin:0 0 6px"><i class="fa-solid fa-wand-magic-sparkles"></i> AI Moments of My Stream</h3>
+ <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 → 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 → AI Moments from my streams.</p>
<div id="dash-ai-clips" class="vod-grid"></div>
<div id="dash-ai-clips" class="vod-grid"></div>
</div>
</div>
@@ -499,7 +499,7 @@
@@ -499,7 +499,7 @@
<!-- AI Moments from my streams: the channel's AI derivation switch (roadmap 33.7) -->
<!-- AI Moments from my streams: the channel's AI derivation switch (roadmap 33.7) -->
<div class="dash-card dash-card-wide" id="dash-card-ai-derivation">
<div class="dash-card dash-card-wide" id="dash-card-ai-derivation">
- <h3><i class="fa-solid fa-wand-magic-sparkles"></i> AI Moments from my streams</h3>
+ <h3><i class="fa-solid fa-sparkles"></i> AI Moments from my streams</h3>
<label class="staff-inline-toggle" style="margin-bottom:8px">
<label class="staff-inline-toggle" style="margin-bottom:8px">
<input type="checkbox" id="dash-ai-derivation" checked onchange="saveAiDerivationSetting(this)">
<input type="checkbox" id="dash-ai-derivation" checked onchange="saveAiDerivationSetting(this)">
<span>Let OpenVibe's AI make Moments from my streams</span>
<span>Let OpenVibe's AI make Moments from my streams</span>
@@ -3,7 +3,7 @@
@@ -3,7 +3,7 @@
<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:1rem;flex-wrap:wrap;margin-bottom:0.5rem">
<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:1rem;flex-wrap:wrap;margin-bottom:0.5rem">
<div>
<div>
- <h1 style="margin-bottom:0.25rem"><i class="fa-solid fa-wand-magic-sparkles"></i> OpenVibe.Live API Docs</h1>
+ <h1 style="margin-bottom:0.25rem"><i class="fa-solid fa-sparkles"></i> OpenVibe.Live API Docs</h1>
<p class="text-muted" style="margin:0">Modular docs for vibe coders. Copy just the section you need, or grab everything.</p>
<p class="text-muted" style="margin:0">Modular docs for vibe coders. Copy just the section you need, or grab everything.</p>
</div>
</div>
<div style="display:flex;gap:0.5rem;flex-shrink:0">
<div style="display:flex;gap:0.5rem;flex-shrink:0">
@@ -26,12 +26,12 @@
@@ -26,12 +26,12 @@
<a href="https://openvibe.games" style="--i:2" class="hb hb-game"><span class="hb-ico"><i class="fa-solid fa-gamepad"></i></span><span class="hb-label">Game</span><i class="fa-solid fa-chevron-right hb-go" aria-hidden="true"></i></a>
<a href="https://openvibe.games" style="--i:2" class="hb hb-game"><span class="hb-ico"><i class="fa-solid fa-gamepad"></i></span><span class="hb-label">Game</span><i class="fa-solid fa-chevron-right hb-go" aria-hidden="true"></i></a>
<a href="/documentation" onclick="return handleLinkClick(event, '/documentation')" style="--i:3" class="hb hb-vibe"
<a href="/documentation" onclick="return handleLinkClick(event, '/documentation')" style="--i:3" class="hb hb-vibe"
data-ovtip-title="Mod it, bot it, build on it"
data-ovtip-title="Mod it, bot it, build on it"
- data-ovtip-icon="fa-wand-magic-sparkles"
+ data-ovtip-icon="fa-sparkles"
data-ovtip-accent="#f472b6"
data-ovtip-accent="#f472b6"
data-ovtip-place="top"
data-ovtip-place="top"
data-ovtip="Chat, streams, clips, overlays, robots, sound commands, whole games — every feature here has an <b>open API</b>, and the entire site is <b>open source</b>. <em>You do not need to know how to code.</em> Point an AI at these docs, describe the thing you want, and it writes it for you."
data-ovtip="Chat, streams, clips, overlays, robots, sound commands, whole games — every feature here has an <b>open API</b>, and the entire site is <b>open source</b>. <em>You do not need to know how to code.</em> Point an AI at these docs, describe the thing you want, and it writes it for you."
data-ovtip-foot="<i class="fa-solid fa-heart"></i> We love what people build here — come make something"
data-ovtip-foot="<i class="fa-solid fa-heart"></i> We love what people build here — come make something"
- data-ovtip-go="Open the API docs"><span class="hb-ico"><i class="fa-solid fa-wand-magic-sparkles"></i></span><span class="hb-label">Build <span class="hb-amp">& Mod</span> <small>API docs</small></span><i class="fa-solid fa-chevron-right hb-go" aria-hidden="true"></i></a>
+ data-ovtip-go="Open the API docs"><span class="hb-ico"><i class="fa-solid fa-sparkles"></i></span><span class="hb-label">Build <span class="hb-amp">& Mod</span> <small>API docs</small></span><i class="fa-solid fa-chevron-right hb-go" aria-hidden="true"></i></a>
<a href="https://github.com/OpenVibers/OpenVibe.Live" target="_blank" style="--i:4" class="hb hb-github" rel="noopener"><span class="hb-ico"><i class="fa-brands fa-github"></i></span><span class="hb-label">GitHub</span><i class="fa-solid fa-chevron-right hb-go" aria-hidden="true"></i></a>
<a href="https://github.com/OpenVibers/OpenVibe.Live" target="_blank" style="--i:4" class="hb hb-github" rel="noopener"><span class="hb-ico"><i class="fa-brands fa-github"></i></span><span class="hb-label">GitHub</span><i class="fa-solid fa-chevron-right hb-go" aria-hidden="true"></i></a>
<a href="https://discord.gg/M6MuRUaeJj" target="_blank" style="--i:5" class="hb hb-discord" rel="noopener noreferrer"><span class="hb-ico"><i class="fa-brands fa-discord"></i></span><span class="hb-label">Discord</span><i class="fa-solid fa-chevron-right hb-go" aria-hidden="true"></i></a>
<a href="https://discord.gg/M6MuRUaeJj" target="_blank" style="--i:5" class="hb hb-discord" rel="noopener noreferrer"><span class="hb-ico"><i class="fa-brands fa-discord"></i></span><span class="hb-label">Discord</span><i class="fa-solid fa-chevron-right hb-go" aria-hidden="true"></i></a>
</nav>
</nav>
@@ -294,7 +294,7 @@
@@ -294,7 +294,7 @@
<span>Set funding goals with custom art, celebrate live in chat, and keep a real creator economy — with fair, transparent payouts.</span>
<span>Set funding goals with custom art, celebrate live in chat, and keep a real creator economy — with fair, transparent payouts.</span>
</div>
</div>
<div class="home-cta-feature">
<div class="home-cta-feature">
- <i class="fa-solid fa-wand-magic-sparkles"></i>
+ <i class="fa-solid fa-sparkles"></i>
<strong>AI clips, VODs & transcripts</strong>
<strong>AI clips, VODs & transcripts</strong>
<span>Every broadcast auto-records, clips on the fly, and gets AI overviews + searchable audio transcripts.</span>
<span>Every broadcast auto-records, clips on the fly, and gets AI overviews + searchable audio transcripts.</span>
</div>
</div>
@@ -360,9 +360,9 @@
@@ -360,9 +360,9 @@
<p class="ovnet-eyebrow"><span class="ovnet-dot" aria-hidden="true"></span>The OpenVibe network</p>
<p class="ovnet-eyebrow"><span class="ovnet-dot" aria-hidden="true"></span>The OpenVibe network</p>
<h2 id="ovnet-title">One account. <span class="ovnet-count" data-count="55">55</span> sites.</h2>
<h2 id="ovnet-title">One account. <span class="ovnet-count" data-count="55">55</span> sites.</h2>
<p class="ovnet-lede">Stream, play, cook, find work, build and ship: every OpenVibe site opens with the same sign-in, wears your theme and carries your OpenCoins and Vibes.</p>
<p class="ovnet-lede">Stream, play, cook, find work, build and ship: every OpenVibe site opens with the same sign-in, wears your theme and carries your OpenCoins and Vibes.</p>
- <nav class="ovnet-jump" data-ovk-scroll aria-label="Kinds of OpenVibe sites"><a href="#ovnet-g-streams" style="--g:#ef4444"><i class="fa-solid fa-tower-broadcast" aria-hidden="true"></i>Streams & places<b>7</b></a><a href="#ovnet-g-games" style="--g:#a855f7"><i class="fa-solid fa-gamepad" aria-hidden="true"></i>Games & laughs<b>6</b></a><a href="#ovnet-g-answers" style="--g:#f59e0b"><i class="fa-solid fa-newspaper" aria-hidden="true"></i>News & answers<b>8</b></a><a href="#ovnet-g-make" style="--g:#3b82f6"><i class="fa-solid fa-wand-magic-sparkles" aria-hidden="true"></i>Make & build<b>8</b></a><a href="#ovnet-g-shop" style="--g:#22c55e"><i class="fa-solid fa-basket-shopping" aria-hidden="true"></i>Shop & swap<b>8</b></a><a href="#ovnet-g-people" style="--g:#ec4899"><i class="fa-solid fa-user-group" aria-hidden="true"></i>People & you<b>7</b></a><a href="#ovnet-g-build" style="--g:#14b8a6"><i class="fa-solid fa-code" aria-hidden="true"></i>For developers<b>11</b></a></nav>
+ <nav class="ovnet-jump" data-ovk-scroll aria-label="Kinds of OpenVibe sites"><a href="#ovnet-g-streams" style="--g:#ef4444"><i class="fa-solid fa-tower-broadcast" aria-hidden="true"></i>Streams & places<b>7</b></a><a href="#ovnet-g-games" style="--g:#a855f7"><i class="fa-solid fa-gamepad" aria-hidden="true"></i>Games & laughs<b>6</b></a><a href="#ovnet-g-answers" style="--g:#f59e0b"><i class="fa-solid fa-newspaper" aria-hidden="true"></i>News & answers<b>8</b></a><a href="#ovnet-g-make" style="--g:#3b82f6"><i class="fa-solid fa-sparkles" aria-hidden="true"></i>Make & build<b>8</b></a><a href="#ovnet-g-shop" style="--g:#22c55e"><i class="fa-solid fa-basket-shopping" aria-hidden="true"></i>Shop & swap<b>8</b></a><a href="#ovnet-g-people" style="--g:#ec4899"><i class="fa-solid fa-user-group" aria-hidden="true"></i>People & you<b>7</b></a><a href="#ovnet-g-build" style="--g:#14b8a6"><i class="fa-solid fa-code" aria-hidden="true"></i>For developers<b>11</b></a></nav>
</header>
</header>
- <div class="ovnet-groups" data-sites="55"><div class="ovnet-group" id="ovnet-g-streams" role="group" aria-labelledby="ovnet-gh-streams" data-group="streams" style="--g:#ef4444"><h3 id="ovnet-gh-streams"><i class="fa-solid fa-tower-broadcast" aria-hidden="true"></i><span>Streams & places</span><b class="ovnet-n">7</b></h3><p class="ovnet-gline">Go live, watch, share pictures and video</p><ul class="ovnet-sites" data-ovk-scroll><li><a class="ovnet-site" href="https://openvibe.live/" data-site="live" style="--i:0"><span class="ov-icon" data-icon="live" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Live</b><small>openvibe.live</small><span>Go live, watch streams</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.video/" data-site="media-hub" style="--i:1"><span class="ov-icon" data-icon="video" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Video</b><small>openvibe.video</small><span>Clips and past streams</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.pics/" data-site="pics" style="--i:2"><span class="ov-icon" data-icon="pics" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Pics</b><small>openvibe.pics</small><span>Share pictures</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.download/" data-site="download" style="--i:3"><span class="ov-icon" data-icon="download" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Download</b><small>openvibe.download</small><span>Your files</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.cam/" data-site="cam" style="--i:4"><span class="ov-icon" data-icon="cam" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Cam</b><small>openvibe.cam</small><span>Your camera, your cameras, and Live's cam button</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.tours/" data-site="tours" style="--i:5"><span class="ov-icon" data-icon="tours" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Tours</b><small>openvibe.tours</small><span>Go anywhere: fly, stay, walk, watch</span></span></a></li><li><a class="ovnet-site" href="https://openre.stream/" data-site="stream" style="--i:6"><span class="ov-icon" data-icon="stream" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Restream</b><small>openre.stream</small><span>Stream everywhere</span></span></a></li></ul></div><div class="ovnet-group" id="ovnet-g-games" role="group" aria-labelledby="ovnet-gh-games" data-group="games" style="--g:#a855f7"><h3 id="ovnet-gh-games"><i class="fa-solid fa-gamepad" aria-hidden="true"></i><span>Games & laughs</span><b class="ovnet-n">6</b></h3><p class="ovnet-gline">Play in the browser, laugh, explore</p><ul class="ovnet-sites" data-ovk-scroll><li><a class="ovnet-site" href="https://openvibe.games/" data-site="games" style="--i:7"><span class="ov-icon" data-icon="games" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Games</b><small>openvibe.games</small><span>Play in your browser</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.fun/" data-site="fun" style="--i:8"><span class="ov-icon" data-icon="fun" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Fun</b><small>openvibe.fun</small><span>Something to do</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.casino/" data-site="casino" style="--i:9"><span class="ov-icon" data-icon="casino" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Casino</b><small>openvibe.casino</small><span>Learn it, play it, never pay it</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.meme/" data-site="meme" style="--i:10"><span class="ov-icon" data-icon="meme" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Meme</b><small>openvibe.meme</small><span>The internet's meme database</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.surf/" data-site="surf" style="--i:11"><span class="ov-icon" data-icon="surf" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Surf</b><small>openvibe.surf</small><span>Ride, browse, watch</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.quest/" data-site="quest" style="--i:12"><span class="ov-icon" data-icon="quest" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Quest</b><small>openvibe.quest</small><span>Earn badges</span></span></a></li></ul></div><div class="ovnet-group" id="ovnet-g-answers" role="group" aria-labelledby="ovnet-gh-answers" data-group="answers" style="--g:#f59e0b"><h3 id="ovnet-gh-answers"><i class="fa-solid fa-newspaper" aria-hidden="true"></i><span>News & answers</span><b class="ovnet-n">8</b></h3><p class="ovnet-gline">News, reports, reviews and answers</p><ul class="ovnet-sites" data-ovk-scroll><li><a class="ovnet-site" href="https://openvibe.news/" data-site="news" style="--i:13"><span class="ov-icon" data-icon="news" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>News</b><small>openvibe.news</small><span>Sourced, not spun</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.report/" data-site="report" style="--i:14"><span class="ov-icon" data-icon="report" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Report</b><small>openvibe.report</small><span>Your numbers, honest</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.blog/" data-site="blog" style="--i:15"><span class="ov-icon" data-icon="blog" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Blog</b><small>openvibe.blog</small><span>Write long-form</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.today/" data-site="today" style="--i:16"><span class="ov-icon" data-icon="today" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Today</b><small>openvibe.today</small><span>Everything happening for you, today</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.reviews/" data-site="reviews" style="--i:17"><span class="ov-icon" data-icon="reviews" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Reviews</b><small>openvibe.reviews</small><span>Honest reviews</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.vision/" data-site="vision" style="--i:18"><span class="ov-icon" data-icon="vision" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Vision</b><small>openvibe.vision</small><span>Ask a picture</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.wiki/" data-site="wiki" style="--i:19"><span class="ov-icon" data-icon="wiki" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Wiki</b><small>openvibe.wiki</small><span>Facts with sources</span></span></a></li><li><a class="ovnet-site" href="https://search.openvibe.network/" data-site="search" style="--i:20"><span class="ov-icon" data-icon="search" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Search</b><small>search.openvibe.network</small><span>Search everything</span></span></a></li></ul></div><div class="ovnet-group" id="ovnet-g-make" role="group" aria-labelledby="ovnet-gh-make" data-group="make" style="--g:#3b82f6"><h3 id="ovnet-gh-make"><i class="fa-solid fa-wand-magic-sparkles" aria-hidden="true"></i><span>Make & build</span><b class="ovnet-n">8</b></h3><p class="ovnet-gline">Tools, sites, bots, designs and code</p><ul class="ovnet-sites" data-ovk-scroll><li><a class="ovnet-site" href="https://openvibe.tools/" data-site="tools" style="--i:21"><span class="ov-icon" data-icon="tools" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Tools</b><small>openvibe.tools</small><span>Every online tool</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.space/" data-site="space" style="--i:22"><span class="ov-icon" data-icon="space" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Space</b><small>openvibe.space</small><span>Host a site or app</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.bot/" data-site="bot" style="--i:23"><span class="ov-icon" data-icon="bot" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Bot</b><small>openvibe.bot</small><span>Drive a robot</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.design/" data-site="design" style="--i:24"><span class="ov-icon" data-icon="design" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Design</b><small>openvibe.design</small><span>Make it yours</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.host/" data-site="host" style="--i:25"><span class="ov-icon" data-icon="host" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Host</b><small>openvibe.host</small><span>Compare hosting</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.domains/" data-site="domains" style="--i:26"><span class="ov-icon" data-icon="domains" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Domains</b><small>openvibe.domains</small><span>Name it, price it, run it</span></span></a></li><li><a class="ovnet-site" href="https://connect.openvibe.website/" data-site="connect" style="--i:27"><span class="ov-icon" data-icon="connect" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Connect</b><small>connect.openvibe.website</small><span>Your computer, on the web, privately</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.codes/" data-site="codes" style="--i:28"><span class="ov-icon" data-icon="codes" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Codes</b><small>openvibe.codes</small><span>Code with any agent</span></span></a></li></ul></div><div class="ovnet-group" id="ovnet-g-shop" role="group" aria-labelledby="ovnet-gh-shop" data-group="shop" style="--g:#22c55e"><h3 id="ovnet-gh-shop"><i class="fa-solid fa-basket-shopping" aria-hidden="true"></i><span>Shop & swap</span><b class="ovnet-n">8</b></h3><p class="ovnet-gline">Deals, jobs, food, homes and trades</p><ul class="ovnet-sites" data-ovk-scroll><li><a class="ovnet-site" href="https://openvibe.coupons/" data-site="coupons" style="--i:29"><span class="ov-icon" data-icon="coupons" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Coupons</b><small>openvibe.coupons</small><span>Codes that work</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.work/" data-site="work" style="--i:30"><span class="ov-icon" data-icon="work" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Work</b><small>openvibe.work</small><span>Find a job</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.deals/" data-site="deals" style="--i:31"><span class="ov-icon" data-icon="deals" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Deals</b><small>openvibe.deals</small><span>Deals people share</span></span></a></li><li><a class="ovnet-site" href="https://open.shoes/" data-site="shoes" style="--i:32"><span class="ov-icon" data-icon="shoes" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Shoes</b><small>open.shoes</small><span>Every shoe, every market, the right price</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.trade/" data-site="trade" style="--i:33"><span class="ov-icon" data-icon="trade" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Trade</b><small>openvibe.trade</small><span>Watch the markets</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.food/" data-site="food" style="--i:34"><span class="ov-icon" data-icon="food" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Food</b><small>openvibe.food</small><span>Food near you</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.rent/" data-site="rent" style="--i:35"><span class="ov-icon" data-icon="rent" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Rent</b><small>openvibe.rent</small><span>Places to rent</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.homes/" data-site="homes" style="--i:36"><span class="ov-icon" data-icon="homes" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Homes</b><small>openvibe.homes</small><span>Homes for sale</span></span></a></li></ul></div><div class="ovnet-group" id="ovnet-g-people" role="group" aria-labelledby="ovnet-gh-people" data-group="people" style="--g:#ec4899"><h3 id="ovnet-gh-people"><i class="fa-solid fa-user-group" aria-hidden="true"></i><span>People & you</span><b class="ovnet-n">7</b></h3><p class="ovnet-gline">Your people, your account, your stuff</p><ul class="ovnet-sites" data-ovk-scroll><li><a class="ovnet-site" href="https://openvibe.community/" data-site="community" style="--i:37"><span class="ov-icon" data-icon="community" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Community</b><small>openvibe.community</small><span>Posts and pastes</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.network/" data-site="network" style="--i:38"><span class="ov-icon" data-icon="network" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Network</b><small>openvibe.network</small><span>Your account</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.chat/" data-site="chat" style="--i:39"><span class="ov-icon" data-icon="chat" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Chat</b><small>openvibe.chat</small><span>Chat with anyone</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.tips/" data-site="tips" style="--i:40"><span class="ov-icon" data-icon="tips" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Tips</b><small>openvibe.tips</small><span>Support streamers</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.vip/" data-site="vip" style="--i:41"><span class="ov-icon" data-icon="vip" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>VIP</b><small>openvibe.vip</small><span>Back your creators</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.help/" data-site="help" style="--i:42"><span class="ov-icon" data-icon="help" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Help</b><small>openvibe.help</small><span>Help nearby</span></span></a></li><li><a class="ovnet-site" href="https://inventory.openvibe.network/" data-site="inventory" style="--i:43"><span class="ov-icon" data-icon="inventory" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Inventory</b><small>inventory.openvibe.network</small><span>Your items</span></span></a></li></ul></div><div class="ovnet-group" id="ovnet-g-build" role="group" aria-labelledby="ovnet-gh-build" data-group="build" style="--g:#14b8a6"><h3 id="ovnet-gh-build"><i class="fa-solid fa-code" aria-hidden="true"></i><span>For developers</span><b class="ovnet-n">11</b></h3><p class="ovnet-gline">APIs and services to build on</p><ul class="ovnet-sites" data-ovk-scroll><li><a class="ovnet-site" href="https://openvibe.media/" data-site="media" style="--i:44"><span class="ov-icon" data-icon="media" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Media</b><small>openvibe.media</small><span>Media API</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.exchange/" data-site="exchange" style="--i:45"><span class="ov-icon" data-icon="exchange" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Exchange</b><small>openvibe.exchange</small><span>AI models, one account</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.zone/" data-site="zone" style="--i:46"><span class="ov-icon" data-icon="zone" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Zone</b><small>openvibe.zone</small><span>S3 storage</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.services/" data-site="services" style="--i:47"><span class="ov-icon" data-icon="services" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Services</b><small>openvibe.services</small><span>APIs, SDK and keys</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.actor/" data-site="actor" style="--i:48"><span class="ov-icon" data-icon="actor" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Actor</b><small>openvibe.actor</small><span>The agent engine</span></span></a></li><li><a class="ovnet-site" href="https://ai.openvibe.services/" data-site="ai" style="--i:49"><span class="ov-icon" data-icon="ai" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>AI</b><small>ai.openvibe.services</small><span>AI router, your keys</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.events/" data-site="events" style="--i:50"><span class="ov-icon" data-icon="events" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Events</b><small>openvibe.events</small><span>Event streams</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.watch/" data-site="watch" style="--i:51"><span class="ov-icon" data-icon="watch" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Watch</b><small>openvibe.watch</small><span>Know when it changes</span></span></a></li><li><a class="ovnet-site" href="https://play.openvibe.network/" data-site="play" style="--i:52"><span class="ov-icon" data-icon="play" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Play</b><small>play.openvibe.network</small><span>Game services</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.run/" data-site="run" style="--i:53"><span class="ov-icon" data-icon="run" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Run</b><small>openvibe.run</small><span>Run jobs anywhere</span></span></a></li><li><a class="ovnet-site" href="https://bench.openvibe.services/" data-site="bench" style="--i:54"><span class="ov-icon" data-icon="bench" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Bench</b><small>bench.openvibe.services</small><span>Which AI is best</span></span></a></li></ul></div></div>
+ <div class="ovnet-groups" data-sites="55"><div class="ovnet-group" id="ovnet-g-streams" role="group" aria-labelledby="ovnet-gh-streams" data-group="streams" style="--g:#ef4444"><h3 id="ovnet-gh-streams"><i class="fa-solid fa-tower-broadcast" aria-hidden="true"></i><span>Streams & places</span><b class="ovnet-n">7</b></h3><p class="ovnet-gline">Go live, watch, share pictures and video</p><ul class="ovnet-sites" data-ovk-scroll><li><a class="ovnet-site" href="https://openvibe.live/" data-site="live" style="--i:0"><span class="ov-icon" data-icon="live" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Live</b><small>openvibe.live</small><span>Go live, watch streams</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.video/" data-site="media-hub" style="--i:1"><span class="ov-icon" data-icon="video" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Video</b><small>openvibe.video</small><span>Clips and past streams</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.pics/" data-site="pics" style="--i:2"><span class="ov-icon" data-icon="pics" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Pics</b><small>openvibe.pics</small><span>Share pictures</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.download/" data-site="download" style="--i:3"><span class="ov-icon" data-icon="download" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Download</b><small>openvibe.download</small><span>Your files</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.cam/" data-site="cam" style="--i:4"><span class="ov-icon" data-icon="cam" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Cam</b><small>openvibe.cam</small><span>Your camera, your cameras, and Live's cam button</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.tours/" data-site="tours" style="--i:5"><span class="ov-icon" data-icon="tours" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Tours</b><small>openvibe.tours</small><span>Go anywhere: fly, stay, walk, watch</span></span></a></li><li><a class="ovnet-site" href="https://openre.stream/" data-site="stream" style="--i:6"><span class="ov-icon" data-icon="stream" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Restream</b><small>openre.stream</small><span>Stream everywhere</span></span></a></li></ul></div><div class="ovnet-group" id="ovnet-g-games" role="group" aria-labelledby="ovnet-gh-games" data-group="games" style="--g:#a855f7"><h3 id="ovnet-gh-games"><i class="fa-solid fa-gamepad" aria-hidden="true"></i><span>Games & laughs</span><b class="ovnet-n">6</b></h3><p class="ovnet-gline">Play in the browser, laugh, explore</p><ul class="ovnet-sites" data-ovk-scroll><li><a class="ovnet-site" href="https://openvibe.games/" data-site="games" style="--i:7"><span class="ov-icon" data-icon="games" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Games</b><small>openvibe.games</small><span>Play in your browser</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.fun/" data-site="fun" style="--i:8"><span class="ov-icon" data-icon="fun" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Fun</b><small>openvibe.fun</small><span>Something to do</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.casino/" data-site="casino" style="--i:9"><span class="ov-icon" data-icon="casino" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Casino</b><small>openvibe.casino</small><span>Learn it, play it, never pay it</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.meme/" data-site="meme" style="--i:10"><span class="ov-icon" data-icon="meme" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Meme</b><small>openvibe.meme</small><span>The internet's meme database</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.surf/" data-site="surf" style="--i:11"><span class="ov-icon" data-icon="surf" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Surf</b><small>openvibe.surf</small><span>Ride, browse, watch</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.quest/" data-site="quest" style="--i:12"><span class="ov-icon" data-icon="quest" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Quest</b><small>openvibe.quest</small><span>Earn badges</span></span></a></li></ul></div><div class="ovnet-group" id="ovnet-g-answers" role="group" aria-labelledby="ovnet-gh-answers" data-group="answers" style="--g:#f59e0b"><h3 id="ovnet-gh-answers"><i class="fa-solid fa-newspaper" aria-hidden="true"></i><span>News & answers</span><b class="ovnet-n">8</b></h3><p class="ovnet-gline">News, reports, reviews and answers</p><ul class="ovnet-sites" data-ovk-scroll><li><a class="ovnet-site" href="https://openvibe.news/" data-site="news" style="--i:13"><span class="ov-icon" data-icon="news" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>News</b><small>openvibe.news</small><span>Sourced, not spun</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.report/" data-site="report" style="--i:14"><span class="ov-icon" data-icon="report" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Report</b><small>openvibe.report</small><span>Your numbers, honest</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.blog/" data-site="blog" style="--i:15"><span class="ov-icon" data-icon="blog" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Blog</b><small>openvibe.blog</small><span>Write long-form</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.today/" data-site="today" style="--i:16"><span class="ov-icon" data-icon="today" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Today</b><small>openvibe.today</small><span>Everything happening for you, today</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.reviews/" data-site="reviews" style="--i:17"><span class="ov-icon" data-icon="reviews" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Reviews</b><small>openvibe.reviews</small><span>Honest reviews</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.vision/" data-site="vision" style="--i:18"><span class="ov-icon" data-icon="vision" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Vision</b><small>openvibe.vision</small><span>Ask a picture</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.wiki/" data-site="wiki" style="--i:19"><span class="ov-icon" data-icon="wiki" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Wiki</b><small>openvibe.wiki</small><span>Facts with sources</span></span></a></li><li><a class="ovnet-site" href="https://search.openvibe.network/" data-site="search" style="--i:20"><span class="ov-icon" data-icon="search" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Search</b><small>search.openvibe.network</small><span>Search everything</span></span></a></li></ul></div><div class="ovnet-group" id="ovnet-g-make" role="group" aria-labelledby="ovnet-gh-make" data-group="make" style="--g:#3b82f6"><h3 id="ovnet-gh-make"><i class="fa-solid fa-sparkles" aria-hidden="true"></i><span>Make & build</span><b class="ovnet-n">8</b></h3><p class="ovnet-gline">Tools, sites, bots, designs and code</p><ul class="ovnet-sites" data-ovk-scroll><li><a class="ovnet-site" href="https://openvibe.tools/" data-site="tools" style="--i:21"><span class="ov-icon" data-icon="tools" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Tools</b><small>openvibe.tools</small><span>Every online tool</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.space/" data-site="space" style="--i:22"><span class="ov-icon" data-icon="space" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Space</b><small>openvibe.space</small><span>Host a site or app</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.bot/" data-site="bot" style="--i:23"><span class="ov-icon" data-icon="bot" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Bot</b><small>openvibe.bot</small><span>Drive a robot</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.design/" data-site="design" style="--i:24"><span class="ov-icon" data-icon="design" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Design</b><small>openvibe.design</small><span>Make it yours</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.host/" data-site="host" style="--i:25"><span class="ov-icon" data-icon="host" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Host</b><small>openvibe.host</small><span>Compare hosting</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.domains/" data-site="domains" style="--i:26"><span class="ov-icon" data-icon="domains" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Domains</b><small>openvibe.domains</small><span>Name it, price it, run it</span></span></a></li><li><a class="ovnet-site" href="https://connect.openvibe.website/" data-site="connect" style="--i:27"><span class="ov-icon" data-icon="connect" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Connect</b><small>connect.openvibe.website</small><span>Your computer, on the web, privately</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.codes/" data-site="codes" style="--i:28"><span class="ov-icon" data-icon="codes" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Codes</b><small>openvibe.codes</small><span>Code with any agent</span></span></a></li></ul></div><div class="ovnet-group" id="ovnet-g-shop" role="group" aria-labelledby="ovnet-gh-shop" data-group="shop" style="--g:#22c55e"><h3 id="ovnet-gh-shop"><i class="fa-solid fa-basket-shopping" aria-hidden="true"></i><span>Shop & swap</span><b class="ovnet-n">8</b></h3><p class="ovnet-gline">Deals, jobs, food, homes and trades</p><ul class="ovnet-sites" data-ovk-scroll><li><a class="ovnet-site" href="https://openvibe.coupons/" data-site="coupons" style="--i:29"><span class="ov-icon" data-icon="coupons" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Coupons</b><small>openvibe.coupons</small><span>Codes that work</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.work/" data-site="work" style="--i:30"><span class="ov-icon" data-icon="work" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Work</b><small>openvibe.work</small><span>Find a job</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.deals/" data-site="deals" style="--i:31"><span class="ov-icon" data-icon="deals" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Deals</b><small>openvibe.deals</small><span>Deals people share</span></span></a></li><li><a class="ovnet-site" href="https://open.shoes/" data-site="shoes" style="--i:32"><span class="ov-icon" data-icon="shoes" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Shoes</b><small>open.shoes</small><span>Every shoe, every market, the right price</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.trade/" data-site="trade" style="--i:33"><span class="ov-icon" data-icon="trade" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Trade</b><small>openvibe.trade</small><span>Watch the markets</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.food/" data-site="food" style="--i:34"><span class="ov-icon" data-icon="food" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Food</b><small>openvibe.food</small><span>Food near you</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.rent/" data-site="rent" style="--i:35"><span class="ov-icon" data-icon="rent" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Rent</b><small>openvibe.rent</small><span>Places to rent</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.homes/" data-site="homes" style="--i:36"><span class="ov-icon" data-icon="homes" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Homes</b><small>openvibe.homes</small><span>Homes for sale</span></span></a></li></ul></div><div class="ovnet-group" id="ovnet-g-people" role="group" aria-labelledby="ovnet-gh-people" data-group="people" style="--g:#ec4899"><h3 id="ovnet-gh-people"><i class="fa-solid fa-user-group" aria-hidden="true"></i><span>People & you</span><b class="ovnet-n">7</b></h3><p class="ovnet-gline">Your people, your account, your stuff</p><ul class="ovnet-sites" data-ovk-scroll><li><a class="ovnet-site" href="https://openvibe.community/" data-site="community" style="--i:37"><span class="ov-icon" data-icon="community" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Community</b><small>openvibe.community</small><span>Posts and pastes</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.network/" data-site="network" style="--i:38"><span class="ov-icon" data-icon="network" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Network</b><small>openvibe.network</small><span>Your account</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.chat/" data-site="chat" style="--i:39"><span class="ov-icon" data-icon="chat" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Chat</b><small>openvibe.chat</small><span>Chat with anyone</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.tips/" data-site="tips" style="--i:40"><span class="ov-icon" data-icon="tips" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Tips</b><small>openvibe.tips</small><span>Support streamers</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.vip/" data-site="vip" style="--i:41"><span class="ov-icon" data-icon="vip" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>VIP</b><small>openvibe.vip</small><span>Back your creators</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.help/" data-site="help" style="--i:42"><span class="ov-icon" data-icon="help" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Help</b><small>openvibe.help</small><span>Help nearby</span></span></a></li><li><a class="ovnet-site" href="https://inventory.openvibe.network/" data-site="inventory" style="--i:43"><span class="ov-icon" data-icon="inventory" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Inventory</b><small>inventory.openvibe.network</small><span>Your items</span></span></a></li></ul></div><div class="ovnet-group" id="ovnet-g-build" role="group" aria-labelledby="ovnet-gh-build" data-group="build" style="--g:#14b8a6"><h3 id="ovnet-gh-build"><i class="fa-solid fa-code" aria-hidden="true"></i><span>For developers</span><b class="ovnet-n">11</b></h3><p class="ovnet-gline">APIs and services to build on</p><ul class="ovnet-sites" data-ovk-scroll><li><a class="ovnet-site" href="https://openvibe.media/" data-site="media" style="--i:44"><span class="ov-icon" data-icon="media" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Media</b><small>openvibe.media</small><span>Media API</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.exchange/" data-site="exchange" style="--i:45"><span class="ov-icon" data-icon="exchange" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Exchange</b><small>openvibe.exchange</small><span>AI models, one account</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.zone/" data-site="zone" style="--i:46"><span class="ov-icon" data-icon="zone" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Zone</b><small>openvibe.zone</small><span>S3 storage</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.services/" data-site="services" style="--i:47"><span class="ov-icon" data-icon="services" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Services</b><small>openvibe.services</small><span>APIs, SDK and keys</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.actor/" data-site="actor" style="--i:48"><span class="ov-icon" data-icon="actor" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Actor</b><small>openvibe.actor</small><span>The agent engine</span></span></a></li><li><a class="ovnet-site" href="https://ai.openvibe.services/" data-site="ai" style="--i:49"><span class="ov-icon" data-icon="ai" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>AI</b><small>ai.openvibe.services</small><span>AI router, your keys</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.events/" data-site="events" style="--i:50"><span class="ov-icon" data-icon="events" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Events</b><small>openvibe.events</small><span>Event streams</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.watch/" data-site="watch" style="--i:51"><span class="ov-icon" data-icon="watch" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Watch</b><small>openvibe.watch</small><span>Know when it changes</span></span></a></li><li><a class="ovnet-site" href="https://play.openvibe.network/" data-site="play" style="--i:52"><span class="ov-icon" data-icon="play" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Play</b><small>play.openvibe.network</small><span>Game services</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.run/" data-site="run" style="--i:53"><span class="ov-icon" data-icon="run" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Run</b><small>openvibe.run</small><span>Run jobs anywhere</span></span></a></li><li><a class="ovnet-site" href="https://bench.openvibe.services/" data-site="bench" style="--i:54"><span class="ov-icon" data-icon="bench" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Bench</b><small>bench.openvibe.services</small><span>Which AI is best</span></span></a></li></ul></div></div>
<footer class="ovnet-foot">
<footer class="ovnet-foot">
<a class="ovnet-cta" href="/api/auth/sso/login?next=%2F" data-ovnet-signin><i class="fa-solid fa-user-plus" aria-hidden="true"></i> Create your free account</a>
<a class="ovnet-cta" href="/api/auth/sso/login?next=%2F" data-ovnet-signin><i class="fa-solid fa-user-plus" aria-hidden="true"></i> Create your free account</a>
<button type="button" class="ovnet-switch" data-ovnet-switcher><span class="ovnet-orb" aria-hidden="true"><svg class="ovmb" viewBox="0 0 24 24" width="22" height="22"><rect class="ovmb-bar" x="4" y="5.2" width="16" height="5.6" rx="2.8"/><circle class="ovmb-dot" cx="7.4" cy="8" r="1.15"/><rect class="ovmb-app ovmb-a0" x="4.2" y="14.6" width="4" height="4" rx="1.3"/><rect class="ovmb-app ovmb-a1" x="10" y="14.6" width="4" height="4" rx="1.3"/><rect class="ovmb-app ovmb-a2" x="15.8" y="14.6" width="4" height="4" rx="1.3"/></svg></span><span class="ovnet-switch-t"><b>Every site is in the menu</b><small>This button, at the top of every page</small></span><i class="ovnet-switch-go" aria-hidden="true"></i></button>
<button type="button" class="ovnet-switch" data-ovnet-switcher><span class="ovnet-orb" aria-hidden="true"><svg class="ovmb" viewBox="0 0 24 24" width="22" height="22"><rect class="ovmb-bar" x="4" y="5.2" width="16" height="5.6" rx="2.8"/><circle class="ovmb-dot" cx="7.4" cy="8" r="1.15"/><rect class="ovmb-app ovmb-a0" x="4.2" y="14.6" width="4" height="4" rx="1.3"/><rect class="ovmb-app ovmb-a1" x="10" y="14.6" width="4" height="4" rx="1.3"/><rect class="ovmb-app ovmb-a2" x="15.8" y="14.6" width="4" height="4" rx="1.3"/></svg></span><span class="ovnet-switch-t"><b>Every site is in the menu</b><small>This button, at the top of every page</small></span><i class="ovnet-switch-go" aria-hidden="true"></i></button>
@@ -1,7 +1,7 @@
@@ -1,7 +1,7 @@
<div class="cf-wrap cf-wrap--ai" data-feed="moments">
<div class="cf-wrap cf-wrap--ai" data-feed="moments">
<header class="cf-head">
<header class="cf-head">
<div class="cf-head-text">
<div class="cf-head-text">
- <h1 class="cf-h1"><span class="cf-ai-mark" aria-hidden="true"><i class="fa-solid fa-wand-magic-sparkles"></i></span> AI Moments</h1>
+ <h1 class="cf-h1"><span class="cf-ai-mark" aria-hidden="true"><i class="fa-solid fa-sparkles"></i></span> AI Moments</h1>
<p class="cf-sub">Auto-clips, standout moments and after-show recaps the AI made from OpenVibe streams. Everything here is AI-generated.</p>
<p class="cf-sub">Auto-clips, standout moments and after-show recaps the AI made from OpenVibe streams. Everything here is AI-generated.</p>
</div>
</div>
<div class="cf-head-actions">
<div class="cf-head-actions">
@@ -128,7 +128,7 @@
@@ -128,7 +128,7 @@
<div class="aiv-senses" id="aiv-senses"></div>
<div class="aiv-senses" id="aiv-senses"></div>
${GROUPS.map((g, i) => `<details class="aiv-group" ${i === 0 ? 'open' : ''}><summary><i class="fa-solid ${g.icon}"></i> ${g.title}</summary><div class="aiv-group-body">${g.keys.filter(k => byKey[k]).map(k => fieldHtml(byKey[k], s[k])).join('')}</div></details>`).join('')}
${GROUPS.map((g, i) => `<details class="aiv-group" ${i === 0 ? 'open' : ''}><summary><i class="fa-solid ${g.icon}"></i> ${g.title}</summary><div class="aiv-group-body">${g.keys.filter(k => byKey[k]).map(k => fieldHtml(byKey[k], s[k])).join('')}</div></details>`).join('')}
<div class="ai-bot-actions">
<div class="ai-bot-actions">
- <button class="btn btn-outline btn-small" onclick="previewAiViewers(this)"><i class="fa-solid fa-wand-magic-sparkles"></i> Preview what they'd say now</button>
+ <button class="btn btn-outline btn-small" onclick="previewAiViewers(this)"><i class="fa-solid fa-sparkles"></i> Preview what they'd say now</button>
<span class="muted" id="aiv-save-state" style="font-size:0.8rem"></span>
<span class="muted" id="aiv-save-state" style="font-size:0.8rem"></span>
</div>
</div>
<div id="aiv-preview" class="ai-bot-preview" style="display:none;"></div>`;
<div id="aiv-preview" class="ai-bot-preview" style="display:none;"></div>`;
@@ -763,7 +763,7 @@ function _channelPasteCardHTML(p, canManage) {
@@ -763,7 +763,7 @@ function _channelPasteCardHTML(p, canManage) {
<div class="ch-paste-info">
<div class="ch-paste-info">
<div class="ch-paste-title">${esc(p.title || 'Untitled')} ${vis}</div>
<div class="ch-paste-title">${esc(p.title || 'Untitled')} ${vis}</div>
<div class="ch-paste-meta muted"><span>${timeAgo(p.created_at)}</span> · <span><i class="fa-solid fa-eye"></i> ${p.views || 0}</span></div>
<div class="ch-paste-meta muted"><span>${timeAgo(p.created_at)}</span> · <span><i class="fa-solid fa-eye"></i> ${p.views || 0}</span></div>
- ${(typeof _cardAiHTML === 'function') ? _cardAiHTML(p.ai_summary) : ((p.ai_summary && p.ai_summary.trim()) ? `<div class="card-ai-overview"><i class="fa-solid fa-wand-magic-sparkles"></i> ${esc(p.ai_summary)}</div>` : '')}
+ ${(typeof _cardAiHTML === 'function') ? _cardAiHTML(p.ai_summary) : ((p.ai_summary && p.ai_summary.trim()) ? `<div class="card-ai-overview"><i class="fa-solid fa-sparkles"></i> ${esc(p.ai_summary)}</div>` : '')}
</div>
</div>
</a>`;
</a>`;
}
}
@@ -2975,7 +2975,7 @@ async function loadChannelAiTimeline(username) {
@@ -2975,7 +2975,7 @@ async function loadChannelAiTimeline(username) {
const hasBothOverviews = !!(streamerOv && chatOverall);
const hasBothOverviews = !!(streamerOv && chatOverall);
const topText = hasBothOverviews ? (combined || `${streamerOv}\n\n${chatOverall}`) : '';
const topText = hasBothOverviews ? (combined || `${streamerOv}\n\n${chatOverall}`) : '';
const header = topText
const header = topText
- ? `<div class="ai-tl-overview-card"><div class="ai-tl-overview-label"><i class="fa-solid fa-wand-magic-sparkles"></i> Overall AI overview <span class="ai-tl-ov-sub">as a streamer & chatter</span></div>${_collapsibleOverview(topText)}</div>`
+ ? `<div class="ai-tl-overview-card"><div class="ai-tl-overview-label"><i class="fa-solid fa-sparkles"></i> Overall AI overview <span class="ai-tl-ov-sub">as a streamer & chatter</span></div>${_collapsibleOverview(topText)}</div>`
: '';
: '';
// ── As a streamer (inner content, no label — the sub-tab / side-label supplies it) ──
// ── As a streamer (inner content, no label — the sub-tab / side-label supplies it) ──
@@ -3498,7 +3498,7 @@ function _aboutAiEffectiveShow() {
@@ -3498,7 +3498,7 @@ function _aboutAiEffectiveShow() {
function _aboutAiOverviewHTML() {
function _aboutAiOverviewHTML() {
if (!_aboutAiEffectiveShow()) return '';
if (!_aboutAiEffectiveShow()) return '';
return `<section class="ch-about-sec about-ai-overview" aria-label="AI summary">
return `<section class="ch-about-sec about-ai-overview" aria-label="AI summary">
- <h3 class="ch-about-h ch-sub-h"><i class="fa-solid fa-wand-magic-sparkles ch-sec-ic" aria-hidden="true"></i>AI summary <span class="ch-about-h-note">from past streams</span></h3>
+ <h3 class="ch-about-h ch-sub-h"><i class="fa-solid fa-sparkles ch-sec-ic" aria-hidden="true"></i>AI summary <span class="ch-about-h-note">from past streams</span></h3>
${_collapsibleOverview(_aboutAiOverview)}
${_collapsibleOverview(_aboutAiOverview)}
</section>`;
</section>`;
}
}
@@ -3952,7 +3952,7 @@ function _renderAboutEdit() {
@@ -3952,7 +3952,7 @@ function _renderAboutEdit() {
<div class="ch-about-ai-toggle">
<div class="ch-about-ai-toggle">
<label class="ch-about-mods-toggle" title="Show the AI-generated overview at the top of your About tab">
<label class="ch-about-mods-toggle" title="Show the AI-generated overview at the top of your About tab">
<input type="checkbox" id="ch-about-ai-overview-toggle" ${_aboutAiEffectiveShow() ? 'checked' : ''} onchange="_aboutAiPref=this.checked?'show':'hide';_markAboutDirty()">
<input type="checkbox" id="ch-about-ai-overview-toggle" ${_aboutAiEffectiveShow() ? 'checked' : ''} onchange="_aboutAiPref=this.checked?'show':'hide';_markAboutDirty()">
- <span><i class="fa-solid fa-wand-magic-sparkles"></i> Show AI overview</span>
+ <span><i class="fa-solid fa-sparkles"></i> Show AI overview</span>
</label>
</label>
<span class="muted ch-about-ai-hint">An AI-written summary of your streams. Shown automatically until you add a bio; turn it on here to keep showing it, or off to hide it.</span>
<span class="muted ch-about-ai-hint">An AI-written summary of your streams. Shown automatically until you add a bio; turn it on here to keep showing it, or off to hide it.</span>
</div>` : ''}
</div>` : ''}
@@ -4555,55 +4555,20 @@ async function offerLiveAlerts(name) {
@@ -4555,55 +4555,20 @@ async function offerLiveAlerts(name) {
}
}
/**
/**
- * The ⋯ menu opens in the top layer, placed on the screen (ask 371, 2026-11-13: "the little three dots doesn't actually
- * open a menu, at least not on mobile"): on a phone the live layout clips what hangs out of it, so the menu opened
- * unseen. Below its button, or above it when there is no room; inside the visible screen; again on a scroll or a resize;
- * a press elsewhere or Escape closes it. Without the Popover API it is fixed, which a clipping ancestor still allows.
+ * The ⋯ menu is the network's layer (openvibe-shared 4.235.0 OVSurfaces): anchored to its button in the top layer,
+ * placed inside the visible screen, above the button when there is no room below, again on a scroll or a resize; one
+ * open at a time, a press elsewhere or Escape closes it (ask 371: on a phone the live layout clipped it).
*/
*/
function _wireChannelMoreLayer(menu) {
function _wireChannelMoreLayer(menu) {
if (menu.dataset.layer) return;
if (menu.dataset.layer) return;
menu.dataset.layer = '1';
menu.dataset.layer = '1';
const pop = menu.querySelector('.ch-more-menu');
const pop = menu.querySelector('.ch-more-menu');
const summary = menu.querySelector('summary');
const summary = menu.querySelector('summary');
- if (!pop || !summary) return;
- const place = () => {
- if (!menu.open) return;
- const s = summary.getBoundingClientRect();
- const vv = window.visualViewport;
- const ox = vv ? vv.offsetLeft : 0, oy = vv ? vv.offsetTop : 0;
- const vw = vv ? vv.width : innerWidth, vh = vv ? vv.height : innerHeight;
- const w = pop.offsetWidth, h = pop.offsetHeight, m = 8, gap = 6;
- const left = Math.min(Math.max(ox + m, s.right - w), ox + vw - w - m);
- let top = s.bottom + gap;
- const above = top + h > oy + vh - m;
- if (above) top = s.top - h - gap >= oy + m ? s.top - h - gap : Math.max(oy + m, oy + vh - h - m);
- pop.style.left = `${Math.round(left)}px`;
- pop.style.top = `${Math.round(top)}px`;
- pop.classList.toggle('is-above', above);
- };
- const onDown = (e) => { if (!menu.contains(e.target)) menu.open = false; };
- const onKey = (e) => { if (e.key === 'Escape') { menu.open = false; summary.focus(); } };
+ let surface = null;
menu.addEventListener('toggle', () => {
menu.addEventListener('toggle', () => {
- const topLayer = typeof pop.showPopover === 'function';
- if (!menu.open) {
- if (topLayer && pop.hasAttribute('popover')) { try { pop.hidePopover(); } catch { /* closed */ } pop.removeAttribute('popover'); }
- pop.classList.remove('is-layer', 'is-above');
- pop.style.left = pop.style.top = '';
- document.removeEventListener('pointerdown', onDown, true);
- document.removeEventListener('keydown', onKey, true);
- window.removeEventListener('scroll', place, true);
- window.removeEventListener('resize', place);
- window.visualViewport?.removeEventListener('resize', place);
- return;
- }
- pop.classList.add('is-layer');
- if (topLayer) { pop.setAttribute('popover', 'manual'); try { pop.showPopover(); } catch { /* stays fixed */ } }
- place();
- document.addEventListener('pointerdown', onDown, true);
- document.addEventListener('keydown', onKey, true);
- window.addEventListener('scroll', place, { capture: true, passive: true });
- window.addEventListener('resize', place);
- window.visualViewport?.addEventListener('resize', place);
+ if (!window.OVSurfaces || !pop || !summary) return;
+ if (!surface) surface = window.OVSurfaces.popover({ el: menu, trigger: summary, pop, place: 'bottom-end', close: () => { menu.open = false; } });
+ if (menu.open) surface.open(); else surface.close();
});
});
}
}
@@ -21,7 +21,11 @@ async function loadGlobalChatAi() {
@@ -21,7 +21,11 @@ async function loadGlobalChatAi() {
if (!strip || !panel) return;
if (!strip || !panel) return;
let insight = null;
let insight = null;
try { insight = (await api('/chat/ai/global')).insight; } catch { /* silent */ }
try { insight = (await api('/chat/ai/global')).insight; } catch { /* silent */ }
+ // A read older than 12 hours is not "now": the strip steps aside (the panel keeps the notes and the timeline). The
+ // owner, 11-13: Global's line was three days old.
+ if (insight && insight.updated_at && _gaiAgeMs(insight.updated_at) > 12 * 3600e3) insight = { ...insight, overview: '' };
if (!insight || !insight.overview) {
if (!insight || !insight.overview) {
+ _gaiTicker(null, '');
strip.style.display = 'none';
strip.style.display = 'none';
// Keep the panel only if the user had already opened it.
// Keep the panel only if the user had already opened it.
if (!panel.classList.contains('gai-user-opened')) panel.style.display = 'none';
if (!panel.classList.contains('gai-user-opened')) panel.style.display = 'none';
@@ -29,8 +33,8 @@ async function loadGlobalChatAi() {
@@ -29,8 +33,8 @@ async function loadGlobalChatAi() {
}
}
// Header strip (compact)
// Header strip (compact)
const stripText = document.getElementById('global-ai-strip-text');
const stripText = document.getElementById('global-ai-strip-text');
- if (stripText) stripText.textContent = insight.overview;
strip.style.display = 'flex';
strip.style.display = 'flex';
+ if (stripText) _gaiTicker(stripText, insight.overview);
// Full panel
// Full panel
const ov = document.getElementById('global-ai-overview');
const ov = document.getElementById('global-ai-overview');
@@ -50,6 +54,45 @@ async function loadGlobalChatAi() {
@@ -50,6 +54,45 @@ async function loadGlobalChatAi() {
if (mem && !insight.memory) { mem.innerHTML = '<span class="gai-empty">Still building a picture of the community…</span>'; }
if (mem && !insight.memory) { mem.innerHTML = '<span class="gai-empty">Still building a picture of the community…</span>'; }
}
}
+/** How old a read is; Chat's times are UTC with no zone ("2026-11-10 08:38:23"). */
+function _gaiAgeMs(ts) {
+ const s = String(ts || '');
+ const t = Date.parse(/[zZ]|[+-]\d\d:?\d\d$/.test(s) ? s : `${s.replace(' ', 'T')}Z`);
+ return Number.isFinite(t) ? Date.now() - t : 0;
+}
+
+/**
+ * The strip reads like a ticker (the owner, 11-13: "make it scroll … scrolls through the summary … click to expand"):
+ * one sentence at a time, each rolling in every five seconds; one longer than the strip pans to its end first. It
+ * waits while a pointer is over it, while the panel is open and while the tab is hidden. With motion off the words
+ * change in place.
+ */
+let _gaiTick = { timer: 0, parts: [], i: 0 };
+function _gaiTicker(el, text) {
+ clearInterval(_gaiTick.timer);
+ const parts = (String(text || '').match(/[^.!?…]+[.!?…]+["”’)]*|[^.!?…]+$/g) || []).map((x) => x.trim()).filter(Boolean).slice(0, 4);
+ _gaiTick = { timer: 0, parts, i: 0 };
+ if (!el) return;
+ const show = (i, roll) => {
+ let t = el.querySelector('.gai-t');
+ if (!t) { t = document.createElement('span'); t.className = 'gai-t'; el.replaceChildren(t); }
+ t.textContent = parts[i] || '';
+ el.classList.remove('gai-roll', 'gai-pan');
+ void el.offsetWidth;
+ if (roll) el.classList.add('gai-roll');
+ const over = t.scrollWidth - el.clientWidth;
+ if (over > 4) { el.style.setProperty('--gai-shift', `${-over - 8}px`); el.style.setProperty('--gai-pan-s', `${Math.min(9, 2 + over / 40)}s`); el.classList.add('gai-pan'); }
+ };
+ show(0, false);
+ if (parts.length < 2 && !el.classList.contains('gai-pan')) return;
+ _gaiTick.timer = setInterval(() => {
+ if (document.hidden || !el.isConnected || el.matches(':hover') || el.closest('.global-ai-strip.open')) return;
+ if (parts.length < 2) { show(0, false); return; }
+ _gaiTick.i = (_gaiTick.i + 1) % parts.length;
+ show(_gaiTick.i, true);
+ }, 5200 + (el.classList.contains('gai-pan') ? 2000 : 0));
+}
+
// ── Global chat AI timeline browser (search + period jump + infinite scroll) ─────────────
// ── Global chat AI timeline browser (search + period jump + infinite scroll) ─────────────
let _gaiTlInited = false;
let _gaiTlInited = false;
let _gaiTl = { q: '', since: null, periodBefore: null, oldestTs: 0, loading: false, done: false, lastDay: null };
let _gaiTl = { q: '', since: null, periodBefore: null, oldestTs: 0, loading: false, done: false, lastDay: null };
@@ -2453,12 +2453,12 @@ function _renderHomeStar(data) {
@@ -2453,12 +2453,12 @@ function _renderHomeStar(data) {
} else if (bioSrc) {
} else if (bioSrc) {
bioHtml = `<p class="star-bio">${_linkify(esc(bioSrc))}</p>`;
bioHtml = `<p class="star-bio">${_linkify(esc(bioSrc))}</p>`;
}
}
- const overview = s.ai_overview ? `<p class="star-overview"><i class="fa-solid fa-wand-magic-sparkles"></i> ${esc(String(s.ai_overview).slice(0, 260))}${String(s.ai_overview).length > 260 ? '…' : ''}</p>` : '';
+ const overview = s.ai_overview ? `<p class="star-overview"><i class="fa-solid fa-sparkles"></i> ${esc(String(s.ai_overview).slice(0, 260))}${String(s.ai_overview).length > 260 ? '…' : ''}</p>` : '';
const pick = s.pick || null;
const pick = s.pick || null;
// Who picked them and when the next star lands are two separate facts, each kept on one line:
// Who picked them and when the next star lands are two separate facts, each kept on one line:
// as one uppercase run the countdown broke in half ("NEXT STAR / IN 3H") on a phone.
// as one uppercase run the countdown broke in half ("NEXT STAR / IN 3H") on a phone.
const why = pick && (pick.headline || pick.reason)
const why = pick && (pick.headline || pick.reason)
- ? `<div class="star-why">${pick.headline ? `<div class="star-why-head">${esc(pick.headline)}</div>` : ''}${pick.reason ? `<div class="star-why-body">${esc(pick.reason)}</div>` : ''}<div class="star-why-meta"><span><i class="fa-solid fa-wand-magic-sparkles"></i> ${pick.by === 'ai' ? 'Picked by OpenVibe\'s AI' : 'Picked on the numbers'}</span>${pick.next_at ? `<span class="star-why-next"><i class="fa-regular fa-clock"></i> Next star ${esc(_starCountdown(pick.next_at))}</span>` : ''}</div></div>`
+ ? `<div class="star-why">${pick.headline ? `<div class="star-why-head">${esc(pick.headline)}</div>` : ''}${pick.reason ? `<div class="star-why-body">${esc(pick.reason)}</div>` : ''}<div class="star-why-meta"><span><i class="fa-solid fa-sparkles"></i> ${pick.by === 'ai' ? 'Picked by OpenVibe\'s AI' : 'Picked on the numbers'}</span>${pick.next_at ? `<span class="star-why-next"><i class="fa-regular fa-clock"></i> Next star ${esc(_starCountdown(pick.next_at))}</span>` : ''}</div></div>`
: '';
: '';
const chips = [
const chips = [
foreign ? `<span class="star-chip pink">${esc(lang.flag)} Streams in ${esc(lang.name)}</span>` : '',
foreign ? `<span class="star-chip pink">${esc(lang.flag)} Streams in ${esc(lang.name)}</span>` : '',
@@ -117,7 +117,7 @@ function _cardAiHTML(text, full) {
@@ -117,7 +117,7 @@ function _cardAiHTML(text, full) {
// -webkit-line-clamp overflow can't clip it away; it sits at the bottom-right.
// -webkit-line-clamp overflow can't clip it away; it sits at the bottom-right.
const showToggle = hasMore || display.length > 100;
const showToggle = hasMore || display.length > 100;
return `<div class="card-ai-overview${showToggle ? '' : ' card-ai-static'}" role="button" tabindex="0" onclick="toggleCardAi(event,this)" onkeydown="if(event.key==='Enter'||event.key===' ')toggleCardAi(event,this)"${hasMore ? ` data-full="${esc(long)}"` : ''}>
return `<div class="card-ai-overview${showToggle ? '' : ' card-ai-static'}" role="button" tabindex="0" onclick="toggleCardAi(event,this)" onkeydown="if(event.key==='Enter'||event.key===' ')toggleCardAi(event,this)"${hasMore ? ` data-full="${esc(long)}"` : ''}>
- <div class="card-ai-clamp"><i class="fa-solid fa-wand-magic-sparkles"></i> <span class="card-ai-text">${esc(display)}</span></div>
+ <div class="card-ai-clamp"><i class="fa-solid fa-sparkles"></i> <span class="card-ai-text">${esc(display)}</span></div>
${showToggle ? '<i class="card-ai-toggle fa-solid fa-chevron-down" aria-label="Toggle overview"></i>' : ''}
${showToggle ? '<i class="card-ai-toggle fa-solid fa-chevron-down" aria-label="Toggle overview"></i>' : ''}
</div>`;
</div>`;
}
}
@@ -1487,7 +1487,7 @@ async function _openChatInsightModal(opts) {
@@ -1487,7 +1487,7 @@ async function _openChatInsightModal(opts) {
<div class="user-ai-modal">
<div class="user-ai-modal">
<div class="user-ai-modal-head">
<div class="user-ai-modal-head">
<h3><i class="fa-solid ${opts.iconClass || 'fa-user-tag'}"></i> ${esc(opts.title || 'User')}</h3>
<h3><i class="fa-solid ${opts.iconClass || 'fa-user-tag'}"></i> ${esc(opts.title || 'User')}</h3>
- <span class="gai-badge"><i class="fa-solid fa-wand-magic-sparkles"></i> AI</span>
+ <span class="gai-badge"><i class="fa-solid fa-sparkles"></i> AI</span>
<button class="uai-close" onclick="document.getElementById('user-ai-modal-overlay').remove()"><i class="fa-solid fa-xmark"></i></button>
<button class="uai-close" onclick="document.getElementById('user-ai-modal-overlay').remove()"><i class="fa-solid fa-xmark"></i></button>
</div>
</div>
<div id="user-ai-modal-body"><div class="gai-empty">Loading insight…</div></div>
<div id="user-ai-modal-body"><div class="gai-empty">Loading insight…</div></div>
@@ -1523,7 +1523,7 @@ async function _openChatInsightModal(opts) {
@@ -1523,7 +1523,7 @@ async function _openChatInsightModal(opts) {
html += `<div class="uai-group-label"><i class="fa-solid fa-tower-broadcast"></i> As a streamer</div>`;
html += `<div class="uai-group-label"><i class="fa-solid fa-tower-broadcast"></i> As a streamer</div>`;
html += `<p class="uai-sub uai-group-sub">Who they are as a streamer — from the AI analysis of their streams.</p>`;
html += `<p class="uai-sub uai-group-sub">Who they are as a streamer — from the AI analysis of their streams.</p>`;
html += `
html += `
- ${_uaiCollapsible('Streamer overview', '<span class="uai-tag uai-tag-streamer">channel</span>', esc(st.overview || st.overview_short || ''), { icon: 'fa-wand-magic-sparkles' })}
+ ${_uaiCollapsible('Streamer overview', '<span class="uai-tag uai-tag-streamer">channel</span>', esc(st.overview || st.overview_short || ''), { icon: 'fa-sparkles' })}
${mems.length ? `<div class="uai-section">
${mems.length ? `<div class="uai-section">
<h4><i class="fa-solid fa-timeline"></i> Recent stream moments</h4>
<h4><i class="fa-solid fa-timeline"></i> Recent stream moments</h4>
<div class="gai-timeline">${mems.map(m => `
<div class="gai-timeline">${mems.map(m => `
@@ -1578,7 +1578,7 @@ function _uaiCollapsible(title, tagHtml, bodyHtml, opts = {}) {
@@ -1578,7 +1578,7 @@ function _uaiCollapsible(title, tagHtml, bodyHtml, opts = {}) {
const preview = plain.length > 90 ? plain.slice(0, 90).trimEnd() + '…' : plain;
const preview = plain.length > 90 ? plain.slice(0, 90).trimEnd() + '…' : plain;
return `<div class="uai-section uai-collapsible">
return `<div class="uai-section uai-collapsible">
<h4 class="uai-collapse-toggle" onclick="_uaiToggleSection(this)" role="button" tabindex="0">
<h4 class="uai-collapse-toggle" onclick="_uaiToggleSection(this)" role="button" tabindex="0">
- <i class="fa-solid ${opts.icon || 'fa-wand-magic-sparkles'}"></i> ${title} ${tagHtml || ''}
+ <i class="fa-solid ${opts.icon || 'fa-sparkles'}"></i> ${title} ${tagHtml || ''}
<i class="fa-solid fa-chevron-down uai-collapse-caret"></i>
<i class="fa-solid fa-chevron-down uai-collapse-caret"></i>
</h4>
</h4>
<div class="uai-collapse-preview">${esc(preview)}</div>
<div class="uai-collapse-preview">${esc(preview)}</div>
@@ -349,7 +349,7 @@ function _wsRenderPanel() {
@@ -349,7 +349,7 @@ function _wsRenderPanel() {
placeholder="What are you streaming?" maxlength="200"
placeholder="What are you streaming?" maxlength="200"
oninput="_wsMarkDirty()">
oninput="_wsMarkDirty()">
<button type="button" class="gl-suggest" id="gl-suggest" onclick="_glSuggestTitles(this)" title="Three title ideas from your last streams on this slot">
<button type="button" class="gl-suggest" id="gl-suggest" onclick="_glSuggestTitles(this)" title="Three title ideas from your last streams on this slot">
- <i class="fa-solid fa-wand-magic-sparkles" aria-hidden="true"></i><span>Suggest</span>
+ <i class="fa-solid fa-sparkles" aria-hidden="true"></i><span>Suggest</span>
</button>
</button>
<span id="bc-ws-save-status" class="bc-ws-save-status" role="status" aria-live="polite"></span>
<span id="bc-ws-save-status" class="bc-ws-save-status" role="status" aria-live="polite"></span>
</div>
</div>
@@ -3385,7 +3385,7 @@ function _wsShowRestreamForm(existing) {
@@ -3385,7 +3385,7 @@ function _wsShowRestreamForm(existing) {
<p style="margin:0;font-size:0.82rem;color:var(--text-secondary)"><i class="fa-solid fa-robot" style="color:#4a9eff"></i> <strong>RobotStreamer</strong> uses a dedicated WebSocket connection (not RTMP). The token and robot below apply to <strong>this stream slot only</strong> — each slot can restream to a different robot.</p>
<p style="margin:0;font-size:0.82rem;color:var(--text-secondary)"><i class="fa-solid fa-robot" style="color:#4a9eff"></i> <strong>RobotStreamer</strong> uses a dedicated WebSocket connection (not RTMP). The token and robot below apply to <strong>this stream slot only</strong> — each slot can restream to a different robot.</p>
</div>
</div>
<div class="form-group">
<div class="form-group">
- <label><i class="fa-solid fa-wand-magic-sparkles" style="color:#4a9eff"></i> Easiest: log in with RobotStreamer</label>
+ <label><i class="fa-solid fa-sparkles" style="color:#4a9eff"></i> Easiest: log in with RobotStreamer</label>
<div style="display:flex;gap:8px">
<div style="display:flex;gap:8px">
<input type="text" id="ws-rs-rs-username" class="form-input form-input-sm" style="flex:1" placeholder="RobotStreamer username" autocomplete="off">
<input type="text" id="ws-rs-rs-username" class="form-input form-input-sm" style="flex:1" placeholder="RobotStreamer username" autocomplete="off">
<input type="password" id="ws-rs-rs-password" class="form-input form-input-sm" style="flex:1" placeholder="Password" autocomplete="off">
<input type="password" id="ws-rs-rs-password" class="form-input form-input-sm" style="flex:1" placeholder="Password" autocomplete="off">
@@ -3477,7 +3477,7 @@ function _wsShowRestreamForm(existing) {
@@ -3477,7 +3477,7 @@ function _wsShowRestreamForm(existing) {
</div>
</div>
</div>
</div>
<div class="bc-ws-toggle-group bc-ws-pc-only">
<div class="bc-ws-toggle-group bc-ws-pc-only">
- <div class="bc-ws-toggle-group-title"><i class="fa-solid fa-wand-magic-sparkles"></i> PowerChat overlay</div>
+ <div class="bc-ws-toggle-group-title"><i class="fa-solid fa-sparkles"></i> PowerChat overlay</div>
<div class="bc-ws-toggle-grid">
<div class="bc-ws-toggle-grid">
<label class="bc-toggle-label bc-ws-toggle-chip" title="Forward this platform's relayed chat to your PowerChat overlay (needs Chat relay)">
<label class="bc-toggle-label bc-ws-toggle-chip" title="Forward this platform's relayed chat to your PowerChat overlay (needs Chat relay)">
<input type="checkbox" id="ws-rs-powerchat-relay" ${(!existing || existing.powerchat_relay !== false) ? 'checked' : ''}>
<input type="checkbox" id="ws-rs-powerchat-relay" ${(!existing || existing.powerchat_relay !== false) ? 'checked' : ''}>
@@ -4878,7 +4878,7 @@ function _renderCameraSwitcherChoices(devices, activeCameraId) {
@@ -4878,7 +4878,7 @@ function _renderCameraSwitcherChoices(devices, activeCameraId) {
options.forEach((device, index) => {
options.forEach((device, index) => {
const current = (activeCameraId || 'default') === device.deviceId;
const current = (activeCameraId || 'default') === device.deviceId;
const meta = device._default
const meta = device._default
- ? { icon: 'fa-solid fa-wand-magic-sparkles', title: 'Default Camera', detail: 'Let the browser choose the preferred camera for this device.' }
+ ? { icon: 'fa-solid fa-sparkles', title: 'Default Camera', detail: 'Let the browser choose the preferred camera for this device.' }
: _describeCameraRole(device, index - 1);
: _describeCameraRole(device, index - 1);
const titleText = device._default ? 'System Default Camera' : (device.label || meta.title);
const titleText = device._default ? 'System Default Camera' : (device.label || meta.title);
const subtitleText = device._default ? meta.detail : `${meta.title} • ${meta.detail}`;
const subtitleText = device._default ? meta.detail : `${meta.title} • ${meta.detail}`;
@@ -394,7 +394,7 @@
@@ -394,7 +394,7 @@
</div>
</div>
<div class="ctx-actions">
<div class="ctx-actions">
${replyBtn}
${replyBtn}
- <button class="ctx-btn" onclick="dismissContextMenu();window.openRelayUserChatInsight&&openRelayUserChatInsight('${esc(platform)}','${esc(externalUsername)}','${esc(displayPlatform)}')"><i class="fa-solid fa-wand-magic-sparkles"></i> AI Insight</button>
+ <button class="ctx-btn" onclick="dismissContextMenu();window.openRelayUserChatInsight&&openRelayUserChatInsight('${esc(platform)}','${esc(externalUsername)}','${esc(displayPlatform)}')"><i class="fa-solid fa-sparkles"></i> AI Insight</button>
${cu && cu.id && ctx.currentStreamData && ctx.currentStreamData.user_id === cu.id ? `<button class="ctx-btn" onclick="dismissContextMenu();window.aivCloneChatter&&aivCloneChatter('relay','${esc(platform)}:${esc(externalUsername)}','${esc(externalUsername)}')"><i class="fa-solid fa-clone"></i> AI Clone</button>` : ''}
${cu && cu.id && ctx.currentStreamData && ctx.currentStreamData.user_id === cu.id ? `<button class="ctx-btn" onclick="dismissContextMenu();window.aivCloneChatter&&aivCloneChatter('relay','${esc(platform)}:${esc(externalUsername)}','${esc(externalUsername)}')"><i class="fa-solid fa-clone"></i> AI Clone</button>` : ''}
${cap.view_all_logs ? `<button class="ctx-btn" onclick="dismissContextMenu();ctxViewRelayLogs('${esc(platform)}','${esc(externalUsername)}')"><i class="fa-solid fa-clock-rotate-left"></i> Chat Logs</button>` : ''}
${cap.view_all_logs ? `<button class="ctx-btn" onclick="dismissContextMenu();ctxViewRelayLogs('${esc(platform)}','${esc(externalUsername)}')"><i class="fa-solid fa-clock-rotate-left"></i> Chat Logs</button>` : ''}
${modBtns ? '<div class="ctx-divider"></div>' + modBtns : ''}
${modBtns ? '<div class="ctx-divider"></div>' + modBtns : ''}
@@ -427,7 +427,7 @@
@@ -427,7 +427,7 @@
<div class="ctx-actions">
<div class="ctx-actions">
${replyBtn}
${replyBtn}
<button class="ctx-btn" data-username="${esc(username)}" onclick="ctxWhisper(this.dataset.username)"><i class="fa-solid fa-comment"></i> Message</button>
<button class="ctx-btn" data-username="${esc(username)}" onclick="ctxWhisper(this.dataset.username)"><i class="fa-solid fa-comment"></i> Message</button>
- <button class="ctx-btn" data-username="${esc(username)}" onclick="dismissContextMenu();window.openAnonChatInsight&&openAnonChatInsight(this.dataset.username)"><i class="fa-solid fa-wand-magic-sparkles"></i> AI Insight</button>
+ <button class="ctx-btn" data-username="${esc(username)}" onclick="dismissContextMenu();window.openAnonChatInsight&&openAnonChatInsight(this.dataset.username)"><i class="fa-solid fa-sparkles"></i> AI Insight</button>
${cap.view_all_logs ? `<button class="ctx-btn" data-username="${esc(username)}" onclick="dismissContextMenu();ctxViewAnonLogs(this.dataset.username)"><i class="fa-solid fa-clock-rotate-left"></i> Chat Logs</button>` : ''}
${cap.view_all_logs ? `<button class="ctx-btn" data-username="${esc(username)}" onclick="dismissContextMenu();ctxViewAnonLogs(this.dataset.username)"><i class="fa-solid fa-clock-rotate-left"></i> Chat Logs</button>` : ''}
${modBtns}
${modBtns}
${banBtns}
${banBtns}
@@ -492,7 +492,7 @@
@@ -492,7 +492,7 @@
<button class="ctx-btn" data-username="${esc(username)}" onclick="ctxWhisper(this.dataset.username)"><i class="fa-solid fa-comment"></i> Message</button>
<button class="ctx-btn" data-username="${esc(username)}" onclick="ctxWhisper(this.dataset.username)"><i class="fa-solid fa-comment"></i> Message</button>
${cu && cu.id && cu.id !== p.id ? `<button class="ctx-btn" data-username="${esc(username)}" data-uid="${esc(String(p.id))}" onclick="ctxCallUser(this.dataset.username, this.dataset.uid)"><i class="fa-solid fa-phone"></i> Call this user</button>` : ''}
${cu && cu.id && cu.id !== p.id ? `<button class="ctx-btn" data-username="${esc(username)}" data-uid="${esc(String(p.id))}" onclick="ctxCallUser(this.dataset.username, this.dataset.uid)"><i class="fa-solid fa-phone"></i> Call this user</button>` : ''}
<button class="ctx-btn" data-username="${esc(username)}" onclick="ctxViewChannel(this.dataset.username)"><i class="fa-solid fa-user"></i> Channel</button>
<button class="ctx-btn" data-username="${esc(username)}" onclick="ctxViewChannel(this.dataset.username)"><i class="fa-solid fa-user"></i> Channel</button>
- ${p.id ? `<button class="ctx-btn" data-username="${esc(username)}" data-uid="${esc(String(p.id))}" onclick="dismissContextMenu();window.openUserChatInsight&&openUserChatInsight(this.dataset.uid, this.dataset.username)"><i class="fa-solid fa-wand-magic-sparkles"></i> AI Insight</button>` : ''}
+ ${p.id ? `<button class="ctx-btn" data-username="${esc(username)}" data-uid="${esc(String(p.id))}" onclick="dismissContextMenu();window.openUserChatInsight&&openUserChatInsight(this.dataset.uid, this.dataset.username)"><i class="fa-solid fa-sparkles"></i> AI Insight</button>` : ''}
${p.id && cu && cu.id && ctx.currentStreamData && ctx.currentStreamData.user_id === cu.id ? `<button class="ctx-btn" data-username="${esc(username)}" data-uid="${esc(String(p.id))}" onclick="dismissContextMenu();window.aivCloneChatter&&aivCloneChatter('user', this.dataset.uid, this.dataset.username)"><i class="fa-solid fa-clone"></i> AI Clone</button>` : ''}
${p.id && cu && cu.id && ctx.currentStreamData && ctx.currentStreamData.user_id === cu.id ? `<button class="ctx-btn" data-username="${esc(username)}" data-uid="${esc(String(p.id))}" onclick="dismissContextMenu();window.aivCloneChatter&&aivCloneChatter('user', this.dataset.uid, this.dataset.username)"><i class="fa-solid fa-clone"></i> AI Clone</button>` : ''}
${cap.view_all_logs ? `<button class="ctx-btn" data-username="${esc(username)}" data-uid="${esc(String(p.id))}" onclick="ctxViewLogs(this.dataset.username, this.dataset.uid)"><i class="fa-solid fa-clock-rotate-left"></i> Chat Logs</button>` : ''}
${cap.view_all_logs ? `<button class="ctx-btn" data-username="${esc(username)}" data-uid="${esc(String(p.id))}" onclick="ctxViewLogs(this.dataset.username, this.dataset.uid)"><i class="fa-solid fa-clock-rotate-left"></i> Chat Logs</button>` : ''}
${cap.manage_users ? `<div class="ctx-rename-group">
${cap.manage_users ? `<div class="ctx-rename-group">
@@ -2456,7 +2456,7 @@ function buildChatMessageEl(msg, opts = {}) {
@@ -2456,7 +2456,7 @@ function buildChatMessageEl(msg, opts = {}) {
? `<div class="clip-chat-thumb"><img src="${esc(clip.thumbnail_url)}" alt="" loading="lazy">${dur ? `<span class="clip-chat-dur">${esc(dur)}</span>` : ''}<span class="clip-chat-play"><i class="fa-solid fa-play"></i></span></div>`
? `<div class="clip-chat-thumb"><img src="${esc(clip.thumbnail_url)}" alt="" loading="lazy">${dur ? `<span class="clip-chat-dur">${esc(dur)}</span>` : ''}<span class="clip-chat-play"><i class="fa-solid fa-play"></i></span></div>`
: `<div class="clip-chat-thumb clip-chat-thumb-ph"><i class="fa-solid fa-scissors"></i></div>`;
: `<div class="clip-chat-thumb clip-chat-thumb-ph"><i class="fa-solid fa-scissors"></i></div>`;
const label = isAuto
const label = isAuto
- ? `<div class="clip-chat-label"><i class="fa-solid fa-wand-magic-sparkles"></i> AI clip</div>`
+ ? `<div class="clip-chat-label"><i class="fa-solid fa-sparkles"></i> AI clip</div>`
: `<div class="clip-chat-label"><i class="fa-solid fa-scissors"></i> New clip</div>`;
: `<div class="clip-chat-label"><i class="fa-solid fa-scissors"></i> New clip</div>`;
const creator = esc(clip.creator || msg.username || 'someone');
const creator = esc(clip.creator || msg.username || 'someone');
const avatar = clip.creator_avatar
const avatar = clip.creator_avatar
@@ -3496,6 +3496,13 @@ function setChatReply(replyData) {
@@ -3496,6 +3496,13 @@ function setChatReply(replyData) {
_chatReplyTo = replyData;
_chatReplyTo = replyData;
// Show/update reply bar on all chat input areas
// Show/update reply bar on all chat input areas
document.querySelectorAll('.chat-input-area').forEach(area => {
document.querySelectorAll('.chat-input-area').forEach(area => {
+ // The shared box says it inside itself, joined to it, with its own × (ask 371, 11-13: "more connected to the
+ // actual composer"); clearing it there clears the reply here.
+ const ovc = area.querySelector('form.ovc-v2')?.__ovc;
+ if (ovc && typeof ovc.reply === 'function') {
+ ovc.reply({ name: replyData.username, text: _spoilFree(replyData.message || ''), data: replyData, onClear: () => { if (_chatReplyTo === replyData) _chatReplyTo = null; } });
+ return;
+ }
let bar = area.querySelector('.chat-reply-bar:not(.chat-edit-bar)');
let bar = area.querySelector('.chat-reply-bar:not(.chat-edit-bar)');
if (!bar) {
if (!bar) {
bar = document.createElement('div');
bar = document.createElement('div');
@@ -3520,7 +3527,9 @@ function setChatReply(replyData) {
@@ -3520,7 +3527,9 @@ function setChatReply(replyData) {
* Clear the reply target — hides the reply bar.
* Clear the reply target — hides the reply bar.
*/
*/
function clearChatReply() {
function clearChatReply() {
+ const was = _chatReplyTo;
_chatReplyTo = null;
_chatReplyTo = null;
+ if (was) document.querySelectorAll('.chat-input-area form.ovc-v2').forEach(f => { if (f.__ovc && f.classList.contains('is-replying')) f.__ovc.clearBar(); });
document.querySelectorAll('.chat-reply-bar:not(.chat-edit-bar)').forEach(bar => {
document.querySelectorAll('.chat-reply-bar:not(.chat-edit-bar)').forEach(bar => {
bar.style.display = 'none';
bar.style.display = 'none';
});
});
@@ -4,13 +4,36 @@
@@ -4,13 +4,36 @@
Loaded after /shared/ov-composer.js; without it the old textarea stays. */
Loaded after /shared/ov-composer.js; without it the old textarea stays. */
(function () {
(function () {
'use strict';
'use strict';
+ /* Live's tools in the shared box (ask 371, 2026-11-13: an emoji button and a "+" in the box, and again in the row under
+ it). The box holds what goes into a message: Live's emote picker (the channel's emotes and every emoji) in its row,
+ and in its + sheet a GIF and adding a channel emote or sound. The row under the box keeps the channel's own actions
+ (sound, coins, tips). Each tool finds its box's textarea by id, as the pickers always have. */
+ var SMILE = 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM8.5 14.2s1.3 1.8 3.5 1.8 3.5-1.8 3.5-1.8M9.2 9.6h.01M14.8 9.6h.01';
+ var GIF = 'M4 7a3 3 0 0 1 3-3h10a3 3 0 0 1 3 3v10a3 3 0 0 1-3 3H7a3 3 0 0 1-3-3zM10.2 10H8.6a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h1.6v-1.7M12.6 10v4M15.2 14v-4h2.2M15.2 12h1.8';
+ var ADD = 'M20 12a8 8 0 1 1-8-8M8.6 14.2s1.2 1.6 3.4 1.6 3.4-1.6 3.4-1.6M9.3 10h.01M14.7 10h.01M18.5 2.5v5M16 5h5';
+ function inputOf(form) { var t = form && form.querySelector('textarea'); return t ? t.id : ''; }
+ function registerTools() {
+ var C = window.OpenVibeComposer;
+ if (!C || typeof C.tool !== 'function' || registerTools.done) return;
+ registerTools.done = true;
+ C.tool('emote', { label: 'Emotes and emoji', path: SMILE, run: function (ctx, button, form) {
+ if (typeof window.toggleEmotePicker === 'function') window.toggleEmotePicker(inputOf(form || (ctx && ctx.form)));
+ } });
+ C.tool('gif', { label: 'GIF', path: GIF, extra: true, run: function (ctx, button, form) {
+ if (typeof window.toggleGifPicker === 'function') window.toggleGifPicker(inputOf(form || (ctx && ctx.form)));
+ } });
+ C.tool('emote-add', { label: 'Add an emote or sound', path: ADD, extra: true, run: function () {
+ if (typeof window.openChannelUploadModal === 'function') window.openChannelUploadModal();
+ } });
+ }
function upgrade(row) {
function upgrade(row) {
if (!row || row.dataset.ovcMounted || !window.OpenVibeComposer || !window.OpenVibeComposer.mount) return;
if (!row || row.dataset.ovcMounted || !window.OpenVibeComposer || !window.OpenVibeComposer.mount) return;
+ registerTools();
var old = row.querySelector('textarea');
var old = row.querySelector('textarea');
if (!old) return;
if (!old) return;
var id = old.id, max = Number(old.getAttribute('maxlength')) || 500, placeholder = old.placeholder || 'Send a message...';
var id = old.id, max = Number(old.getAttribute('maxlength')) || 500, placeholder = old.placeholder || 'Send a message...';
var c = window.OpenVibeComposer.mount(document.createElement('div'), {
var c = window.OpenVibeComposer.mount(document.createElement('div'), {
- preset: 'chat', maxLength: max, placeholder: placeholder, label: placeholder, tools: ['emoji'],
+ preset: 'chat', maxLength: max, placeholder: placeholder, label: placeholder, tools: ['emote'],
onSubmit: function () {
onSubmit: function () {
var before = c.text.value;
var before = c.text.value;
if (id === 'fcw-chat-input' && typeof window.fcwSendChat === 'function') window.fcwSendChat();
if (id === 'fcw-chat-input' && typeof window.fcwSendChat === 'function') window.fcwSendChat();
@@ -34,7 +34,7 @@
@@ -34,7 +34,7 @@
types: ['all', 'clips', 'shots', 'recaps'],
types: ['all', 'clips', 'shots', 'recaps'],
titles: { all: 'AI Moments', clips: 'AI auto-clips', shots: 'AI moments', recaps: 'AI recaps' },
titles: { all: 'AI Moments', clips: 'AI auto-clips', shots: 'AI moments', recaps: 'AI recaps' },
empty: {
empty: {
- all: ['fa-wand-magic-sparkles', 'No AI moments yet', 'The AI clips standout moments and writes recaps after streams.'],
+ all: ['fa-sparkles', 'No AI moments yet', 'The AI clips standout moments and writes recaps after streams.'],
clips: ['fa-scissors', 'No auto-clips yet', 'When chat erupts during a stream, the AI cuts a clip of it.'],
clips: ['fa-scissors', 'No auto-clips yet', 'When chat erupts during a stream, the AI cuts a clip of it.'],
shots: ['fa-image', 'No AI moments yet', 'The AI picks standout frames from recent streams.'],
shots: ['fa-image', 'No AI moments yet', 'The AI picks standout frames from recent streams.'],
recaps: ['fa-clipboard-list', 'No AI recaps yet', 'After a long enough stream the AI writes a recap.'],
recaps: ['fa-clipboard-list', 'No AI recaps yet', 'After a long enough stream the AI writes a recap.'],
@@ -129,7 +129,7 @@
@@ -129,7 +129,7 @@
});
});
return img;
return img;
}
}
- const aiBadge = () => el('span', { class: 'cf-ai', title: 'Made by AI' }, icon('fa-wand-magic-sparkles'), 'AI');
+ const aiBadge = () => el('span', { class: 'cf-ai', title: 'Made by AI' }, icon('fa-sparkles'), 'AI');
// ── cards ──────────────────────────────────────────────────────────────────────────────
// ── cards ──────────────────────────────────────────────────────────────────────────────
function meta(it, extra) {
function meta(it, extra) {
@@ -220,7 +220,7 @@
@@ -220,7 +220,7 @@
const stats = el('span', { class: 'cf-recap-stats' });
const stats = el('span', { class: 'cf-recap-stats' });
if (it.duration_seconds) stats.append(el('span', null, el('b', { text: duration(it.duration_seconds) }), ' live'));
if (it.duration_seconds) stats.append(el('span', null, el('b', { text: duration(it.duration_seconds) }), ' live'));
if (it.views) stats.append(el('span', null, el('b', { text: compact(it.views) }), ' peak'));
if (it.views) stats.append(el('span', null, el('b', { text: compact(it.views) }), ' peak'));
- band.append(el('span', { class: 'cf-recap-head' }, el('span', { class: 'cf-recap-label' }, icon('fa-wand-magic-sparkles'), ' AI recap'), el('span', { class: 'cf-recap-sub', text: 'After-show report' }), stats));
+ band.append(el('span', { class: 'cf-recap-head' }, el('span', { class: 'cf-recap-label' }, icon('fa-sparkles'), ' AI recap'), el('span', { class: 'cf-recap-sub', text: 'After-show report' }), stats));
const body = el('div', { class: 'cf-body' }, spa(it.href, { class: 'cf-title', text: it.title }));
const body = el('div', { class: 'cf-body' }, spa(it.href, { class: 'cf-title', text: it.title }));
if (it.stream_title && it.stream_title !== it.title) body.append(el('div', { class: 'cf-streamtitle', text: it.stream_title }));
if (it.stream_title && it.stream_title !== it.title) body.append(el('div', { class: 'cf-streamtitle', text: it.stream_title }));
body.append(meta(it));
body.append(meta(it));
@@ -1150,7 +1150,7 @@ function renderDashTokenPresetButtons() {
@@ -1150,7 +1150,7 @@ function renderDashTokenPresetButtons() {
<div style="display:flex;flex-wrap:wrap;gap:8px">
<div style="display:flex;flex-wrap:wrap;gap:8px">
${dashTokenPresets.map((preset) => `
${dashTokenPresets.map((preset) => `
<button type="button" class="btn btn-small btn-outline" onclick="applyDashTokenPreset('${preset.id}')">
<button type="button" class="btn btn-small btn-outline" onclick="applyDashTokenPreset('${preset.id}')">
- <i class="fa-solid fa-wand-magic-sparkles"></i> ${esc(preset.label)}
+ <i class="fa-solid fa-sparkles"></i> ${esc(preset.label)}
</button>`).join('')}
</button>`).join('')}
</div>
</div>
<p class="muted" id="token-preset-hint" style="font-size:0.78rem;margin-top:8px">Use a preset to preselect the scopes needed for a common integration.</p>`;
<p class="muted" id="token-preset-hint" style="font-size:0.78rem;margin-top:8px">Use a preset to preselect the scopes needed for a common integration.</p>`;
@@ -620,7 +620,8 @@ function closeEmotePicker() {
@@ -620,7 +620,8 @@ function closeEmotePicker() {
document.addEventListener('pointerdown', (e) => {
document.addEventListener('pointerdown', (e) => {
if (!_emotePickerOpen) return;
if (!_emotePickerOpen) return;
const t = e.target && e.target.closest ? e.target : null;
const t = e.target && e.target.closest ? e.target : null;
- if (t && (t.closest('.emote-picker') || t.closest('.emote-picker-btn'))) return;
+ // Its own button toggles it (the shared box's emote tool, or an old row's): a press there is left to that button.
+ if (t && (t.closest('.emote-picker') || t.closest('.emote-picker-btn') || t.closest('.ovc-t-emote'))) return;
closeEmotePicker();
closeEmotePicker();
}, true);
}, true);
document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && _emotePickerOpen) closeEmotePicker(); });
document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && _emotePickerOpen) closeEmotePicker(); });
@@ -85,7 +85,7 @@ function _getGifPicker() {
@@ -85,7 +85,7 @@ function _getGifPicker() {
document.addEventListener('pointerdown', (event) => {
document.addEventListener('pointerdown', (event) => {
if (!_gifPickerOpen || !_gifPickerEl) return;
if (!_gifPickerOpen || !_gifPickerEl) return;
const t = event.target;
const t = event.target;
- if (_gifPickerEl.contains(t) || (t && t.closest && t.closest('.gif-btn'))) return;
+ if (_gifPickerEl.contains(t) || (t && t.closest && (t.closest('.gif-btn') || t.closest('.ovc-t-gif') || t.closest('.ovc-tray-i')))) return;
closeGifPicker();
closeGifPicker();
}, true);
}, true);
document.addEventListener('keydown', (event) => {
document.addEventListener('keydown', (event) => {
@@ -139,9 +139,9 @@
@@ -139,9 +139,9 @@
el.querySelectorAll('[data-pick]').forEach(x => x.classList.remove('on')); b.classList.add('on');
el.querySelectorAll('[data-pick]').forEach(x => x.classList.remove('on')); b.classList.add('on');
if (b.dataset.pick === 'new') { D.slot = null; form.classList.remove('is-hidden'); el.querySelector('#ovg-title-in').focus(); }
if (b.dataset.pick === 'new') { D.slot = null; form.classList.remove('is-hidden'); el.querySelector('#ovg-title-in').focus(); }
else { D.slot = D.slots.find(s => String(s.id) === b.dataset.pick) || null; form.classList.add('is-hidden'); }
else { D.slot = D.slots.find(s => String(s.id) === b.dataset.pick) || null; form.classList.add('is-hidden'); }
- const n = document.querySelector('#ovg-foot [data-act="next"]'); if (n) n.innerHTML = D.slot ? 'Next <i class="fa-solid fa-arrow-right"></i>' : '<i class="fa-solid fa-wand-magic-sparkles"></i> Create my stream';
+ const n = document.querySelector('#ovg-foot [data-act="next"]'); if (n) n.innerHTML = D.slot ? 'Next <i class="fa-solid fa-arrow-right"></i>' : '<i class="fa-solid fa-sparkles"></i> Create my stream';
}));
}));
- const n = document.querySelector('#ovg-foot [data-act="next"]'); if (n && !D.slot) n.innerHTML = D.slots.length ? 'Pick a stream above' : '<i class="fa-solid fa-wand-magic-sparkles"></i> Create my stream';
+ const n = document.querySelector('#ovg-foot [data-act="next"]'); if (n && !D.slot) n.innerHTML = D.slots.length ? 'Pick a stream above' : '<i class="fa-solid fa-sparkles"></i> Create my stream';
},
},
validate: async () => {
validate: async () => {
if (D.slot) { D.method = D.slot.streaming_method || 'browser'; D.mode = D.slot.browser_mode || 'camera'; await loadEndpoint(); await loadDests(); return true; }
if (D.slot) { D.method = D.slot.streaming_method || 'browser'; D.mode = D.slot.browser_mode || 'camera'; await loadEndpoint(); await loadDests(); return true; }
@@ -519,7 +519,7 @@
@@ -519,7 +519,7 @@
<div class="ovg-join-cta">
<div class="ovg-join-cta">
<a class="btn btn-primary btn-lg ovg-join-go" href="/api/auth/sso/login"><i class="fa-solid fa-user-plus"></i> Create your free account</a>
<a class="btn btn-primary btn-lg ovg-join-go" href="/api/auth/sso/login"><i class="fa-solid fa-user-plus"></i> Create your free account</a>
<a class="btn btn-outline ovg-join-in" href="/api/auth/sso/login"><i class="fa-solid fa-right-to-bracket"></i> I already have one</a>
<a class="btn btn-outline ovg-join-in" href="/api/auth/sso/login"><i class="fa-solid fa-right-to-bracket"></i> I already have one</a>
- <button type="button" class="ovg-join-tour"><i class="fa-solid fa-wand-magic-sparkles"></i> Just show me around first</button>
+ <button type="button" class="ovg-join-tour"><i class="fa-solid fa-sparkles"></i> Just show me around first</button>
</div>
</div>
</div>`;
</div>`;
el.querySelector('.ovg-join-tour').addEventListener('click', () => openSiteTour());
el.querySelector('.ovg-join-tour').addEventListener('click', () => openSiteTour());
@@ -171,7 +171,7 @@
@@ -171,7 +171,7 @@
${up ? `<span class="hf-up"><i class="fa-solid fa-clock"></i> ${escText(up)}</span>` : ''}
${up ? `<span class="hf-up"><i class="fa-solid fa-clock"></i> ${escText(up)}</span>` : ''}
</a>
</a>
</div>
</div>
- ${now ? `<div class="hf-now"><span class="hf-now-head"><i class="fa-solid fa-wand-magic-sparkles"></i> Right now <span>${m.captured_at ? escText(typeof timeAgo === 'function' ? timeAgo(m.captured_at) : '') : ''}</span></span>${escText(now)}</div>` : ''}
+ ${now ? `<div class="hf-now"><span class="hf-now-head"><i class="fa-solid fa-sparkles"></i> Right now <span>${m.captured_at ? escText(typeof timeAgo === 'function' ? timeAgo(m.captured_at) : '') : ''}</span></span>${escText(now)}</div>` : ''}
${overview ? `<div class="hf-overview">${escText(overview)}</div>` : ''}
${overview ? `<div class="hf-overview">${escText(overview)}</div>` : ''}
<div class="hf-actions">
<div class="hf-actions">
<a class="hf-watch" href="${escText(chan)}" onclick="return handleLinkClick(event, '${escText(chan)}')"><i class="fa-solid fa-play"></i> Watch & chat</a>
<a class="hf-watch" href="${escText(chan)}" onclick="return handleLinkClick(event, '${escText(chan)}')"><i class="fa-solid fa-play"></i> Watch & chat</a>
@@ -30,7 +30,7 @@
@@ -30,7 +30,7 @@
{ id: 'home', page: 'home', label: 'Home', href: '/', icon: 'fa-house' },
{ id: 'home', page: 'home', label: 'Home', href: '/', icon: 'fa-house' },
// VODs, clips and pastes people made are one feed; what the AI made is the other.
// VODs, clips and pastes people made are one feed; what the AI made is the other.
{ id: 'content', page: 'content', label: 'Content', href: '/content', icon: 'fa-photo-film' },
{ id: 'content', page: 'content', label: 'Content', href: '/content', icon: 'fa-photo-film' },
- { id: 'moments', page: 'moments', label: 'Moments', href: '/moments', icon: 'fa-wand-magic-sparkles' },
+ { id: 'moments', page: 'moments', label: 'Moments', href: '/moments', icon: 'fa-sparkles' },
{ id: 'chat', page: 'chat', label: 'Chat', href: '/chat', icon: 'fa-comments' },
{ id: 'chat', page: 'chat', label: 'Chat', href: '/chat', icon: 'fa-comments' },
{ id: 'rundown', page: 'rundown', label: 'Rundown', href: '/rundown', icon: 'fa-newspaper' },
{ id: 'rundown', page: 'rundown', label: 'Rundown', href: '/rundown', icon: 'fa-newspaper' },
{ id: 'broadcast', page: 'broadcast', elId: 'nav-broadcast', label: 'Go Live', href: '/broadcast', icon: 'fa-tower-broadcast', dot: false, dotId: 'nav-live-dot',
{ id: 'broadcast', page: 'broadcast', elId: 'nav-broadcast', label: 'Go Live', href: '/broadcast', icon: 'fa-tower-broadcast', dot: false, dotId: 'nav-live-dot',
@@ -83,7 +83,7 @@ function renderPasteCard(p) {
@@ -83,7 +83,7 @@ function renderPasteCard(p) {
<span class="paste-card-time">${timeAgo}</span>
<span class="paste-card-time">${timeAgo}</span>
</div>
</div>
</div>
</div>
- ${(typeof _cardAiHTML === 'function') ? _cardAiHTML(p.ai_summary) : ((p.ai_summary && p.ai_summary.trim()) ? `<div class="card-ai-overview"><i class="fa-solid fa-wand-magic-sparkles"></i> ${escapeHtml(p.ai_summary)}</div>` : '')}
+ ${(typeof _cardAiHTML === 'function') ? _cardAiHTML(p.ai_summary) : ((p.ai_summary && p.ai_summary.trim()) ? `<div class="card-ai-overview"><i class="fa-solid fa-sparkles"></i> ${escapeHtml(p.ai_summary)}</div>` : '')}
</div>
</div>
</a>`;
</a>`;
// Wrap for admin bulk-select (ctrl/shift multi-select + bulk delete) when enabled.
// Wrap for admin bulk-select (ctrl/shift multi-select + bulk delete) when enabled.
@@ -344,7 +344,7 @@ async function loadPasteViewer(slug) {
@@ -344,7 +344,7 @@ async function loadPasteViewer(slug) {
try { _aiTags = p.ai_tags ? JSON.parse(p.ai_tags) : []; } catch { _aiTags = []; }
try { _aiTags = p.ai_tags ? JSON.parse(p.ai_tags) : []; } catch { _aiTags = []; }
const aiHtml = p.ai_summary ? `
const aiHtml = p.ai_summary ? `
<div class="paste-ai-analysis">
<div class="paste-ai-analysis">
- <div class="paste-ai-title"><i class="fa-solid fa-wand-magic-sparkles"></i> AI Analysis</div>
+ <div class="paste-ai-title"><i class="fa-solid fa-sparkles"></i> AI Analysis</div>
<div class="paste-ai-summary">${escapeHtml(p.ai_summary)}</div>
<div class="paste-ai-summary">${escapeHtml(p.ai_summary)}</div>
${_aiTags.length ? `<div class="paste-ai-tags">${_aiTags.map(t => `<span class="paste-ai-tag">${escapeHtml(String(t))}</span>`).join('')}</div>` : ''}
${_aiTags.length ? `<div class="paste-ai-tags">${_aiTags.map(t => `<span class="paste-ai-tag">${escapeHtml(String(t))}</span>`).join('')}</div>` : ''}
</div>` : '';
</div>` : '';
@@ -69,7 +69,7 @@
@@ -69,7 +69,7 @@
<header class="recap-head">
<header class="recap-head">
<div class="recap-grade" title="${esc(g.label)}"><span>${esc(w.grade || 'B')}</span><small>${esc(g.label)}</small></div>
<div class="recap-grade" title="${esc(g.label)}"><span>${esc(w.grade || 'B')}</span><small>${esc(g.label)}</small></div>
<div class="recap-head-body">
<div class="recap-head-body">
- <div class="recap-kicker"><i class="fa-solid fa-clipboard-list"></i> After-show report ${r.ai ? '<span class="recap-ai"><i class="fa-solid fa-wand-magic-sparkles"></i> written by OpenVibe AI</span>' : ''}</div>
+ <div class="recap-kicker"><i class="fa-solid fa-clipboard-list"></i> After-show report ${r.ai ? '<span class="recap-ai"><i class="fa-solid fa-sparkles"></i> written by OpenVibe AI</span>' : ''}</div>
<h1 class="recap-headline">${esc(w.headline || s.title)}</h1>
<h1 class="recap-headline">${esc(w.headline || s.title)}</h1>
<div class="recap-by">${av(u, 34)}<a class="recap-by-name" ${go(path)}>${esc(u.display_name)}</a><span class="muted">streamed</span><b>${esc(s.title)}</b><span class="recap-by-meta">${esc(when(s.started_at))} · ${dur(s.duration_seconds)}${s.category ? ` · ${esc(s.category)}` : ''}</span></div>
<div class="recap-by">${av(u, 34)}<a class="recap-by-name" ${go(path)}>${esc(u.display_name)}</a><span class="muted">streamed</span><b>${esc(s.title)}</b><span class="recap-by-meta">${esc(when(s.started_at))} · ${dur(s.duration_seconds)}${s.category ? ` · ${esc(s.category)}` : ''}</span></div>
${(w.tags || []).length ? `<div class="recap-tags">${w.tags.map(t => `<span>#${esc(t)}</span>`).join('')}</div>` : ''}
${(w.tags || []).length ? `<div class="recap-tags">${w.tags.map(t => `<span>#${esc(t)}</span>`).join('')}</div>` : ''}
@@ -17,11 +17,15 @@
@@ -17,11 +17,15 @@
- a PIP lives in the player (docked) or over the page (floating, position: fixed, it stays on screen while the page
- a PIP lives in the player (docked) or over the page (floating, position: fixed, it stays on screen while the page
scrolls); it follows the finger or the mouse with a transform each frame and lands with a spring (FLIP), dropped
scrolls); it follows the finger or the mouse with a transform each frame and lands with a spring (FLIP), dropped
over the player it docks, anywhere else it floats; a flick carries on the way it was thrown;
over the player it docks, anywhere else it floats; a flick carries on the way it was thrown;
- - thrown past the side of the screen (or of the player) it tucks into that side, a tab with its picture still going
- at the edge, and a tap brings it out ("get out of the way" without hiding it);
- - it is never smaller than a 150 px wide 16:9 box on a phone; two fingers pinch it, the corner sizes it, a double
- tap (or click) toggles small and large; with focus the arrows move it, + and − size it, M mutes, T tucks, F pops it
- out of the player or back in, Home puts it back where the streamer put it.
+ - thrown past the side of the screen (or of the player) it tucks into that side: it folds into a small handle at
+ the edge (openvibe-shared 4.235.0; owner 11-13: "a small button that lets you pop it in and out"), the handle
+ slides up and down its side, a tap opens it where it was and a pull brings it out under the finger;
+ - it is never smaller than a 150 px wide 16:9 box on a phone and grows to the whole width of the window (past the
+ player it floats over the page); two fingers pinch it, the corner sizes it, a double tap (or click) toggles small
+ and large (on a phone, the screen's width); with focus the arrows move it, + and − size it, M mutes, T tucks, F
+ pops it out of the player or back in, Home puts it back where the streamer put it;
+ - popped out of the browser (its own window on top of everything), the box in the page steps aside until that
+ window closes, so the picture is not shown twice.
Waiting (owner: "this black thing that's just stuck there … use the OV animations"): until its first frame, and
Waiting (owner: "this black thing that's just stuck there … use the OV animations"): until its first frame, and
whenever its frames stop for 4 s, it shows the mark at work over its last picture, and tries again by itself after
whenever its frames stop for 4 s, it shows the mark at work over its last picture, and tries again by itself after
15 s and on a backoff when its connection drops (2, 4, 8, 15, 30 s); a source that ended leaves with an animation.
15 s and on a backoff when its connection drops (2, 4, 8, 15, 30 s); a source that ended leaves with an animation.
@@ -38,7 +42,7 @@
@@ -38,7 +42,7 @@
const LS_KEY = (slot, sourceMsid) => `ov-pip-${slot}-${sourceMsid}`;
const LS_KEY = (slot, sourceMsid) => `ov-pip-${slot}-${sourceMsid}`;
const LS_V1 = (streamId, sourceMsid) => `ov-pip-geom-${streamId}-${sourceMsid}`; // per session, before 2026-11-11
const LS_V1 = (streamId, sourceMsid) => `ov-pip-geom-${streamId}-${sourceMsid}`; // per session, before 2026-11-11
- const MIN_W = 0.08, MAX_W = 0.60;
+ const MIN_W = 0.08, MAX_W = 1; // a viewer may make it as wide as the window (the streamer's default stays ≤ 0.6)
const POLL_MS = 30000;
const POLL_MS = 30000;
const BASE = 16 / 9; // the shape `w` is measured for
const BASE = 16 / 9; // the shape `w` is measured for
const MAX_H = 0.85; // of the frame's height, whatever the shape
const MAX_H = 0.85; // of the frame's height, whatever the shape
@@ -202,7 +206,7 @@
@@ -202,7 +206,7 @@
<button type="button" data-act="tuck" title="Tuck ${escapeHtml(label)} to the side" aria-label="Tuck ${escapeHtml(label)} to the side"><i class="fa-solid fa-angles-right"></i></button>
<button type="button" data-act="tuck" title="Tuck ${escapeHtml(label)} to the side" aria-label="Tuck ${escapeHtml(label)} to the side"><i class="fa-solid fa-angles-right"></i></button>
</span>
</span>
</div>
</div>
- <button type="button" class="ov-pip-tab" data-act="untuck" data-ov-float-tab title="Bring ${escapeHtml(label)} back" aria-label="Bring ${escapeHtml(label)} back"><span class="ov-pip-tab-l"><i class="fa-solid fa-chevron-left"></i></span><span class="ov-pip-tab-r"><i class="fa-solid fa-chevron-right"></i></span></button>
+ <button type="button" class="ov-pip-tab" data-ov-float-tab title="Bring ${escapeHtml(label)} back" aria-label="Bring ${escapeHtml(label)} back"><span class="ov-pip-tab-l"><i class="fa-solid fa-chevron-left"></i></span><span class="ov-pip-tab-r"><i class="fa-solid fa-chevron-right"></i></span></button>
<div class="ov-pip-resize" data-ov-float-resize title="Drag to resize" aria-hidden="true"></div>`;
<div class="ov-pip-resize" data-ov-float-resize title="Drag to resize" aria-hidden="true"></div>`;
o.el = el;
o.el = el;
o.container = container;
o.container = container;
@@ -233,8 +237,6 @@
@@ -233,8 +237,6 @@
minPx: MIN_PX,
minPx: MIN_PX,
bigW: BIG_W,
bigW: BIG_W,
tabPx: TAB_PX,
tabPx: TAB_PX,
- tab: false,
- resize: false,
hintClass: 'ov-pip-dock-hint',
hintClass: 'ov-pip-dock-hint',
ignore: '[data-act], .ov-pip-resize, [data-ov-float-resize], a',
ignore: '[data-act], .ov-pip-resize, [data-ov-float-resize], a',
onChange(geom) { o.geom = geom; saveGeom(o); },
onChange(geom) { o.geom = geom; saveGeom(o); },
@@ -251,41 +253,29 @@
@@ -251,41 +253,29 @@
container.appendChild(el);
container.appendChild(el);
}
}
wireMuteKey(o);
wireMuteKey(o);
- wireResize(o);
+ wirePopout(o);
setTimeout(() => el.classList.remove('is-entering'), 420);
setTimeout(() => el.classList.remove('is-entering'), 420);
}
}
- /** The corner grip: the shared engine owns pinch and the keys; this is the same sizing on the PIP's own handle. */
- function wireResize(o) {
- const el = o.el;
- const handle = el.querySelector('.ov-pip-resize');
- let start = null;
- handle.addEventListener('pointerdown', (e) => {
- if (e.button > 0) return;
- const b = o.float.metrics();
- start = { px: e.clientX, py: e.clientY, w: o.float.geom().w, rw: b.rw };
- el.classList.add('is-resizing');
- try { handle.setPointerCapture(e.pointerId); } catch { /* */ }
- e.preventDefault();
- e.stopPropagation();
- });
- handle.addEventListener('pointermove', (e) => {
- if (!start || !start.rw) return;
- // The handle's diagonal pull is the box's own width; `w` is the 16:9 width of the same area.
- const k = Math.sqrt((o.aspect || BASE) / BASE) || 1;
- const pull = ((e.clientX - start.px) + (e.clientY - start.py) * (o.aspect || BASE)) / 2;
- o.float.set({ w: clamp(start.w + pull / start.rw / k, MIN_W, MAX_W, start.w) });
- });
- const end = (e) => {
- if (!start) return;
- start = null;
- el.classList.remove('is-resizing');
- try { handle.releasePointerCapture(e.pointerId); } catch { /* */ }
- o.float.settle();
- o.float.save();
+ /**
+ * Out of the browser: while the browser's own picture-in-picture window shows this PIP, the box in the page steps
+ * aside (owner, 11-13: "it just goes twice like this and ugly"), and comes back when that window closes.
+ */
+ function wirePopout(o) {
+ const v = o.el.querySelector('.ov-pip-video');
+ const btn = o.el.querySelector('[data-act="popout"]');
+ const set = (out) => {
+ o.el.classList.toggle('is-popped', out);
+ if (btn) btn.setAttribute('aria-pressed', String(out));
+ if (!out && !reduced()) {
+ o.el.classList.remove('is-entering');
+ void o.el.offsetWidth;
+ o.el.classList.add('is-entering');
+ setTimeout(() => o.el && o.el.classList.remove('is-entering'), 420);
+ }
};
};
- handle.addEventListener('pointerup', end);
- handle.addEventListener('pointercancel', end);
+ v.addEventListener('enterpictureinpicture', () => set(true));
+ v.addEventListener('leavepictureinpicture', () => set(false));
}
}
/** With focus, M mutes; the engine owns the arrows, + and −, T, F and Home. */
/** With focus, M mutes; the engine owns the arrows, + and −, T, F and Home. */
@@ -321,7 +311,6 @@
@@ -321,7 +311,6 @@
}
}
if (!o.float) return;
if (!o.float) return;
if (what === 'tuck') o.float.tuck();
if (what === 'tuck') o.float.tuck();
- else if (what === 'untuck') o.float.untuck();
else if (what === 'reset') o.float.reset();
else if (what === 'reset') o.float.reset();
else if (what === 'float') { o.float.toggleMode(); }
else if (what === 'float') { o.float.toggleMode(); }
else if (what === 'mute') {
else if (what === 'mute') {
@@ -409,7 +409,7 @@ function _ensurePlayerLoaderStructure(placeholder) {
@@ -409,7 +409,7 @@ function _ensurePlayerLoaderStructure(placeholder) {
<div class="player-loader-expanded">
<div class="player-loader-expanded">
<p class="player-loader-detail"></p>
<p class="player-loader-detail"></p>
<p class="player-loader-hint">
<p class="player-loader-hint">
- <i class="fa-solid fa-wand-magic-sparkles"></i>
+ <i class="fa-solid fa-sparkles"></i>
<span></span>
<span></span>
</p>
</p>
<div class="player-loader-actions">
<div class="player-loader-actions">
@@ -551,7 +551,7 @@ button{font:inherit}
@@ -551,7 +551,7 @@ button{font:inherit}
<div class="vital"><div class="vlabel"><i class="fa-solid fa-scissors"></i> Clips</div><div class="vnum" id="v-clips">–</div><div class="vdelta zero" id="v-clips-d"> </div></div>
<div class="vital"><div class="vlabel"><i class="fa-solid fa-scissors"></i> Clips</div><div class="vnum" id="v-clips">–</div><div class="vdelta zero" id="v-clips-d"> </div></div>
<div class="vital"><div class="vlabel"><i class="fa-solid fa-paste"></i> Pastes</div><div class="vnum" id="v-pastes">–</div><div class="vdelta zero" id="v-pastes-d"> </div></div>
<div class="vital"><div class="vlabel"><i class="fa-solid fa-paste"></i> Pastes</div><div class="vnum" id="v-pastes">–</div><div class="vdelta zero" id="v-pastes-d"> </div></div>
<div class="vital"><div class="vlabel"><i class="fa-solid fa-hourglass-half"></i> Stream hrs</div><div class="vnum" id="v-hours">–</div><div class="vdelta zero" id="v-hours-d"> </div></div>
<div class="vital"><div class="vlabel"><i class="fa-solid fa-hourglass-half"></i> Stream hrs</div><div class="vnum" id="v-hours">–</div><div class="vdelta zero" id="v-hours-d"> </div></div>
- <div class="vital"><div class="vlabel"><i class="fa-solid fa-wand-magic-sparkles"></i> AI moments</div><div class="vnum" id="v-ai">–</div><div class="vdelta zero" id="v-ai-d"> </div></div>
+ <div class="vital"><div class="vlabel"><i class="fa-solid fa-sparkles"></i> AI moments</div><div class="vnum" id="v-ai">–</div><div class="vdelta zero" id="v-ai-d"> </div></div>
<div class="vital"><div class="vlabel"><i class="fa-solid fa-message"></i> Messages</div><div class="vnum" id="v-msgs">–</div><div class="vdelta zero" id="v-msgs-d"> </div></div>
<div class="vital"><div class="vlabel"><i class="fa-solid fa-message"></i> Messages</div><div class="vnum" id="v-msgs">–</div><div class="vdelta zero" id="v-msgs-d"> </div></div>
<div class="vital"><div class="vlabel"><i class="fa-solid fa-users"></i> OpenVibes</div><div class="vnum" id="v-users">–</div><div class="vdelta zero" id="v-users-d"> </div></div>
<div class="vital"><div class="vlabel"><i class="fa-solid fa-users"></i> OpenVibes</div><div class="vnum" id="v-users">–</div><div class="vdelta zero" id="v-users-d"> </div></div>
</section>
</section>
@@ -74,7 +74,7 @@ html[data-ov-motion=off] .al::after{display: none}
@@ -74,7 +74,7 @@ html[data-ov-motion=off] .al::after{display: none}
sub: { icon: 'fa-star', say: 'subscribed' },
sub: { icon: 'fa-star', say: 'subscribed' },
raid: { icon: 'fa-users', say: 'raided' },
raid: { icon: 'fa-users', say: 'raided' },
goal: { icon: 'fa-trophy', say: 'Goal reached' },
goal: { icon: 'fa-trophy', say: 'Goal reached' },
- element: { icon: 'fa-wand-magic-sparkles', say: 'pressed' },
+ element: { icon: 'fa-sparkles', say: 'pressed' },
};
};
var queue = [], showing = false, seen = new Set();
var queue = [], showing = false, seen = new Set();
function el(tag, cls, kids) { var n = document.createElement(tag); if (cls) n.className = cls; (kids || []).forEach(function (k) { if (k != null) n.append(k); }); return n; }
function el(tag, cls, kids) { var n = document.createElement(tag); if (cls) n.className = cls; (kids || []).forEach(function (k) { if (k != null) n.append(k); }); return n; }
@@ -191,15 +191,6 @@
@@ -191,15 +191,6 @@
</button>
</button>
</div>
</div>
<div class="chat-tools-row">
<div class="chat-tools-row">
- <button class="emote-picker-btn" onclick="toggleEmotePicker('pc-input')" title="Emotes">
- <i class="fa-solid fa-face-smile"></i>
- </button>
- <button class="emote-picker-btn gif-btn" onclick="toggleGifPicker('pc-input')" title="GIFs">
- <span class="gif-btn-text">GIF</span>
- </button>
- <button class="emote-picker-btn" onclick="if(typeof openChannelUploadModal==='function')openChannelUploadModal()" title="Add channel emote / sound">
- <i class="fa-solid fa-plus"></i>
- </button>
<button class="chat-tts-toggle tts-active" id="pc-tts-btn" onclick="toggleChatTTS(this)" title="TTS On (click to mute)">
<button class="chat-tts-toggle tts-active" id="pc-tts-btn" onclick="toggleChatTTS(this)" title="TTS On (click to mute)">
<i class="fa-solid fa-volume-high"></i>
<i class="fa-solid fa-volume-high"></i>
</button>
</button>
@@ -246,7 +246,7 @@
@@ -246,7 +246,7 @@
<!-- AI & transcription -->
<!-- AI & transcription -->
<div class="section">
<div class="section">
- <h2><i class="fa-solid fa-wand-magic-sparkles"></i> AI & Transcription</h2>
+ <h2><i class="fa-solid fa-sparkles"></i> AI & Transcription</h2>
<p>OpenVibe.Live has optional AI features that generate overviews, memories, and summaries of streams, VODs, clips, and pastes to make them easier to browse.</p>
<p>OpenVibe.Live has optional AI features that generate overviews, memories, and summaries of streams, VODs, clips, and pastes to make them easier to browse.</p>
<p><strong>Audio transcription runs locally on our own server</strong> (whisper.cpp) — your stream audio is <strong>not</strong> sent to a third-party transcription API. Frame analysis for stream memories may use an AI provider, gated by a strict daily budget and only on content you've made available on the platform.</p>
<p><strong>Audio transcription runs locally on our own server</strong> (whisper.cpp) — your stream audio is <strong>not</strong> sent to a third-party transcription API. Frame analysis for stream memories may use an AI provider, gated by a strict daily budget and only on content you've made available on the platform.</p>
<p>Generated transcripts, timestamps, and overviews are stored alongside the content they describe and follow the same visibility you chose for that content. If you delete a VOD, clip, or paste, its AI-generated data goes with it.</p>
<p>Generated transcripts, timestamps, and overviews are stored alongside the content they describe and follow the same visibility you chose for that content. If you delete a VOD, clip, or paste, its AI-generated data goes with it.</p>
new file mode 100644
new file mode 100644
@@ -0,0 +1,35 @@
@@ -0,0 +1,35 @@
+'use strict';
+// The owner's phone, 2026-11-13 (ask 371): the chat box holds what goes into a message (Live's emote picker in its
+// row; a GIF and adding an emote or sound in its + sheet) and the row under it only the channel's own actions, so
+// nothing is there twice; a reply is said inside the box; the global chat's AI line is a ticker that steps aside when
+// it is old; a section's glyph is drawn inside its tile, not to its edges; AI wears the sparkles.
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const read = (p) => fs.readFileSync(path.join(__dirname, '..', p), 'utf8');
+
+const composer = read('public/js/composer.js');
+assert.ok(/C\.tool\('emote', \{ label: 'Emotes and emoji'/.test(composer) && /tools: \['emote'\]/.test(composer), 'the box\'s row has Live\'s emote picker');
+assert.ok(/C\.tool\('gif', \{ label: 'GIF', path: GIF, extra: true/.test(composer) && /C\.tool\('emote-add', \{ label: 'Add an emote or sound', path: ADD, extra: true/.test(composer),
+ 'its + sheet has a GIF and adding an emote or sound');
+for (const f of ['public/fragments/channel.html', 'public/fragments/chat.html', 'public/fragments/broadcast.html', 'public/popout-chat.html']) {
+ const html = read(f);
+ assert.ok(!/onclick="toggleEmotePicker\(|onclick="toggleGifPicker\(|title="Add channel emote/.test(html), `${f}: no emote, GIF or add button under the box (they are in it)`);
+}
+assert.ok(/t\.closest\('\.ovc-t-emote'\)/.test(read('public/js/emotes.js')), 'a press on the box\'s emote button is left to it');
+assert.ok(/t\.closest\('\.ovc-t-gif'\) \|\| t\.closest\('\.ovc-tray-i'\)/.test(read('public/js/gif-picker.js')), 'and on the sheet\'s GIF');
+
+const chat = read('public/js/chat.js');
+assert.ok(/const ovc = area\.querySelector\('form\.ovc-v2'\)\?\.__ovc;\n\s*if \(ovc && typeof ovc\.reply === 'function'\) \{\n\s*ovc\.reply\(\{ name: replyData\.username/.test(chat), 'a reply is said inside the shared box');
+assert.ok(/f\.__ovc\.clearBar\(\)/.test(chat), 'and cleared there');
+
+const page = read('public/js/app-chatpage.js');
+assert.ok(/_gaiAgeMs\(insight\.updated_at\) > 12 \* 3600e3/.test(page), 'a read older than 12 hours steps aside');
+assert.ok(/function _gaiTicker\(el, text\)/.test(page) && /el\.matches\(':hover'\) \|\| el\.closest\('\.global-ai-strip\.open'\)/.test(page), 'a ticker that waits under a pointer and while open');
+const css = read('public/css/features/chatpage.css');
+assert.ok(css.includes('@keyframes gaiRoll') && css.includes('@keyframes gaiPan') && css.includes('html[data-ov-motion="off"] .global-ai-strip .gai-t { animation: none !important; }'), 'it rolls and pans, and not with motion off');
+
+assert.ok(read('public/css/features/channel.css').includes('#page-channel .ch-sec-ic > svg { width: 62%; height: 62%; }'), 'a section\'s glyph inside its tile');
+const wand = ['public/js/app-channel.js', 'public/js/chat.js', 'public/fragments/chat.html', 'public/fragments/channel.html'].filter((f) => read(f).includes('fa-wand-magic-sparkles'));
+assert.deepStrictEqual(wand, [], 'AI wears the sparkles, not a wand');
+console.log('chat-box-and-ticker: one emote button, the extras in the +, a reply in the box, a ticker that steps aside, glyphs in their tiles');
@@ -21,9 +21,9 @@ assert.ok(/> \.chat-sidebar > :is\(\.chat-settings-panel, \.chat-users-panel\) \
@@ -21,9 +21,9 @@ assert.ok(/> \.chat-sidebar > :is\(\.chat-settings-panel, \.chat-users-panel\) \
assert.ok(/> \.chat-sidebar \.chat-header \{ padding-top: 4px; padding-bottom: 4px; min-height: 44px; \}/.test(css), 'a compact header');
assert.ok(/> \.chat-sidebar \.chat-header \{ padding-top: 4px; padding-bottom: 4px; min-height: 44px; \}/.test(css), 'a compact header');
assert.ok(/> \.chat-sidebar > \.chat-grip \{ position: absolute; top: 0; height: 14px;/.test(css), 'the grip over its top edge, no row of its own');
assert.ok(/> \.chat-sidebar > \.chat-grip \{ position: absolute; top: 0; height: 14px;/.test(css), 'the grip over its top edge, no row of its own');
const js = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'app-channel.js'), 'utf8');
const js = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'app-channel.js'), 'utf8');
-assert.ok(/function _wireChannelMoreLayer\(menu\)/.test(js) && /pop\.setAttribute\('popover', 'manual'\)/.test(js) && /_wireChannelMoreLayer\(menu\);\n menu\.open = false;/.test(js),
- 'the ⋯ menu opens in the top layer, placed on the screen (a phone\'s live layout clipped it)');
-assert.ok(css.includes('#page-channel .ch-more-menu.is-layer { position: fixed;'), 'and is fixed there');
+assert.ok(/function _wireChannelMoreLayer\(menu\)/.test(js) && /window\.OVSurfaces\.popover\(\{ el: menu, trigger: summary, pop, place: 'bottom-end'/.test(js) && /_wireChannelMoreLayer\(menu\);\n menu\.open = false;/.test(js),
+ 'the ⋯ menu is the network\'s layer: anchored to its button in the top layer (a phone\'s live layout clipped it)');
+assert.ok(!/_wireChannelMoreLayer[\s\S]{0,1500}showPopover/.test(js), 'Live keeps no placement of its own');
assert.ok(css.includes('.ch-bot-tabs button > :is(i, svg) { flex: none; display: inline-grid; place-items: center; width: 20px; height: 20px;'),
assert.ok(css.includes('.ch-bot-tabs button > :is(i, svg) { flex: none; display: inline-grid; place-items: center; width: 20px; height: 20px;'),
'every tab icon fills the same box (the robot was drawn smaller than the chat bubble)');
'every tab icon fills the same box (the robot was drawn smaller than the chat bubble)');
const html = fs.readFileSync(path.join(__dirname, '..', 'public', 'fragments', 'channel.html'), 'utf8');
const html = fs.readFileSync(path.join(__dirname, '..', 'public', 'fragments', 'channel.html'), 'utf8');
@@ -83,7 +83,7 @@ assert.ok(/\.gl-chips \{ display: grid; grid-template-columns: repeat\(2, minmax
@@ -83,7 +83,7 @@ assert.ok(/\.gl-chips \{ display: grid; grid-template-columns: repeat\(2, minmax
assert.ok(panel.includes('<span class="gl-chip-k">Address</span><span class="gl-chip-v" id="gl-link">') && panel.includes('class="gl-chip gl-chip--link" onclick="_glCopyLink()"'), 'the address tile copies');
assert.ok(panel.includes('<span class="gl-chip-k">Address</span><span class="gl-chip-v" id="gl-link">') && panel.includes('class="gl-chip gl-chip--link" onclick="_glCopyLink()"'), 'the address tile copies');
assert.ok((panel.match(/class="gl-chip-i" aria-hidden="true"/g) || []).length === 4, 'each tile has its icon in a tile of its own');
assert.ok((panel.match(/class="gl-chip-i" aria-hidden="true"/g) || []).length === 4, 'each tile has its icon in a tile of its own');
// Suggest sits against the title with the network's glyph, not an emoji.
// Suggest sits against the title with the network's glyph, not an emoji.
-assert.ok(panel.includes('<i class="fa-solid fa-wand-magic-sparkles" aria-hidden="true"></i><span>Suggest</span>') && !/gl-suggest[\s\S]{0,200}\\u2728/.test(panel), 'Suggest wears the network glyph');
+assert.ok(panel.includes('<i class="fa-solid fa-sparkles" aria-hidden="true"></i><span>Suggest</span>') && !/gl-suggest[\s\S]{0,200}\\u2728/.test(panel), 'Suggest wears the network glyph');
assert.ok(/\.gl-title-row \.gl-title \{ flex: 0 1 auto; width: auto; max-width: 100%; field-sizing: content;/.test(css), 'the title is as wide as its words, so Suggest follows them');
assert.ok(/\.gl-title-row \.gl-title \{ flex: 0 1 auto; width: auto; max-width: 100%; field-sizing: content;/.test(css), 'the title is as wide as its words, so Suggest follows them');
// The head: Streamer setup at the row's end, and the line under it never leaves one word alone.
// The head: Streamer setup at the row's end, and the line under it never leaves one word alone.
assert.ok(frag.includes('<span class="gl-head-t">Go Live</span> <button type="button" class="glw-open-btn" onclick="openSetupHub()"'), 'the head');
assert.ok(frag.includes('<span class="gl-head-t">Go Live</span> <button type="button" class="glw-open-btn" onclick="openSetupHub()"'), 'the head');
@@ -102,8 +102,10 @@ console.log('OK H: overlay is a full independent viewer session, attached and de
@@ -102,8 +102,10 @@ console.log('OK H: overlay is a full independent viewer session, attached and de
assert.ok(/localStorage\.setItem\(LS_KEY\(o\.slot, o\.pip\.source_msid\)/.test(pip), 'the place persists per slot and per PIP');
assert.ok(/localStorage\.setItem\(LS_KEY\(o\.slot, o\.pip\.source_msid\)/.test(pip), 'the place persists per slot and per PIP');
assert.ok(/account\.put\(`\$\{o\.slot\}:\$\{o\.pip\.source_msid\}`, o\.geom\)/.test(pip), 'and in the account when signed in');
assert.ok(/account\.put\(`\$\{o\.slot\}:\$\{o\.pip\.source_msid\}`, o\.geom\)/.test(pip), 'and in the account when signed in');
assert.ok(/localStorage\.getItem\(LS_V1\(streamId, pip\.source_msid\)\)/.test(pip), 'a place saved per session before still counts');
assert.ok(/localStorage\.getItem\(LS_V1\(streamId, pip\.source_msid\)\)/.test(pip), 'a place saved per session before still counts');
-assert.ok(/data-act="untuck"/.test(pip) && read('public/css/features/pip.css').includes('.ov-pip.is-tucked .ov-pip-tab { display: grid; }'),
- 'a PIP tucked aside keeps its tab on screen to bring it back');
+assert.ok(/class="ov-pip-tab" data-ov-float-tab/.test(pip) && read('public/css/features/pip.css').includes('.ov-pip.is-tucked .ov-pip-tab { display: grid;'),
+ 'a PIP tucked aside keeps its handle on screen to bring it back');
+assert.ok(/enterpictureinpicture/.test(pip) && /leavepictureinpicture/.test(pip) && read('public/css/features/pip.css').includes('.ov-pip.is-popped {'),
+ 'popped out of the browser, the box in the page steps aside until that window closes');
assert.ok(/muted: pip\.muted !== false/.test(pip),
assert.ok(/muted: pip\.muted !== false/.test(pip),
'the overlay starts as the streamer chose, muted unless they said otherwise');
'the overlay starts as the streamer chose, muted unless they said otherwise');
assert.ok(/is-offline/.test(pip), 'a camera going offline must degrade to a badge, not a broken overlay');
assert.ok(/is-offline/.test(pip), 'a camera going offline must degrade to a badge, not a broken overlay');