openvibers/openvibe.live Commits

Home: featured live stream, rails that fill their rows, stats without the panel; voice chat rebuilt

OP OpenVibers committed 17 Sept 2026 · 945f4a3d3b · 21 files, +1073 −859

Home
- Featured live stream at the top whenever someone is live (features.json:
  featured, loaded only then): server-picked, rotating every 4 minutes,
  muted inline playback (FLV for RTMP, JSMPEG relay; live frames for
  WebRTC), the newest AI "right now" description, Watch & chat, Next, and
  an × that is remembered (Show featured stream in the Live Now header).
- Rails show whole rows: two rows first, "Load N more" completes the row
  and adds whole rows, and a column change fills the last row in.
- Stats board: no panel; the toggle is a card like the chips.
- Hero buttons size to their labels and wrap instead of truncating.
- Pitch: community-run / free speech / built-in-the-open copy and chips.
- "One stream. Everywhere." lists every way in (browser, screen, OBS/
  Streamlabs RTMP or WHIP, ffmpeg/scripts, robots, hardware encoders).
- Setup quest: a real wave surface instead of a rotating blob.
- Network hub grid no longer overflows a 390px phone (minmax(0, 1fr)).

Voice chat (from the audit)
- Only the newcomer offers; the other side answers. Candidates that
  arrive early are kept. One side restarts ICE. Join: ICE config cached
  and fetched alongside the microphone; no second getUserMedia for labels.
- Reconnect: detached handlers on the old socket, per-socket guards,
  fatal errors (channel gone after a restart, full, private, banned) end
  the call instead of looping; mute/camera state re-sent after a
  reconnect; welcome for the wrong channel refused; join sequence guards.
- Sidebar and tiles are keyed updates (no rebuild flicker, no lost
  clicks); the server pushes the channel list on every change (poll is
  now a 30s safety net); status pill connecting/connected/reconnecting;
  level-driven speaking rings (remote levels from the receiver, no
  analyser per peer); enter/leave transitions; peer menus work in
  voice channels.
- Server: private 1:1 calls hidden from strangers and invite-only,
  duplicates of the same account replaced, 3 sockets per address,
  kick cooldown, call-user rate limit and block check, respond requires
  a real invite, 200/s signaling limit, cosmetics cached, TURN never
  emitted without credentials (RTCPeerConnection threw), optional
  short-lived TURN credentials (TURN_AUTH_SECRET), one STUN server.
- Dead setup panel, initCallPanel, screen-share placeholder removed.

Tests: npm test 46/46; browser smoke; two-client voice test (join 1.0s,
second participant with media 2.1s, list push 3ms, no ghosts).

Changes

ddiff --git a/public/css/features/chatpage.css b/public/css/features/chatpage.css
iindex 0afb7eb9..73e22da4 100644
---- a/public/css/features/chatpage.css
++++ b/public/css/features/chatpage.css
@@@ -184,43 +184,6 @@
background-position: center;
flex-shrink: 0;
}
--.vc-setup-panel {
-- border-top: 1px solid var(--border);
-- padding: 16px;
-- background: var(--bg-primary);
-- flex-shrink: 0;
--}
--.vc-setup-title {
-- font-weight: 700;
-- font-size: 0.9rem;
-- color: var(--text-primary);
--}
--.vc-setup-avatar video.vc-preview-video {
-- width: 100%;
-- height: 100%;
-- object-fit: cover;
-- border-radius: 50%;
--}
--.vc-setup-avatar-img {
-- width: 100%;
-- height: 100%;
-- border-radius: 50%;
-- display: flex;
-- align-items: center;
-- justify-content: center;
-- font-size: 1.8rem;
-- font-weight: 700;
-- color: var(--text-muted);
-- background-size: cover;
-- background-position: center;
--}
--.vc-meter-bar {
-- height: 100%;
-- width: 0%;
-- background: linear-gradient(90deg, var(--success, #22c55e), var(--accent));
-- border-radius: 4px;
-- transition: width 0.06s linear;
--}
.vc-device-group {
display: flex;
flex-direction: column;
@@@ -231,9 +194,6 @@
width: 100%;
accent-color: var(--accent);
}
--.vc-test-btn {
-- flex: 0 0 auto;
--}
.vc-connected-panel {
border-top: 1px solid var(--border);
background: var(--bg-primary);
@@@ -358,13 +318,6 @@
.vc-sidebar-header {
padding: 10px 12px 8px;
}
-- .vc-setup-panel {
-- padding: 10px;
-- }
-- .vc-setup-avatar {
-- width: 56px;
-- height: 56px;
-- }
.vc-connected-header {
padding: 8px 10px;
}
@@@ -377,3 +330,14 @@
align-items: center;
gap: 8px;
}
++
++/* Channel rows arrive and leave with a short move; the joined row and speaking names stay clear. */
++.vc-channel-item.is-entering { animation: vcRowIn 200ms cubic-bezier(.2, .9, .3, 1); }
++.vc-channel-item.is-leaving { animation: vcRowOut 200ms ease-in forwards; }
++@keyframes vcRowIn { from { opacity: 0; transform: translateX(-6px); } }
++@keyframes vcRowOut { to { opacity: 0; transform: translateX(-6px); } }
++.vc-channel-item.is-private .vc-channel-icon { color: #f472b6; }
++.vc-participant-row.speaking .vc-avatar-mini { box-shadow: 0 0 0 2px rgba(74, 222, 128, 0.7); }
++.vc-connected-panel.is-open { animation: vcPanelIn 220ms cubic-bezier(.2, .9, .3, 1); }
++@keyframes vcPanelIn { from { opacity: 0; transform: translateY(6px); } }
++@media (prefers-reduced-motion: reduce) { .vc-channel-item.is-entering, .vc-channel-item.is-leaving, .vc-connected-panel.is-open { animation: none; } }
ddiff --git a/public/css/features/featured.css b/public/css/features/featured.css
nnew file mode 100644
iindex 00000000..bd8caf15
---- /dev/null
++++ b/public/css/features/featured.css
@@@ -0,0 +1,93 @@
++/* Featured live stream on the home page (public/js/home-featured.js). */
++.home-featured { margin: 6px 0 22px; }
++.home-featured[hidden] { display: none; }
++.hf {
++ position: relative; display: grid; grid-template-columns: minmax(0, 1.6fr) minmax(280px, 1fr); gap: 0;
++ border-radius: 20px; overflow: hidden; isolation: isolate;
++ background: color-mix(in srgb, var(--bg-card) 92%, transparent);
++ border: 1px solid color-mix(in srgb, #ef4444 26%, var(--border));
++ box-shadow: 0 24px 60px -30px rgba(0, 0, 0, 0.8);
++ animation: hfIn 320ms cubic-bezier(.2, .9, .3, 1);
++}
++@keyframes hfIn { from { opacity: 0; transform: translateY(8px); } }
++.hf-media { position: relative; aspect-ratio: 16 / 9; background: #000; overflow: hidden; }
++.hf-media video, .hf-media canvas, .hf-media img.hf-frame { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; background: #000; }
++.hf-media img.hf-frame { object-fit: cover; transition: opacity 0.5s; }
++.hf-media.is-switching video, .hf-media.is-switching canvas, .hf-media.is-switching img.hf-frame { opacity: 0; }
++.hf-media video, .hf-media canvas { transition: opacity 0.4s; }
++.hf-poster { position: absolute; inset: 0; background-size: cover; background-position: center; filter: blur(14px) saturate(1.2); transform: scale(1.1); opacity: 0.55; }
++.hf-live {
++ position: absolute; top: 12px; left: 12px; z-index: 2; display: inline-flex; align-items: center; gap: 6px;
++ padding: 4px 10px; border-radius: 999px; font-size: 0.7rem; font-weight: 800; letter-spacing: 0.08em; color: #fff;
++ background: #ef4444; box-shadow: 0 4px 14px rgba(239, 68, 68, 0.45);
++}
++.hf-live i { font-size: 0.5rem; animation: hfBlink 1.6s ease-in-out infinite; }
++@keyframes hfBlink { 50% { opacity: 0.35; } }
++.hf-viewers { position: absolute; top: 12px; right: 12px; z-index: 2; display: inline-flex; align-items: center; gap: 5px; padding: 4px 9px; border-radius: 999px; font-size: 0.72rem; color: #fff; background: rgba(0, 0, 0, 0.55); backdrop-filter: blur(6px); }
++.hf-sound {
++ position: absolute; bottom: 12px; left: 12px; z-index: 2; display: inline-flex; align-items: center; gap: 7px;
++ padding: 7px 12px; border-radius: 999px; border: 1px solid rgba(255, 255, 255, 0.25); cursor: pointer;
++ background: rgba(0, 0, 0, 0.6); color: #fff; font: inherit; font-size: 0.78rem; font-weight: 600; backdrop-filter: blur(6px);
++ transition: background 0.15s, transform 0.15s;
++}
++.hf-sound:hover { background: rgba(0, 0, 0, 0.8); transform: translateY(-1px); }
++.hf-preview-note { position: absolute; bottom: 12px; right: 12px; z-index: 2; padding: 4px 9px; border-radius: 999px; font-size: 0.68rem; color: #ddd; background: rgba(0, 0, 0, 0.5); }
++.hf-media-link { position: absolute; inset: 0; z-index: 1; }
++
++.hf-panel { display: flex; flex-direction: column; gap: 10px; padding: 18px 18px 16px; min-width: 0; }
++.hf-kicker { display: flex; align-items: center; justify-content: space-between; gap: 8px; font-size: 0.66rem; font-weight: 800; letter-spacing: 0.1em; text-transform: uppercase; color: var(--text-muted); }
++.hf-kicker b { color: #ef4444; display: inline-flex; align-items: center; gap: 6px; }
++.hf-hide { background: none; border: 0; padding: 4px 6px; color: var(--text-muted); cursor: pointer; border-radius: 8px; font-size: 0.8rem; line-height: 1; }
++.hf-hide:hover { color: var(--text-primary); background: color-mix(in srgb, var(--text-primary) 8%, transparent); }
++.hf-title { margin: 0; font-size: clamp(1.05rem, 1.8vw, 1.35rem); font-weight: 800; line-height: 1.2; letter-spacing: -0.01em; color: var(--text-primary); overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
++.hf-who { display: flex; align-items: center; gap: 8px; font-size: 0.86rem; color: var(--text-secondary); text-decoration: none; min-width: 0; }
++.hf-who:hover { color: var(--text-primary); }
++.hf-who b { color: var(--text-primary); }
++.hf-who .muted { margin-left: auto; font-size: 0.74rem; white-space: nowrap; }
++.hf-tag { font-size: 0.68rem; padding: 2px 8px; border-radius: 999px; background: color-mix(in srgb, var(--accent) 14%, transparent); color: var(--accent-light); }
++.hf-now {
++ position: relative; padding: 10px 12px 10px 14px; border-radius: 12px; font-size: 0.84rem; line-height: 1.5; color: var(--text-secondary);
++ background: color-mix(in srgb, #22d3ee 7%, transparent); border: 1px solid color-mix(in srgb, #22d3ee 22%, transparent);
++ transition: opacity 0.3s;
++}
++.hf-now::before { content: ''; position: absolute; left: 0; top: 10px; bottom: 10px; width: 3px; border-radius: 3px; background: #22d3ee; }
++.hf-now-head { display: flex; align-items: center; gap: 6px; margin-bottom: 3px; font-size: 0.66rem; font-weight: 800; letter-spacing: 0.08em; text-transform: uppercase; color: #22d3ee; }
++.hf-now-head span { margin-left: auto; font-weight: 600; letter-spacing: 0; text-transform: none; color: var(--text-muted); }
++.hf-now.is-updating { opacity: 0.4; }
++.hf-overview { font-size: 0.8rem; color: var(--text-muted); line-height: 1.45; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; }
++.hf-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: auto; padding-top: 4px; }
++.hf-watch {
++ display: inline-flex; align-items: center; gap: 8px; padding: 10px 16px; border-radius: 12px; text-decoration: none;
++ color: #fff; font-weight: 700; font-size: 0.9rem;
++ background: linear-gradient(160deg, color-mix(in srgb, var(--accent) 85%, #000), color-mix(in srgb, var(--accent) 60%, #000));
++ border: 1px solid color-mix(in srgb, var(--accent) 60%, transparent);
++ box-shadow: 0 8px 22px -10px color-mix(in srgb, var(--accent) 80%, transparent);
++ transition: transform 0.18s cubic-bezier(.2, 1.3, .3, 1), box-shadow 0.2s;
++}
++.hf-watch:hover { transform: translateY(-2px); box-shadow: 0 14px 30px -12px color-mix(in srgb, var(--accent) 90%, transparent); }
++.hf-next {
++ display: inline-flex; align-items: center; gap: 6px; padding: 9px 12px; border-radius: 12px; cursor: pointer; font: inherit; font-size: 0.82rem; font-weight: 600;
++ color: var(--text-secondary); background: transparent; border: 1px solid var(--border); transition: color 0.15s, border-color 0.15s;
++}
++.hf-next:hover { color: var(--text-primary); border-color: color-mix(in srgb, var(--accent) 50%, var(--border)); }
++.hf-next small { color: var(--text-muted); font-weight: 500; }
++.hf-chat { display: flex; align-items: center; gap: 6px; font-size: 0.74rem; color: var(--text-muted); }
++.hf-chat i { color: var(--accent); }
++
++/* The Live Now header gets a small way back in once the featured stream is hidden. */
++.home-featured-toggle {
++ display: inline-flex; align-items: center; gap: 6px; margin-left: 8px; padding: 4px 10px; border-radius: 999px; cursor: pointer;
++ font: inherit; font-size: 0.72rem; color: var(--text-muted); background: transparent; border: 1px dashed color-mix(in srgb, var(--text-primary) 18%, transparent);
++}
++.home-featured-toggle:hover { color: var(--text-primary); border-color: var(--accent); }
++.home-featured-toggle[hidden] { display: none; }
++
++@media (max-width: 860px) {
++ .hf { grid-template-columns: 1fr; }
++ .hf-panel { padding: 14px 14px 14px; }
++ .hf-overview { display: none; }
++}
++@media (prefers-reduced-motion: reduce) {
++ .hf, .hf-live i { animation: none; }
++ .hf-media video, .hf-media canvas, .hf-media img.hf-frame, .hf-now { transition: none; }
++}
ddiff --git a/public/css/features/home.css b/public/css/features/home.css
iindex 9edba8f0..6b3639cc 100644
---- a/public/css/features/home.css
++++ b/public/css/features/home.css
@@@ -81,7 +81,9 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
}
.network-hub-grid {
display: grid;
-- grid-template-columns: repeat(3, 1fr);
++ /* minmax(0, 1fr): a plain 1fr floors at the card's min-content, and a nowrap description made
++ two columns wider than a 390px phone (the third card was cut off at the edge). */
++ grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
max-width: 960px; margin: 0 auto;
}
@@@ -112,15 +114,15 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
.network-card:hover .network-card-arrow { opacity: 1; transform: translateX(3px); }
@media (max-width: 960px) {
-- .network-hub-grid { grid-template-columns: repeat(3, 1fr); gap: 10px; }
++ .network-hub-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; }
}
@media (max-width: 768px) {
-- .network-hub-grid { grid-template-columns: 1fr 1fr; gap: 10px; }
++ .network-hub-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
.network-hub { padding: 24px 16px; }
.network-hub-header { flex-direction: column; gap: 8px; }
}
@media (max-width: 480px) {
-- .network-hub-grid { grid-template-columns: 1fr 1fr; gap: 8px; }
++ .network-hub-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
.network-hub { padding: 20px 12px; }
.network-card { padding: 12px; gap: 10px; }
.network-card-icon { width: 36px; height: 36px; font-size: 1rem; border-radius: 8px; }
@@@ -203,10 +205,11 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
.hero-philosophy-links { margin-top: 24px; }
.hero-stat-group {
display: grid; grid-template-columns: 118px minmax(0, 1fr); gap: 12px; align-items: center;
-- padding: 11px 14px 11px 0;
-- border-top: 1px solid color-mix(in srgb, var(--accent) 12%, transparent);
++ padding: 11px 14px 11px 0; border-radius: 16px;
++ background: color-mix(in srgb, var(--bg-secondary) 55%, transparent);
++ border: 1px solid color-mix(in srgb, var(--accent) 14%, transparent);
}
--.hero-stat-group:first-child { border-top: 0; }
++.hero-stat-group + .hero-stat-group { margin-top: 8px; }
.hero-stat-kicker {
display: flex; align-items: center; gap: 7px; padding-left: 16px; align-self: stretch;
border-left: 3px solid color-mix(in srgb, var(--accent) 55%, transparent);
@@@ -512,7 +515,7 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
.hero-stat-strip[hidden] { display: none !important; }
.hero-stat-strip {
display: grid; grid-template-columns: repeat(3, minmax(0, 1fr));
-- gap: clamp(4px, 0.8vw, 9px); padding: clamp(7px, 1vw, 11px);
++ gap: clamp(6px, 0.9vw, 10px); padding: 0;
max-width: 940px; margin: 0 auto; width: 100%;
}
.hero-stat-strip .hero-stat { min-width: 0; padding: clamp(6px, 1.1vw, 10px) clamp(7px, 1.2vw, 12px); gap: clamp(5px, 0.9vw, 10px); }
@@@ -530,13 +533,17 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
}
.hero-stat-strip .hero-stat { background: color-mix(in srgb, var(--accent) 7%, transparent); }
.hero-stat-more {
-- display: flex; align-items: center; justify-content: center; gap: 8px; width: 100%;
-- padding: 8px 12px 9px; cursor: pointer; font: inherit;
++ display: inline-flex; align-items: center; justify-content: center; gap: 9px; width: auto;
++ margin: 0 auto; padding: 8px 16px 8px 9px; cursor: pointer; font: inherit; border-radius: 13px;
font-size: 0.76rem; font-weight: 700; letter-spacing: 0.02em;
color: var(--text-primary);
-- background: none; border: 0; border-top: 1px solid color-mix(in srgb, var(--accent) 12%, transparent);
-- transition: color 0.2s, background 0.2s;
++ /* The same card as the chips above, so it belongs to the row instead of underlining a panel. */
++ background: color-mix(in srgb, var(--bg-secondary) 78%, transparent);
++ border: 1px solid color-mix(in srgb, var(--accent) 24%, transparent); backdrop-filter: blur(8px);
++ box-shadow: 0 6px 20px -12px rgba(0, 0, 0, 0.7);
++ transition: color 0.2s, background 0.2s, border-color 0.2s, transform 0.2s;
}
++.hero-stat-more:hover { border-color: color-mix(in srgb, var(--accent) 55%, transparent); transform: translateY(-1px); }
/* The stats are something to be proud of — the toggle reads as an invitation, not a footnote. */
.hero-stat-more-icon {
display: inline-grid; place-items: center; width: 22px; height: 22px; border-radius: 7px;
@@@ -547,14 +554,12 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
.hero-stat-more-icon i { font-size: 0.7rem; }
.hero-stat-more-text { background: linear-gradient(90deg, var(--text-primary), color-mix(in srgb, var(--accent) 60%, var(--text-primary))); -webkit-background-clip: text; background-clip: text; color: transparent; }
.hero-stat-more-chev { color: var(--text-muted); }
--.hero-stat-more:hover { background: color-mix(in srgb, var(--accent) 8%, transparent); }
.hero-stat-more:hover .hero-stat-more-icon { box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent) 22%, transparent); transform: rotate(-6deg) scale(1.06); }
.hero-stat-more.is-open .hero-stat-more-chev { transform: rotate(180deg); }
/* Every number opens its history: say so without a hover (touch screens have none). */
.hero-stat--clickable { cursor: pointer; }
@media (hover: none) { .hero-stat-chart-hint { opacity: 0.55; } }
--.hero-stat-full { border-top: 1px solid color-mix(in srgb, var(--accent) 12%, transparent); }
--.hero-stat-full .hero-stat-group:first-child { border-top: 0; }
++.hero-stat-full { border: 0; }
@media (max-width: 640px) {
.hero-stats { min-height: 182px; }
}
@@@ -670,7 +675,7 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
.hero-stat-strip { grid-auto-rows: 1fr; align-items: stretch; }
.hero-stat--spark { display: flex; }
.hero-stats.is-swapping { backdrop-filter: none !important; -webkit-backdrop-filter: none !important; }
--.hero-stats.is-swapping { background: color-mix(in srgb, var(--bg-primary) 88%, transparent); }
++.hero-stats.is-swapping { background: none; }
@media (max-width: 900px) {
.hero-stats {
backdrop-filter: none; -webkit-backdrop-filter: none;
@@@ -978,3 +983,13 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
.star-card.is-swapping { transition: none; }
.star-card.is-compact .star-avatar-ring { animation: none; }
}
++
++/* The pitch banner: what kind of place this is, before the headline says it. */
++.cta-chips { display: flex; flex-wrap: wrap; justify-content: center; gap: 8px; margin: 0 0 16px; }
++.cta-chip {
++ display: inline-flex; align-items: center; gap: 7px; padding: 5px 12px; border-radius: 999px;
++ font-size: 0.74rem; font-weight: 700; letter-spacing: 0.02em; color: var(--text-secondary);
++ background: color-mix(in srgb, var(--accent) 10%, transparent); border: 1px solid color-mix(in srgb, var(--accent) 30%, transparent);
++}
++.cta-chip i { color: var(--accent-light); }
++.cta-hero { text-shadow: 0 6px 30px color-mix(in srgb, var(--accent) 35%, transparent); }
ddiff --git a/public/css/home-fx.css b/public/css/home-fx.css
iindex 859fa1a4..67a4f8ba 100644
---- a/public/css/home-fx.css
++++ b/public/css/home-fx.css
@@@ -20,20 +20,19 @@
/* Six buttons may only lay out as 6×1, 3×2 or 2×3 — the divisors of six. auto-fit chose however
many max-content columns happened to fit, which is how the row came out as four and then two.
The column count is picked explicitly per width, and every column is the same width. */
++/* Equal columns cut labels short ("GitH…", "Disc…") whenever the row was narrower than six full
++ labels. The row now sizes to its content and wraps: a button is never narrower than its own
++ label, and the row centres whatever fits per line. */
#page-home .hero-cta {
-- --hb-cols: 3;
-- display: grid; gap: clamp(8px, 1.4vw, 12px); margin-inline: auto;
-- grid-template-columns: repeat(var(--hb-cols), minmax(0, 1fr));
-- width: 100%; max-width: 720px;
++ display: flex; flex-wrap: wrap; justify-content: center; align-items: stretch;
++ gap: clamp(8px, 1.4vw, 12px); margin-inline: auto;
++ width: 100%; max-width: min(1180px, 96vw);
}
/* Six across only when the hero column is genuinely wide enough for full labels. The hero content
is capped well under the viewport, so a viewport breakpoint alone gave 133px buttons at 1280px
with every label cut to an ellipsis. */
--@media (min-width: 1500px) {
-- #page-home .hero-cta { --hb-cols: 6; max-width: 1180px; }
--}
--#page-home .hero-cta > .hb { min-width: 0; }
--#page-home .hero-cta .hb-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
++#page-home .hero-cta > .hb { flex: 0 1 auto; width: auto; min-width: max-content; }
++#page-home .hero-cta .hb-label { white-space: nowrap; }
/* The "API docs" aside only fits beside "Build & Mod" when the buttons are wide; everywhere else it
is what pushes the label into an ellipsis. The tooltip still says it. */
#page-home .hero-cta .hb-label small { display: none; }
@@@ -119,14 +118,16 @@
.hb-tools { --c: #2dd4bf; } .hb-game { --c: #4ade80; } .hb-github { --c: #e5e7eb; } .hb-discord { --c: #5865f2; } .hb-vibe { --c: #f472b6; }
/* Narrow phones: two tidy columns instead of a ragged wrap. Landscape keeps a single row. */
@media (max-width: 560px) {
-- #page-home .hero-cta { --hb-cols: 2; max-width: 420px; }
++ #page-home .hero-cta { max-width: 420px; }
++ /* Two per line on a phone, still never narrower than the label. */
++ #page-home .hero-cta > .hb { flex: 1 1 calc(50% - 12px); justify-content: flex-start; }
.hb { font-size: 0.86rem; padding-right: 12px; }
/* The "API docs" note is what would push "Build & Mod" to an ellipsis at two columns. */
.hb-label small { display: none; }
}
/* A landscape phone is wide but short: three across keeps it to two rows, never a ragged wrap. */
@media (max-width: 900px) and (orientation: landscape) and (max-height: 500px) {
-- #page-home .hero-cta { --hb-cols: 3; max-width: 96vw; }
++ #page-home .hero-cta { max-width: 96vw; }
.hb { min-height: 40px; }
}
@keyframes hfxPulse { 0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 55%, transparent); } 70% { box-shadow: 0 0 0 14px color-mix(in srgb, var(--accent) 0%, transparent); } 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 0%, transparent); } }
@@@ -283,7 +284,7 @@
#page-home .hero-float-badge {
backdrop-filter: none !important; -webkit-backdrop-filter: none !important;
}
--#page-home .hero-stats { background: color-mix(in srgb, var(--bg-primary) 84%, transparent); }
++#page-home .hero-stats { background: none; } /* the chips are the cards; no panel behind them */
#page-home .hero-stat { background: color-mix(in srgb, var(--bg-secondary) 90%, transparent); }
#page-home .hero-latest { background: color-mix(in srgb, var(--bg-secondary) 88%, transparent); }
ddiff --git a/public/css/home-tour.css b/public/css/home-tour.css
iindex bad31fcf..a7582e6c 100644
---- a/public/css/home-tour.css
++++ b/public/css/home-tour.css
@@@ -178,17 +178,17 @@
transition: height 1.8s cubic-bezier(.32, 1, .38, 1);
background: linear-gradient(180deg, color-mix(in srgb, var(--q) 20%, transparent), color-mix(in srgb, var(--q) 9%, transparent));
}
--/* A big, almost-round blob whose top edge sits just above the water line. Rotating it makes that
-- edge rise and fall — a crest, with no SVG and no script. */
++/* The surface: two strips of a repeating wave, masked out of the water colour, drifting sideways
++ at different speeds. One wavelength of travel per loop, so the motion never skips. */
.ovg-quest-wave {
-- position: absolute; left: 50%; margin-left: -85%; top: -4px; width: 170%; aspect-ratio: 1;
-- border-radius: 43% 47% 44% 46%;
-- background: color-mix(in srgb, var(--q) 24%, transparent);
-- box-shadow: 0 -1px 0 color-mix(in srgb, var(--q) 55%, #fff 25%);
-- animation: questWave 9s linear infinite;
--}
--.ovg-quest-wave--b { top: -9px; opacity: 0.45; box-shadow: none; border-radius: 47% 42% 46% 44%; animation-duration: 13.5s; animation-direction: reverse; }
--@keyframes questWave { to { transform: rotate(360deg); } }
++ --wl: 84px; position: absolute; left: 0; top: -8px; width: calc(100% + var(--wl)); height: 16px;
++ background: linear-gradient(180deg, color-mix(in srgb, var(--q) 34%, #fff 14%), color-mix(in srgb, var(--q) 22%, transparent));
++ -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 84 16' preserveAspectRatio='none'%3E%3Cpath d='M0 9 C14 3 28 3 42 9 S70 15 84 9 V16 H0 Z'/%3E%3C/svg%3E") repeat-x left top / var(--wl) 16px;
++ mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 84 16' preserveAspectRatio='none'%3E%3Cpath d='M0 9 C14 3 28 3 42 9 S70 15 84 9 V16 H0 Z'/%3E%3C/svg%3E") repeat-x left top / var(--wl) 16px;
++ animation: questWave 4.5s linear infinite;
++}
++.ovg-quest-wave--b { --wl: 118px; top: -11px; opacity: 0.45; animation-duration: 7s; animation-direction: reverse; }
++@keyframes questWave { to { transform: translateX(calc(var(--wl) * -1)); } }
.ovg-quest-bub {
position: absolute; left: var(--x); bottom: -6px; width: 5px; height: 5px; border-radius: 50%;
background: color-mix(in srgb, var(--q) 60%, #fff 25%); opacity: 0;
ddiff --git a/public/css/style.css b/public/css/style.css
iindex ef7229f4..5556268f 100644
---- a/public/css/style.css
++++ b/public/css/style.css
@@@ -2027,16 +2027,13 @@ main#app { position: relative; z-index: 1; }
the same chip column grid so chips line up top-to-bottom and each row spans the
full board width — no more differently-sized islands stacked down the middle. */
.hero-stats {
-- display: flex; flex-direction: column; margin: 4px 0 26px;
++ display: flex; flex-direction: column; gap: 10px; margin: 4px 0 26px;
/* Break out of the 860px hero text column: a stat board wants dashboard width. */
width: min(1240px, 94vw); position: relative; left: 50%; transform: translateX(-50%);
-- border-radius: 18px; overflow: hidden;
-- background: color-mix(in srgb, var(--bg-primary) 58%, transparent);
-- border: 1px solid color-mix(in srgb, var(--accent) 18%, transparent);
-- box-shadow: 0 18px 50px -30px rgba(0, 0, 0, 0.9);
-- backdrop-filter: blur(10px); text-align: left;
++ /* No panel: the chips are the cards. A big translucent rectangle behind them only boxed the
++ hero in and fought the background art. */
++ background: none; border: 0; box-shadow: none; text-align: left;
}
--.hero-stat-group:nth-child(even) { background: color-mix(in srgb, var(--accent) 4%, transparent); }
.hero-stat-kicker i { color: var(--accent); font-size: 0.8rem; opacity: 0.9; }
/* auto-fit: a row with fewer chips stretches them to the board edge instead of
leaving a ragged gap — every row reads as one full-width band. */
@@@ -6502,13 +6499,28 @@ body.theme-light .user-dropdown {
border: 3px solid transparent;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
--.call-avatar.speaking {
-- border-color: #22c55e;
-- box-shadow: 0 0 0 2px rgba(34,197,94,0.45), 0 0 12px rgba(34,197,94,0.3);
--}
--.call-participant-tile.is-speaking .call-avatar {
-- border-color: #22c55e;
-- box-shadow: 0 0 0 2px rgba(34,197,94,0.45), 0 0 12px rgba(34,197,94,0.3);
++.call-avatar.speaking, .call-participant-tile.is-speaking .call-avatar { border-color: #22c55e; }
++/* The ring around a speaker follows their audio level (--lvl, 0–1, set by call.js) with a transform
++ and an opacity — nothing that repaints the tile. */
++.call-participant-tile { --lvl: 0; }
++.call-avatar { position: relative; }
++.call-avatar::after {
++ content: ''; position: absolute; inset: -6px; border-radius: 50%; pointer-events: none;
++ border: 2px solid rgba(34, 197, 94, 0.9); box-shadow: 0 0 14px rgba(34, 197, 94, 0.45);
++ opacity: 0; transform: scale(0.9);
++ transition: transform 90ms linear, opacity 120ms linear;
++}
++.call-participant-tile.is-speaking .call-avatar::after { opacity: calc(0.35 + var(--lvl) * 0.65); transform: scale(calc(1 + var(--lvl) * 0.18)); }
++.call-participant-tile.is-connecting { opacity: 0.6; }
++.call-participant-tile.is-connecting .call-avatar { animation: callTileConnecting 1.4s ease-in-out infinite; }
++@keyframes callTileConnecting { 50% { opacity: 0.45; } }
++.call-participant-tile.is-entering { animation: callTileIn 220ms cubic-bezier(.2, .9, .3, 1.2); }
++.call-participant-tile.is-leaving { animation: callTileOut 240ms ease-in forwards; pointer-events: none; }
++@keyframes callTileIn { from { opacity: 0; transform: scale(0.86); } }
++@keyframes callTileOut { to { opacity: 0; transform: scale(0.86); } }
++@media (prefers-reduced-motion: reduce) {
++ .call-participant-tile.is-entering, .call-participant-tile.is-leaving, .call-participant-tile.is-connecting .call-avatar, .vc-connected-badge i { animation: none !important; }
++ .call-avatar::after { transition: none; }
}
/* Video (camera on) */
@@@ -7927,123 +7939,16 @@ body.theme-light .user-dropdown {
/* Small avatar used in participant rows */
--/* ── Setup Panel ───────────────────────────────────────────── */
--
--.vc-setup-header {
-- display: flex;
-- align-items: center;
-- justify-content: space-between;
-- margin-bottom: 14px;
--}
--.vc-setup-close {
-- background: none;
-- border: none;
-- color: var(--text-muted);
-- cursor: pointer;
-- font-size: 1rem;
-- padding: 2px 6px;
-- border-radius: var(--radius-sm);
-- transition: color 0.15s, background 0.15s;
--}
--.vc-setup-close:hover {
-- color: var(--text-primary);
-- background: var(--bg-tertiary);
--}
--
--.vc-setup-preview {
-- display: flex;
-- align-items: center;
-- gap: 14px;
-- margin-bottom: 14px;
--}
--
--.vc-setup-avatar {
-- position: relative;
-- width: 72px;
-- height: 72px;
-- border-radius: 50%;
-- background: var(--bg-tertiary);
-- overflow: hidden;
-- display: flex;
-- align-items: center;
-- justify-content: center;
-- flex-shrink: 0;
--}
--
--.vc-mic-level {
-- position: absolute;
-- inset: 0;
-- border-radius: 50%;
-- border: 3px solid transparent;
-- transition: border-color 0.1s;
-- pointer-events: none;
--}
--.vc-mic-level.active {
-- border-color: var(--success, #22c55e);
-- box-shadow: 0 0 10px rgba(34, 197, 94, 0.3);
--}
--
--.vc-setup-meter {
-- flex: 1;
-- min-width: 0;
--}
--.vc-setup-meter {
-- height: 8px;
-- background: var(--bg-tertiary);
-- border-radius: 4px;
-- overflow: hidden;
--}
--
--/* Device select groups */
--.vc-setup-devices,
--.vc-setup-input-mode {
-- display: flex;
-- flex-direction: column;
-- gap: 10px;
-- margin-bottom: 14px;
--}
--.vc-device-group label {
-- font-size: 0.75rem;
-- font-weight: 600;
-- color: var(--text-muted);
-- display: flex;
-- align-items: center;
-- gap: 6px;
--}
--.vc-device-group label i {
-- font-size: 0.7rem;
--}
--.vc-device-group select,
--.vc-device-group input[type="text"] {
-- background: var(--bg-tertiary);
-- color: var(--text-primary);
-- border: 1px solid var(--border);
-- border-radius: var(--radius-sm);
-- padding: 6px 10px;
-- font-size: 0.82rem;
--}
--.vc-device-group span {
-- font-size: 0.75rem;
-- color: var(--text-muted);
-- text-align: right;
--}
--
--.vc-setup-actions {
-- display: flex;
-- gap: 8px;
--}
--.vc-join-btn {
-- flex: 1;
--}
--
/* ── Connected Panel ───────────────────────────────────────── */
--.vc-connected-badge i {
-- font-size: 0.5rem;
-- animation: vc-pulse 2s infinite;
--}
++.vc-connected-badge i { font-size: 0.5rem; }
++/* Only while the connection is in flux does the dot move; a settled call has nothing to pulse about. */
++.vc-connected-badge.is-connecting i, .vc-connected-badge.is-reconnecting i { animation: vc-pulse 1.2s infinite; }
++.vc-connected-badge.is-connecting { color: var(--text-muted); }
++.vc-connected-badge.is-reconnecting { color: #f59e0b; }
++.vc-connected-badge.is-connected i { color: #22c55e; }
@keyframes vc-pulse {
0%, 100% { opacity: 1; }
-- 50% { opacity: 0.4; }
++ 50% { opacity: 0.35; }
}
/* Participant grid */
@@@ -8759,11 +8664,6 @@ body.theme-light .user-dropdown {
font-size: 0.5rem;
color: #22c55e;
margin-right: 4px;
-- animation: vc-mini-pulse 2s infinite;
--}
--@keyframes vc-mini-pulse {
-- 0%, 100% { opacity: 1; }
-- 50% { opacity: 0.4; }
}
.vc-mini-status { font-weight: 600; white-space: nowrap; }
.vc-mini-channel { color: var(--accent, #6366f1); font-weight: 500; white-space: nowrap; }
ddiff --git a/public/features.json b/public/features.json
iindex 018c6bc2..8ad1203e 100644
---- a/public/features.json
++++ b/public/features.json
@@@ -125,7 +125,6 @@
"vcCreateChannel",
"vcToggleMute",
"vcToggleCamera",
-- "vcToggleScreenShare",
"vcToggleSettings",
"vcLeave",
"vcSwitchMic",
@@@ -263,6 +262,14 @@
"stubs": [
"openStatsNerds"
]
++ },
++ "featured": {
++ "css": [
++ "/css/features/featured.css"
++ ],
++ "js": [
++ "/js/home-featured.js"
++ ]
}
},
"routes": [
ddiff --git a/public/fragments/chat.html b/public/fragments/chat.html
iindex 982ba085..a7aafa64 100644
---- a/public/fragments/chat.html
++++ b/public/fragments/chat.html
@@@ -37,9 +37,6 @@
<button class="vc-ctrl-btn" id="vc-btn-camera" onclick="vcToggleCamera()" title="Toggle Camera">
<i class="fa-solid fa-video"></i>
</button>
-- <button class="vc-ctrl-btn" id="vc-btn-screen" onclick="vcToggleScreenShare()" title="Share Screen" style="display:none">
-- <i class="fa-solid fa-display"></i>
-- </button>
<button class="vc-ctrl-btn vc-ctrl-settings" id="vc-btn-settings" onclick="vcToggleSettings()" title="Settings">
<i class="fa-solid fa-gear"></i>
</button>
@@@ -214,7 +211,7 @@
</div>
<div class="form-group">
<label>Max Participants</label>
-- <input type="number" id="vc-create-max" class="form-input" value="15" min="2" max="15">
++ <input type="number" id="vc-create-max" class="form-input" value="8" min="2" max="8">
</div>
</div>
<div class="modal-footer">
ddiff --git a/public/index.html b/public/index.html
iindex 49d39d5e..6b928334 100644
---- a/public/index.html
++++ b/public/index.html
@@@ -358,10 +358,13 @@
</div>
<div class="container">
++ <!-- Featured live stream (public/js/home-featured.js): filled only when someone is live. -->
++ <div class="home-featured" id="home-featured" hidden></div>
<!-- Live Streams -->
<div class="section-header" style="--sc:#ef4444">
<h2><i class="fa-solid fa-circle live-dot"></i> Live Now<small class="section-kicker">on air</small></h2>
<span id="live-count" class="badge">0</span>
++ <button type="button" class="home-featured-toggle" id="home-featured-toggle" hidden><i class="fa-solid fa-satellite-dish"></i> Show featured stream</button>
</div>
<div class="stream-grid" id="stream-grid-live">
<div class="ovsk ovsk-card" aria-hidden="true"><div class="ovsk-thumb"></div><div class="ovsk-meta"><div class="ovsk-bar ovsk-av"></div><div class="ovsk-lines"><div class="ovsk-bar" style="height:11px;width:72%"></div><div class="ovsk-bar" style="height:9px;width:45%"></div></div></div></div>
@@@ -454,9 +457,14 @@
STREAM SITE") was a louder restatement of the lede two lines below it, which
already says the platform is built by one person who streams here. One of them
is enough, and the lede is the one that reads like a human wrote it. -->
++ <div class="cta-chips" aria-hidden="true">
++ <span class="cta-chip"><i class="fa-solid fa-people-group"></i> Community-run</span>
++ <span class="cta-chip"><i class="fa-solid fa-comment"></i> Free speech, light rules</span>
++ <span class="cta-chip"><i class="fa-solid fa-code-branch"></i> Built in the open</span>
++ </div>
<h2 class="cta-hero">Streaming the way it <span class="cta-hero-grad">should</span> be.</h2>
<p class="cta-lede">
-- OpenVibe.Live is a living platform built by <strong>one person who actually streams here</strong>, hangs out in chat, and ships your ideas. No boardroom, no algorithm games, no mystery bans — just an <strong>epic</strong> streaming stack packed with things the big sites flat-out don't have.
++ OpenVibe.Live is an <strong>open, community-run</strong> streaming platform. It's built here, in public, by people who actually stream and hang out in chat — and the ideas that come up in chat are the ones that get shipped. Say what you think: <strong>free speech, light-touch moderation, no mystery bans</strong>. Underneath it all is a streaming stack with things the big sites just don't have.
</p>
<button class="home-about-toggle" type="button" onclick="toggleHomeAbout()" aria-expanded="false" aria-controls="home-about-body">
ddiff --git a/public/js/app-home.js b/public/js/app-home.js
iindex 198f271d..e2fb340b 100644
---- a/public/js/app-home.js
++++ b/public/js/app-home.js
@@@ -1176,11 +1176,8 @@ async function loadHome() {
setTimeout(() => OVSkeleton.clearAll(), 9000);
}
-- // Reset homepage pagination on fresh load
-- _homeRecentOnlinePage = 1;
-- _homeRecentVodsPage = 1;
-- _homeClipsPage = 1;
-- _homePastesPage = 1;
++ // Reset homepage rails on fresh load
++ homeRailReset();
// Deliberately NOT awaited. Every other section used to sit behind this one request, so a
// slow /streams held up the clips, VODs, pastes, digest and star rails even though none of
@@@ -1195,6 +1192,7 @@ async function loadHome() {
const noLiveEl = document.getElementById('no-live-streams');
if (noLiveEl) noLiveEl.style.display = streams.length ? 'none' : '';
renderStreamGrid('stream-grid-live', streams, true);
++ _homeFeaturedSync(streams.length > 0);
}, { ttl: 20000 });
} catch (e) {
console.error('Failed to load live streams', e);
@@@ -1338,9 +1336,8 @@ async function loadHomeDigest() {
box.style.display = '';
}
--async function loadHomeRecentOnline(page, append) {
-- if (page !== undefined) _homeRecentOnlinePage = page;
-- const { offset, limit } = homePageRange(_homeRecentOnlinePage, HOME_RECENT_ONLINE_PAGE_SIZE);
++async function loadHomeRecentOnline(opts) {
++ const { offset, limit, append, rail } = homeRailRange('recent', opts, 'stream-grid-recent', 'loadHomeRecentOnline');
try {
const recentData = await api(`/streams/recently-online?limit=${limit}&offset=${offset}`);
renderRecentlyOnline('stream-grid-recent', recentData.streamers || [], append);
@@@ -1351,8 +1348,9 @@ async function loadHomeRecentOnline(page, append) {
OVDensity.attach(document.getElementById('stream-grid-recent'),
{ key: 'recent', minCard: 188, header: document.getElementById('stream-grid-recent')?.previousElementSibling });
}
-- renderHomePagination('stream-grid-recent-pagination', recentData.total || 0, _homeRecentOnlinePage, HOME_RECENT_ONLINE_PAGE_SIZE, 'loadHomeRecentOnline');
-- } catch { /* silent */ }
++ homeRailLoaded(rail, offset, (recentData.streamers || []).length, recentData.total);
++ renderHomePagination('stream-grid-recent-pagination', rail);
++ } catch { rail.filling = false; }
}
function renderRecentlyOnline(containerId, streamers, append) {
@@@ -1407,16 +1405,15 @@ function renderRecentlyOnline(containerId, streamers, append) {
}).join(''));
}
--async function loadHomeRecentVods(page, append) {
-- if (page !== undefined) _homeRecentVodsPage = page;
-- const { offset, limit } = homePageRange(_homeRecentVodsPage, HOME_RECENT_VODS_PAGE_SIZE);
++async function loadHomeRecentVods(opts) {
++ const { offset, limit, append, rail } = homeRailRange('vods', opts, 'home-recent-vods-grid', 'loadHomeRecentVods');
try {
const render = (data) => {
const vods = data.vods || [];
const header = document.getElementById('home-recent-vods-header');
const grid = document.getElementById('home-recent-vods-grid');
if (!header || !grid) return;
-- if (!vods.length && _homeRecentVodsPage === 1) { header.style.display = 'none'; grid.innerHTML = ''; return; }
++ if (!vods.length && !append) { header.style.display = 'none'; grid.innerHTML = ''; return; }
header.style.display = '';
const _put = (html) => ovPutCards(grid, html, append);
_put(vods.map(v => {
@@@ -1441,21 +1438,21 @@ async function loadHomeRecentVods(page, append) {
`;
}).join(''));
if (window.OVDensity) OVDensity.attach(grid, { key: 'vods', header: header });
-- renderHomePagination('home-recent-vods-pagination', data.total || 0, _homeRecentVodsPage, HOME_RECENT_VODS_PAGE_SIZE, 'loadHomeRecentVods');
++ homeRailLoaded(rail, offset, vods.length, data.total);
++ renderHomePagination('home-recent-vods-pagination', rail);
};
await apiSWR(`/streams/recent-vods?limit=${limit}&offset=${offset}`, render, { ttl: 180000 });
-- } catch { /* silent */ }
++ } catch { rail.filling = false; }
}
--async function loadHomeClips(page, append) {
-- if (page !== undefined) _homeClipsPage = page;
-- const { offset, limit } = homePageRange(_homeClipsPage, HOME_CLIPS_PAGE_SIZE);
++async function loadHomeClips(opts) {
++ const { offset, limit, append, rail } = homeRailRange('clips', opts, 'home-clips-grid', 'loadHomeClips');
try {
const render = (data) => {
const clips = data.clips || [];
const header = document.getElementById('home-clips-header');
const grid = document.getElementById('home-clips-grid');
-- if (!clips.length && _homeClipsPage === 1) { if (header) header.style.display = 'none'; return; }
++ if (!clips.length && !append) { if (header) header.style.display = 'none'; return; }
if (header) header.style.display = '';
const _put = (html) => ovPutCards(grid, html, append);
_put(clips.map(c => `
@@@ -1477,21 +1474,21 @@ async function loadHomeClips(page, append) {
</a>
`).join(''));
if (window.OVDensity) OVDensity.attach(grid, { key: 'clips', header: header });
-- renderHomePagination('home-clips-pagination', data.total || 0, _homeClipsPage, HOME_CLIPS_PAGE_SIZE, 'loadHomeClips');
++ homeRailLoaded(rail, offset, clips.length, data.total);
++ renderHomePagination('home-clips-pagination', rail);
};
await apiSWR(`/clips?limit=${limit}&offset=${offset}`, render, { ttl: 180000 });
-- } catch { /* silent */ }
++ } catch { rail.filling = false; }
}
--async function loadHomePastes(page, append) {
-- if (page !== undefined) _homePastesPage = page;
-- const { offset, limit } = homePageRange(_homePastesPage, HOME_PASTES_PAGE_SIZE);
++async function loadHomePastes(opts) {
++ const { offset, limit, append, rail } = homeRailRange('pastes', opts, 'home-pastes-list', 'loadHomePastes');
try {
const render = (data) => {
const pastes = data.pastes || [];
const header = document.getElementById('home-pastes-header');
const list = document.getElementById('home-pastes-list');
-- if (!pastes.length && _homePastesPage === 1) { if (header) header.style.display = 'none'; return; }
++ if (!pastes.length && !append) { if (header) header.style.display = 'none'; return; }
if (header) header.style.display = '';
const _put = (html) => ovPutCards(list, html, append);
_put(pastes.map(p => {
@@@ -1517,10 +1514,11 @@ async function loadHomePastes(page, append) {
</a>`;
}).join(''));
if (window.OVDensity) OVDensity.attach(list, { key: 'pastes', header: document.getElementById('home-pastes-header') });
-- renderHomePagination('home-pastes-pagination', data.total || 0, _homePastesPage, HOME_PASTES_PAGE_SIZE, 'loadHomePastes');
++ homeRailLoaded(rail, offset, pastes.length, data.total);
++ renderHomePagination('home-pastes-pagination', rail);
};
await apiSWR(`/pastes?limit=${limit}&offset=${offset}`, render, { ttl: 180000 });
-- } catch { /* silent */ }
++ } catch { rail.filling = false; }
}
async function loadHomeLeaderboards() {
@@@ -1682,6 +1680,22 @@ function streamCardHTML(s, isLive) {
</a>`;
}
++/**
++ * The featured stream box lives in its own lazily loaded feature; it is only fetched the first time
++ * someone is live, and it stays out of the page entirely if the reader switched it off.
++ */
++let _homeFeaturedLoading = null;
++function _homeFeaturedSync(hasLive) {
++ const off = (() => { try { return localStorage.getItem('ov_home_featured_off') === '1'; } catch { return false; } })();
++ const t = document.getElementById('home-featured-toggle');
++ if (t) { t.hidden = !(off && hasLive); if (!t._ovBound) { t._ovBound = true; t.addEventListener('click', () => { if (window.homeFeatured) homeFeatured.setOff(false); else { try { localStorage.removeItem('ov_home_featured_off'); } catch { /* */ } _homeFeaturedSync(true); } }); } }
++ if (!hasLive || off) { if (window.homeFeatured) homeFeatured.boot(hasLive); return; }
++ if (window.homeFeatured) { homeFeatured.boot(true); return; }
++ if (!_homeFeaturedLoading && window.ov && ov.load) {
++ _homeFeaturedLoading = ov.load('featured').then(() => { if (_homeIsActive() && window.homeFeatured) homeFeatured.boot(true); }).catch(() => { _homeFeaturedLoading = null; });
++ }
++}
++
function renderStreamGrid(containerId, streams, isLive) {
const c = document.getElementById(containerId);
if (!c) return;
@@@ -1867,10 +1881,12 @@ async function refreshHomeLive() {
// never disrupt someone paging through or reading below the fold).
function refreshHomeSections() {
if (!_homeIsActive() || document.visibilityState !== 'visible') return;
-- if (_homeRecentOnlinePage === 1 && typeof loadHomeRecentOnline === 'function') loadHomeRecentOnline();
-- if (_homeRecentVodsPage === 1 && typeof loadHomeRecentVods === 'function') loadHomeRecentVods();
-- if (_homeClipsPage === 1 && typeof loadHomeClips === 'function') loadHomeClips();
-- if (_homePastesPage === 1 && typeof loadHomePastes === 'function') loadHomePastes();
++ // Only rails the reader has not expanded: a refresh must never fold a browsed rail back up.
++ const untouched = (k) => { const r = _homeRails[k]; return !r || r.shown <= Math.max(_narrow ? 2 : 4, _homeRailCols(r.grid) * 2); };
++ if (untouched('recent') && typeof loadHomeRecentOnline === 'function') loadHomeRecentOnline();
++ if (untouched('vods') && typeof loadHomeRecentVods === 'function') loadHomeRecentVods();
++ if (untouched('clips') && typeof loadHomeClips === 'function') loadHomeClips();
++ if (untouched('pastes') && typeof loadHomePastes === 'function') loadHomePastes();
if (typeof loadHomeLeaderboards === 'function') loadHomeLeaderboards();
}
@@@ -1885,10 +1901,6 @@ function stopHomeRefresh() {
if (_homeSectionsTimer) { clearInterval(_homeSectionsTimer); _homeSectionsTimer = null; }
} // populated on channel load, used for filter bar
// Homepage pagination state
--let _homeRecentOnlinePage = 1;
--let _homeRecentVodsPage = 1;
--let _homeClipsPage = 1;
--let _homePastesPage = 1;
// The home page used to open with 46 cards across four list sections, which made it enormous and
// meant every visitor downloaded four full pages of thumbnails to scroll past them. One row's
// worth each is plenty for a front page; the pagination underneath still reaches the rest, and
@@@ -1900,27 +1912,51 @@ let _homePastesPage = 1;
// matchMedia, not innerWidth: reading innerWidth while the page is still being styled forces a
// synchronous style and layout pass of the whole document.
const _narrow = typeof window !== 'undefined' && window.matchMedia('(max-width: 759px)').matches;
--const HOME_RECENT_ONLINE_PAGE_SIZE = _narrow ? 4 : 6;
--const HOME_RECENT_VODS_PAGE_SIZE = _narrow ? 4 : 6;
--const HOME_CLIPS_PAGE_SIZE = _narrow ? 4 : 6;
--const HOME_PASTES_PAGE_SIZE = _narrow ? 4 : 5;
/**
-- * How many more a "Load more" press brings in.
++ * Home rails are sized by the row, not by a page number.
*
-- * The first page is deliberately small — it is a taste of the section, not a browse view. Once
-- * somebody has pressed Load more they have said they want to browse, so subsequent pages are
-- * larger: fewer presses to get anywhere, and the cost is paid only by people who asked for it.
++ * A rail's grid decides how many cards fit per row (ov-density.js); the rail then always shows
++ * whole rows: the first load is two rows, "Load more" adds whole rows, and if the row count
++ * changes (a rotated phone, a narrower window, the per-row control) the last row is filled in
++ * with one small request. A rail of six cards in four-wide rows no longer ends with two cards
++ * and a hole.
*/
--const HOME_LOAD_MORE = 8;
--/** Offset and limit for a given page, given a first page that is a different size to the rest. */
--function homePageRange(page, firstSize) {
-- if (page <= 1) return { offset: 0, limit: firstSize };
-- return { offset: firstSize + (page - 2) * HOME_LOAD_MORE, limit: HOME_LOAD_MORE };
++const _homeRails = {}; // key → { shown, total, grid, loader, filling }
++function homeRailReset() { for (const k of Object.keys(_homeRails)) delete _homeRails[k]; }
++function _homeRailCols(grid) {
++ try { return Math.max(1, (window.OVDensity && grid) ? OVDensity.columns(grid) : 1); } catch { return 1; }
++}
++/** offset/limit for a fresh load (two rows) or for `more` cards, plus the rail record to update after. */
++function homeRailRange(key, opts, gridId, loaderName) {
++ const rail = _homeRails[key] || (_homeRails[key] = { shown: 0, total: 0, filling: false });
++ rail.grid = document.getElementById(gridId);
++ rail.loader = loaderName;
++ if (opts && opts.more) return { offset: rail.shown, limit: opts.more, append: true, rail };
++ rail.shown = 0;
++ const cols = _homeRailCols(rail.grid);
++ return { offset: 0, limit: Math.max(_narrow ? 2 : 4, cols * 2), append: false, rail };
++}
++/** Record what a load brought in, then fill the last row if it is short. */
++function homeRailLoaded(rail, offset, count, total) {
++ rail.shown = offset + count;
++ rail.total = Number(total) || 0;
++ rail.filling = false;
++ if (rail.grid && !rail.grid._ovRailBound) {
++ rail.grid._ovRailBound = true;
++ rail.grid.addEventListener('ovd:columns', () => homeRailAutofill(rail));
++ }
++ homeRailAutofill(rail);
}
--/** How many rows are on screen once `page` has loaded. */
--function homeShownCount(page, firstSize) {
-- const r = homePageRange(page, firstSize);
-- return r.offset + r.limit;
++function homeRailAutofill(rail) {
++ if (!rail || rail.filling || !rail.loader || !rail.grid) return;
++ const cols = _homeRailCols(rail.grid);
++ const left = Math.max(0, rail.total - rail.shown);
++ const short = rail.shown % cols;
++ if (!left || !short) return;
++ const need = Math.min(left, cols - short);
++ rail.filling = true;
++ const fn = window[rail.loader];
++ if (typeof fn === 'function') fn({ more: need, auto: true }); else rail.filling = false;
}
// Thin wrapper for homepage section pagination — same visual style as renderVodsPagination.
@@@ -1932,19 +1968,21 @@ function homeShownCount(page, firstSize) {
* they're looking at, or they want the dedicated page. So: one button that appends the next
* batch, showing how many are left, and it disappears when there are none.
*/
--function renderHomePagination(containerId, total, page, pageSize, setterName) {
++function renderHomePagination(containerId, rail) {
const el = document.getElementById(containerId);
-- if (!el) return;
-- // The first page is a different size to the ones after it, so "how many are on screen" is not
-- // page * pageSize.
-- const shown = homeShownCount(page, pageSize);
-- const left = Math.max(0, (total || 0) - shown);
++ if (!el || !rail) return;
++ const left = Math.max(0, rail.total - rail.shown);
// These containers ship with an inline display:none — the old pager unhid them itself, and
// this one has to as well or the button renders into a hidden box and nobody ever sees it.
if (left <= 0) { el.innerHTML = ''; el.style.display = 'none'; return; }
el.style.display = 'block';
-- const next = Math.min(left, HOME_LOAD_MORE);
-- el.innerHTML = `<button type="button" class="home-load-more" onclick="${setterName}(${page + 1}, true)">
++ // Whole rows: whatever completes the current row, then two more rows (one on a phone, where a
++ // row is a screen). "8 more" into a four-wide grid was two rows; into a three-wide grid it was
++ // two rows and a stray pair.
++ const cols = _homeRailCols(rail.grid);
++ const short = rail.shown % cols;
++ const next = Math.min(left, (short ? cols - short : 0) + cols * (_narrow ? 1 : 2));
++ el.innerHTML = `<button type="button" class="home-load-more" onclick="${rail.loader}({ more: ${next} })">
<i class="fa-solid fa-arrow-down"></i>
<span>Load ${next} more</span>
<small>${left.toLocaleString()} left</small>
ddiff --git a/public/js/call.js b/public/js/call.js
iindex d24fd5e2..99735c85 100644
---- a/public/js/call.js
++++ b/public/js/call.js
@@@ -64,13 +64,19 @@ let _iceServers = [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun1.l.google.com:19302' },
];
++let _iceServersAt = 0;
++// Fetched once per five minutes (short-lived TURN credentials last longer than that), and never
++// again on every join — the list used to be a round trip on the critical path of each join.
async function _ensureIceServers() {
++ if (Date.now() - _iceServersAt < 5 * 60 * 1000) return;
try {
const res = await fetch('/api/auth/ice-servers');
if (res.ok) {
const data = await res.json();
if (Array.isArray(data.iceServers) && data.iceServers.length) {
-- _iceServers = data.iceServers;
++ // A turn: entry without credentials would make every RTCPeerConnection throw.
++ const ok = data.iceServers.filter((s) => s && s.urls && (!/^turns?:/i.test(String(s.urls)) || (s.username && s.credential)));
++ if (ok.length) { _iceServers = ok; _iceServersAt = Date.now(); }
}
}
} catch (e) {
@@@ -346,6 +352,43 @@ function _scheduleRender() {
* frequent event (~12 updates/sec per speaking participant).
* Returns true if the tile was found and updated in-place.
*/
++function _tileFor(peerId) {
++ const gridId = callState.vcMode ? 'vc-participants-grid' : (callState.broadcastMode ? 'bc-call-participants-grid' : 'call-participants-grid');
++ const grid = document.getElementById(gridId);
++ return grid ? grid.querySelector(`[data-peer-id="${CSS.escape(peerId)}"]`) : null;
++}
++function _updateTileConnecting(peerId, connecting) {
++ const tile = _tileFor(peerId);
++ if (tile) tile.classList.toggle('is-connecting', !!connecting);
++}
++/** Audio level (0–1) drives the avatar ring through a CSS variable — no re-render, no layout. */
++function _setTileLevel(peerId, level) {
++ const tile = _tileFor(peerId);
++ if (!tile) return;
++ const v = Math.max(0, Math.min(1, level));
++ if (Math.abs((tile._ovLvl || 0) - v) < 0.04) return;
++ tile._ovLvl = v;
++ tile.style.setProperty('--lvl', v.toFixed(2));
++}
++// Remote levels come from the receiver itself (what is actually being heard), ten times a second
++// while the tab is visible — no AnalyserNode per peer, no socket messages.
++let _levelTimer = null;
++function _startLevelLoop() {
++ _stopLevelLoop();
++ _levelTimer = setInterval(() => {
++ if (document.hidden || !callState.joined) return;
++ for (const [peerId, peer] of callState.peers) {
++ if (!peer.pc || peer.muted || peer.forceMuted || peer.localMuted) { _setTileLevel(peerId, 0); continue; }
++ try {
++ const r = peer.pc.getReceivers().find((x) => x.track && x.track.kind === 'audio');
++ const src = r && r.getSynchronizationSources ? r.getSynchronizationSources()[0] : null;
++ _setTileLevel(peerId, src && typeof src.audioLevel === 'number' ? Math.min(1, src.audioLevel * 4) : 0);
++ } catch { /* */ }
++ }
++ }, 100);
++}
++function _stopLevelLoop() { if (_levelTimer) { clearInterval(_levelTimer); _levelTimer = null; } }
++
function _updateTileSpeaking(peerId, speaking, muted, forceMuted) {
const gridId = callState.vcMode ? 'vc-participants-grid' : (callState.broadcastMode ? 'bc-call-participants-grid' : 'call-participants-grid');
const grid = document.getElementById(gridId);
@@@ -426,71 +469,6 @@ function _updateTileMuted(peerId, muted, forceMuted) {
/* ── Public API (called from stream page) ──────────────────── */
--/**
-- * Initialize the call panel UI for a stream.
-- * Called when a channel page loads and the stream has call_mode set.
-- */
--function initCallPanel(stream) {
-- const panel = document.getElementById('call-panel');
-- if (!panel) return;
--
-- callState.broadcastMode = false;
--
-- callState.streamId = stream.id;
-- callState.callMode = stream.call_mode;
-- callState.lastSyncedCallMode = stream.call_mode || null;
-- callState.isStreamer = !!(currentUser && stream.user_id === currentUser.id);
--
-- _syncCallSettingsUI();
-- _startViewerCallStatusSync(stream.id);
--
-- if (!stream.call_mode) {
-- panel.style.display = 'none';
-- return;
-- }
--
-- panel.style.display = '';
--
-- // Update mode display
-- const modeLabel = document.getElementById('call-mode-label');
-- if (modeLabel) {
-- const labels = { 'mic': 'Voice Chat', 'mic+cam': 'Voice + Camera', 'cam+mic': 'Video Call' };
-- modeLabel.textContent = labels[stream.call_mode] || 'Group Call';
-- }
--
-- // Update hint text based on mode
-- const hint = document.getElementById('call-join-hint');
-- if (hint) {
-- const hints = {
-- 'mic': 'Join the voice chat to talk with the streamer and other viewers',
-- 'mic+cam': 'Join voice chat — you can optionally enable your camera',
-- 'cam+mic': 'Join the video call — microphone and camera required',
-- };
-- hint.textContent = hints[stream.call_mode] || '';
-- }
--
-- // Show/hide camera elements based on mode
-- const camBtn = document.getElementById('call-btn-camera');
-- if (camBtn) camBtn.style.display = stream.call_mode === 'mic' ? 'none' : '';
--
-- const camGroup = document.getElementById('call-cam-group');
-- if (camGroup) camGroup.style.display = stream.call_mode === 'mic' ? 'none' : '';
--
-- // Reset connected badge
-- const connBadge = document.getElementById('call-connected-badge');
-- if (connBadge) connBadge.style.display = 'none';
--
-- // Populate device selectors
-- _populateCallDevices();
--
-- // Fetch current participants
-- _fetchCallStatus(stream.id);
--}
--
--/**
-- * Initialize the call panel for the broadcast/dashboard page.
-- * The streamer auto-joins the call — no join button needed.
-- */
function initBroadcastCallPanel(streamId, callMode) {
const panel = document.getElementById('bc-call-panel');
if (!panel || !callMode) {
@@@ -501,11 +479,11 @@ function initBroadcastCallPanel(streamId, callMode) {
// If already in a call for this stream, skip re-init
if ((callState.joined || callState.connecting) && callState.streamId === streamId && callState.broadcastMode && callState.callMode === callMode) return;
-- // Clean up any previous call
++ // Clean up any previous call. A voice channel is left properly (its panel, mini bar and chat
++ // mode reset) instead of being cut off underneath the broadcast desk.
if (callState.joined || callState.connecting) {
-- callState.joined = false;
-- callState.connecting = false;
-- _cleanupCall();
++ if (callState.vcMode && typeof vcLeave === 'function') { vcLeave(); _callSystemMessage('Left the voice channel to open the broadcast call panel'); }
++ else { callState.joined = false; callState.connecting = false; _cleanupCall(); }
}
callState.streamId = streamId;
@@@ -542,9 +520,10 @@ async function joinCall() {
if (callState.joined || callState.connecting || !(callState.channelId || callState.streamId)) return;
callState.connecting = true;
++ _setConnState('connecting');
-- // Fetch ICE/TURN server config from the server before creating any peer connections
-- await _ensureIceServers();
++ // ICE/TURN config loads while the microphone is being opened, not before it.
++ const icePromise = _ensureIceServers();
if (callState.reconnectTimer) {
clearTimeout(callState.reconnectTimer);
@@@ -555,35 +534,7 @@ async function joinCall() {
// Camera is always off by default — user must explicitly opt in
const wantCameraOff = callState.startCameraOff !== undefined ? callState.startCameraOff : true;
-- // If a reusable preview stream was passed in (from voice-channels setup),
-- // use it directly instead of calling getUserMedia again — avoids the
-- // double-acquisition lag on Steam Deck / PipeWire.
-- let reuseStream = callState._reusableStream || null;
-- delete callState._reusableStream;
--
-- if (reuseStream) {
-- // Ensure the reusable stream has an audio track; drop video if camera should be off
-- const audioTracks = reuseStream.getAudioTracks();
-- if (!audioTracks.length || audioTracks[0].readyState !== 'live') {
-- // Stream is stale — fall through to fresh getUserMedia
-- reuseStream.getTracks().forEach(t => t.stop());
-- reuseStream = null;
-- } else if (wantCameraOff) {
-- // Strip video tracks when starting with camera off
-- reuseStream.getVideoTracks().forEach(t => {
-- t.stop();
-- reuseStream.removeTrack(t);
-- });
-- callState.cameraOff = true;
-- } else {
-- callState.cameraOff = !reuseStream.getVideoTracks().some(t => t.readyState === 'live');
-- }
-- }
--
-- if (reuseStream) {
-- callState.localStream = reuseStream;
-- callState.noMic = false;
-- } else {
++ {
// If we're broadcasting, clone the broadcast's audio track instead of
// calling getUserMedia again — avoids device contention on Linux/PipeWire
// where a second getUserMedia can steal the mic from the broadcast or
@@@ -654,10 +605,19 @@ async function joinCall() {
// Show local preview
_updateLocalPreview();
-- // Connect signaling WebSocket
++ // Peer connections are built from `welcome`, so the ICE list must be in by the time the
++ // socket opens — it almost always is by now.
++ await icePromise;
++ if (!callState.connecting) return; // left while the microphone was being opened
_connectCallWs();
}
++/** connecting → connected → reconnecting, for the voice channel status pill. */
++function _setConnState(state) {
++ callState.connState = state;
++ if (typeof vcSetStatus === 'function') vcSetStatus(state);
++}
++
/** Leave the group call */
function leaveCall() {
const streamId = callState.streamId;
@@@ -720,11 +680,16 @@ function _cleanupCall() {
_sharedPeerAudioCtx = null;
}
-- // Close WebSocket
++ // Close the socket with its handlers detached: its close event must not reach the reconnect
++ // path of a join that starts right after this (leave, rejoin, ghost participant).
if (callState.ws) {
-- try { callState.ws.close(); } catch {}
++ const w = callState.ws;
callState.ws = null;
++ w.onopen = null; w.onmessage = null; w.onclose = null; w.onerror = null;
++ try { w.close(); } catch {}
}
++ _stopLevelLoop();
++ callState.connState = 'idle';
if (callState.reconnectTimer) {
clearTimeout(callState.reconnectTimer);
@@@ -928,7 +893,10 @@ function _connectCallWs() {
const token = _getAuthToken();
const channelParam = callState.channelId || callState.streamId;
let url = `${proto}//${location.host}/ws/call?channelId=${encodeURIComponent(channelParam)}`;
-- if (token) url += `&token=${token}`;
++ // The server reads the session cookie on the upgrade; the token only rides in the URL (and
++ // every proxy log) for a browser that has no cookie.
++ const hasCookie = /(?:^|;\s*)(?:ov_token|token)=/.test(document.cookie);
++ if (token && !hasCookie) url += `&token=${encodeURIComponent(token)}`;
if (callState.reconnectTimer) {
clearTimeout(callState.reconnectTimer);
@@@ -954,23 +922,28 @@ function _connectCallWs() {
}
callState.peers.clear();
-- callState.ws = new WebSocket(url);
++ const ws = new WebSocket(url);
++ callState.ws = ws;
-- callState.ws.onopen = () => {
++ ws.onopen = () => {
++ if (callState.ws !== ws) return;
callState.reconnectDelay = 3000;
};
-- callState.ws.onmessage = (e) => {
++ ws.onmessage = (e) => {
++ if (callState.ws !== ws) return;
try {
const msg = JSON.parse(e.data);
_handleCallMessage(msg);
} catch {}
};
-- callState.ws.onclose = () => {
++ ws.onclose = () => {
++ if (callState.ws !== ws) return; // a socket this join already replaced
const shouldReconnect = !callState.intentionalDisconnect && (callState.joined || callState.connecting) && (callState.channelId || callState.streamId);
callState.ws = null;
if (shouldReconnect) {
++ _setConnState('reconnecting');
// Reconnect
callState.reconnectTimer = setTimeout(() => {
if (!callState.intentionalDisconnect && (callState.joined || callState.connecting) && (callState.channelId || callState.streamId)) {
@@@ -983,7 +956,8 @@ function _connectCallWs() {
}
};
-- callState.ws.onerror = (e) => {
++ ws.onerror = (e) => {
++ if (callState.ws !== ws) return;
console.warn('[Call] WebSocket error:', e);
};
}
@@@ -991,9 +965,20 @@ function _connectCallWs() {
function _handleCallMessage(msg) {
switch (msg.type) {
case 'welcome':
++ // A click on channel B while A was still connecting used to land you in A with B's
++ // label; the welcome names its channel, so a stale one is refused.
++ if (callState.vcMode && msg.channelId && callState.channelId && msg.channelId !== callState.channelId) {
++ try { callState.ws.close(); } catch {}
++ break;
++ }
callState.myPeerId = msg.peerId;
callState.joined = true;
callState.connecting = false;
++ _setConnState('connected');
++ // A reconnect gets a fresh server-side record (unmuted, camera off): say what we are.
++ if (callState.muted) _sendCallMsg({ type: 'mute', muted: true });
++ if (!callState.cameraOff) _sendCallMsg({ type: 'camera-off', cameraOff: false });
++ _startLevelLoop();
callState.isStreamer = msg.isStreamer;
// Server provides canonical moderation capability for this channel.
callState.canModerate = !!msg.canModerate;
@@@ -1038,7 +1023,9 @@ function _handleCallMessage(msg) {
break;
case 'peer-joined': {
-- _createPeerConnection(msg.peerId, true, msg);
++ // The newcomer offers to everyone already here; this side only answers. Both sides
++ // offering at once cost every pair a collision, a rollback and dropped candidates.
++ _createPeerConnection(msg.peerId, false, msg);
_renderCallUI();
_callSystemMessage(`${_resolveParticipantDisplayName(msg)} joined the call`);
_callSounds.play('join');
@@@ -1226,10 +1213,12 @@ function _handleCallMessage(msg) {
case 'error': {
_callSystemMessage(msg.message || 'Call error');
-- if (!callState.joined) {
-- callState.connecting = false;
-- _cleanupCall();
-- _renderCallUI();
++ // "Channel not found" after a server restart, full, not live, banned, private: nothing a
++ // retry can fix — reconnecting every few seconds with a toast each time was the bug.
++ const fatal = /not found|full|not live|banned|private|missing/i.test(String(msg.message || ''));
++ if (!callState.joined || fatal) {
++ if (callState.vcMode && typeof vcLeave === 'function') vcLeave();
++ else { callState.joined = false; callState.connecting = false; _cleanupCall(); _renderCallUI(); }
}
break;
}
@@@ -1242,11 +1231,10 @@ function _createPeerConnection(peerId, initiator, peerInfo) {
if (callState.peers.has(peerId)) {
_closePeer(peerId);
}
--
-- const pc = new RTCPeerConnection({ iceServers: _iceServers });
--
const peer = {
-- pc,
++ pc: null,
++ connecting: true,
++ _pendingIce: [],
username: peerInfo?.username || null,
anonId: peerInfo?.anonId || null,
displayName: _resolveParticipantDisplayName(peerInfo),
@@@ -1269,6 +1257,18 @@ function _createPeerConnection(peerId, initiator, peerInfo) {
videoStream: null,
};
callState.peers.set(peerId, peer);
++ // The offering side builds its connection now; the answering side waits for the offer.
++ if (initiator) _attachPeerPc(peerId, peer);
++ return peer;
++}
++
++/** The RTCPeerConnection for a peer record: local tracks, ICE, remote tracks, state handling. */
++function _attachPeerPc(peerId, peer) {
++ if (peer.pc) return peer.pc;
++ let pc;
++ try { pc = new RTCPeerConnection({ iceServers: _iceServers }); }
++ catch (err) { console.warn('[Call] ICE config rejected, using STUN only:', err.message); pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] }); }
++ peer.pc = pc;
// Add local tracks to the connection
if (callState.localStream) {
@@@ -1319,18 +1319,11 @@ function _createPeerConnection(peerId, initiator, peerInfo) {
return;
}
console.log(`[Call] Attempting ICE restart for peer ${peerId} (attempt ${peer._iceRestartCount})`);
++ // One side restarts (restartIce() raises negotiationneeded, which sends the
++ // offer); the other answers. Both restarting at once collided every time.
try {
-- peer.pc.restartIce();
-- peer.pc.createOffer({ iceRestart: true }).then(offer => {
-- return peer.pc.setLocalDescription(offer);
-- }).then(() => {
-- _sendCallMsg({ type: 'offer', targetPeerId: peerId, sdp: peer.pc.localDescription });
-- }).catch(err => {
-- console.warn(`[Call] ICE restart offer failed for ${peerId}:`, err.message);
-- _closePeer(peerId);
-- callState.peers.delete(peerId);
-- _scheduleRender();
-- });
++ if (callState.myPeerId < peerId) peer.pc.restartIce();
++ else peer._disconnectTimer = setTimeout(() => { if (callState.joined && peer.pc && peer.pc.iceConnectionState !== 'connected' && peer.pc.iceConnectionState !== 'completed') { _closePeer(peerId); callState.peers.delete(peerId); _scheduleRender(); } }, 8000);
} catch {
_closePeer(peerId);
callState.peers.delete(peerId);
@@@ -1342,6 +1335,7 @@ function _createPeerConnection(peerId, initiator, peerInfo) {
if (peer._disconnectTimer) { clearTimeout(peer._disconnectTimer); peer._disconnectTimer = null; }
// Reset restart counter on successful connection
peer._iceRestartCount = 0;
++ if (peer.connecting) { peer.connecting = false; _updateTileConnecting(peerId, false); }
} else if (state === 'failed') {
if (peer._disconnectTimer) { clearTimeout(peer._disconnectTimer); peer._disconnectTimer = null; }
// One ICE restart attempt before giving up
@@@ -1349,16 +1343,8 @@ function _createPeerConnection(peerId, initiator, peerInfo) {
peer._iceRestartAttempted = true;
console.log(`[Call] Peer ${peerId} ICE failed — attempting one restart`);
try {
-- peer.pc.restartIce();
-- peer.pc.createOffer({ iceRestart: true }).then(offer => {
-- return peer.pc.setLocalDescription(offer);
-- }).then(() => {
-- _sendCallMsg({ type: 'offer', targetPeerId: peerId, sdp: peer.pc.localDescription });
-- }).catch(() => {
-- _closePeer(peerId);
-- callState.peers.delete(peerId);
-- _scheduleRender();
-- });
++ if (callState.myPeerId < peerId) peer.pc.restartIce();
++ else setTimeout(() => { if (callState.joined && peer.pc && peer.pc.iceConnectionState === 'failed') { _closePeer(peerId); callState.peers.delete(peerId); _scheduleRender(); } }, 8000);
} catch {
_closePeer(peerId);
callState.peers.delete(peerId);
@@@ -1387,8 +1373,8 @@ function _createPeerConnection(peerId, initiator, peerInfo) {
if (peer._negotiating || peer._handlingRemoteOffer) return; // prevent re-entrant negotiation
peer._negotiating = true;
try {
-- const offer = await pc.createOffer();
-- await pc.setLocalDescription(offer);
++ // No explicit offer: after a rollback the browser knows which description is right.
++ await pc.setLocalDescription();
_sendCallMsg({
type: 'offer',
targetPeerId: peerId,
@@@ -1412,6 +1398,7 @@ async function _handleOffer(msg) {
}
const peer = callState.peers.get(peerId);
if (!peer) return;
++ if (!peer.pc) _attachPeerPc(peerId, peer);
try {
// Perfect negotiation: detect offer collision (both sides sent offers simultaneously)
@@@ -1434,8 +1421,8 @@ async function _handleOffer(msg) {
peer._handlingRemoteOffer = true;
await peer.pc.setRemoteDescription(new RTCSessionDescription(msg.sdp));
-- const answer = await peer.pc.createAnswer();
-- await peer.pc.setLocalDescription(answer);
++ _flushPendingIce(peer);
++ await peer.pc.setLocalDescription();
_sendCallMsg({
type: 'answer',
targetPeerId: peerId,
@@@ -1458,21 +1445,22 @@ async function _handleAnswer(msg) {
}
try {
await peer.pc.setRemoteDescription(new RTCSessionDescription(msg.sdp));
++ _flushPendingIce(peer);
} catch (err) {
console.error('[Call] Handle answer failed:', err);
}
}
++/** Candidates that arrive before the remote description are kept, not thrown away. */
async function _handleIceCandidate(msg) {
const peer = callState.peers.get(msg.fromPeerId);
-- if (!peer) return;
-- try {
-- if (msg.candidate) {
-- await peer.pc.addIceCandidate(new RTCIceCandidate(msg.candidate));
-- }
-- } catch (err) {
-- // ICE candidate errors are common during renegotiation, ignore
-- }
++ if (!peer || !msg.candidate) return;
++ if (!peer.pc || !peer.pc.remoteDescription) { peer._pendingIce.push(msg.candidate); return; }
++ try { await peer.pc.addIceCandidate(new RTCIceCandidate(msg.candidate)); } catch { /* stale during renegotiation */ }
++}
++function _flushPendingIce(peer) {
++ const list = peer._pendingIce.splice(0);
++ for (const c of list) { try { peer.pc.addIceCandidate(new RTCIceCandidate(c)).catch(() => { }); } catch { /* */ } }
}
function _closePeer(peerId) {
@@@ -1751,9 +1739,13 @@ function _closeCallVideoPopout() {
function _renderCallUI() {
_syncCallSettingsUI();
-- // Voice channel mode — delegate rendering to voice-channels.js
++ // Voice channel mode — delegate rendering to voice-channels.js. The floating peer menu and the
++ // video popout are page-level, so they are kept in step here as well (the ⋮ menu never opened
++ // in a voice channel because this returned before it was drawn).
if (callState.vcMode && typeof vcRenderUI === 'function') {
vcRenderUI();
++ _syncCallVideoPopout();
++ _renderFloatingPeerContextMenu();
return;
}
@@@ -1876,6 +1868,7 @@ function _createParticipantTile(opts) {
if (opts.muted || opts.forceMuted) tile.classList.add('is-muted');
if (opts.localMuted) tile.classList.add('is-local-muted');
if (opts.speaking && !opts.muted && !opts.forceMuted) tile.classList.add('is-speaking');
++ if (opts.connecting) tile.classList.add('is-connecting');
// Video or avatar placeholder
const showVideo = _peerHasActiveVideo(opts);
@@@ -2410,7 +2403,8 @@ function _setupLocalAudioProcessing() {
function _processLocalSpeechFrame() {
if (!callState.localAnalyser) return;
-- const bins = new Uint8Array(callState.localAnalyser.frequencyBinCount);
++ if (!callState._bins || callState._bins.length !== callState.localAnalyser.frequencyBinCount) callState._bins = new Uint8Array(callState.localAnalyser.frequencyBinCount);
++ const bins = callState._bins;
callState.localAnalyser.getByteFrequencyData(bins);
// Only average bins in the speech frequency band (~85–3400 Hz) to reduce
// false positives from fans, keyboard clicks, and low-frequency rumble
@@@ -2432,6 +2426,7 @@ function _processLocalSpeechFrame() {
callState.localSpeechDetected = detected || Date.now() < callState.speechHoldUntil;
_applyLocalAudioGate();
++ if (callState.myPeerId && !document.hidden) _setTileLevel(callState.myPeerId, _shouldTransmitAudio() ? percent / 60 : 0);
const speaking = _shouldBeSpeaking();
if (speaking !== callState.localSpeaking) {
@@@ -2482,7 +2477,8 @@ function _applyLocalAudioGate() {
if (t.enabled !== enabled) t.enabled = enabled;
});
}
-- _syncCallSettingsUI();
++ // Forty element lookups twelve times a second, for a panel that only changes when the gate does.
++ if (callState._lastGate !== enabled) { callState._lastGate = enabled; _syncCallSettingsUI(); }
}
function onCallInputModeChange(value) {
ddiff --git a/public/js/chat.js b/public/js/chat.js
iindex ee603f56..9e3acd61 100644
---- a/public/js/chat.js
++++ b/public/js/chat.js
@@@ -3182,6 +3182,11 @@ function handleChatMessage(msg) {
_handleVcCallResponse(msg);
break;
}
++ case 'voice-channels': {
++ // The server pushes the sidebar list on every change; the voice code applies it when loaded.
++ if (typeof vcApplyChannels === 'function') vcApplyChannels(msg.channels);
++ break;
++ }
}
}
@@@ -4829,10 +4834,11 @@ async function _joinVoiceChannelFromInvite(channelId, channelName, switchToChat
toast('That call channel is no longer available', 'error');
return;
}
-- if (typeof callState !== 'undefined' && callState.joined && callState.channelId === ch.id) {
++ if (typeof callState !== 'undefined' && (callState.joined || callState.connecting) && callState.channelId === ch.id) {
return;
}
-- await vcJoinChannel(ch);
++ // Through the same door as a click: leaves whatever channel we are in first.
++ vcSelectChannel(ch.id);
}
async function acceptVcInvite(channelId, channelName) {
ddiff --git a/public/js/home-featured.js b/public/js/home-featured.js
nnew file mode 100644
iindex 00000000..bca9bbd3
---- /dev/null
++++ b/public/js/home-featured.js
@@@ -0,0 +1,251 @@
++/**
++ * home-featured.js — the featured live stream at the top of the home page.
++ *
++ * Whenever someone is live, the home page opens on a stream: picked by the server from everyone
++ * live (busiest first, rotating every few minutes so a quiet stream gets its turn), playing muted
++ * and inline, with the newest AI "right now" description beside it and one button into the
++ * stream and its chat. Loaded only when a stream is live (features.json: featured), so an empty
++ * site pays nothing for it.
++ *
++ * Playback by protocol, without the channel page's player (which is bound to that page's DOM):
++ * rtmp → HTTP-FLV through flv.js, the same relay the channel uses as its backup
++ * jsmpeg → the JSMPEG relay socket into a canvas
++ * webrtc → live frames (the stream's thumbnail, refreshed) with a "tap to watch" note; a
++ * mediasoup viewer session is the channel page's job
++ * Sound is never on by itself; the Unmute button asks the browser for it. Playback pauses when
++ * the tab is hidden or the box scrolls away, and stops when the reader leaves the home page.
++ *
++ * Off switch: the × in the corner (and "Show featured stream" in the Live Now header to undo it).
++ * Remembered in localStorage.
++ */
++(function () {
++ 'use strict';
++ if (window.homeFeatured) return;
++
++ const OFF_KEY = 'ov_home_featured_off';
++ const POLL_MS = 30000;
++ const FRAME_MS = 10000;
++
++ const st = { el: null, current: null, poll: 0, frame: 0, player: null, unmuted: false, visible: true, io: null, gen: 0, skip: null };
++ const $ = (sel) => st.el && st.el.querySelector(sel);
++ const escText = (v) => (typeof esc === 'function' ? esc(String(v == null ? '' : v)) : String(v == null ? '' : v));
++ const constrained = () => {
++ try {
++ const c = navigator.connection || {};
++ return !!(c.saveData || /(^|[^3-9])2g/.test(String(c.effectiveType || '')));
++ } catch { return false; }
++ };
++
++ function isOff() { try { return localStorage.getItem(OFF_KEY) === '1'; } catch { return false; } }
++ function setOff(off) {
++ try { off ? localStorage.setItem(OFF_KEY, '1') : localStorage.removeItem(OFF_KEY); } catch { /* */ }
++ const t = document.getElementById('home-featured-toggle');
++ if (t) t.hidden = !off || !st.hasLive;
++ if (off) stop(); else boot(true);
++ }
++
++ function loadScript(src) {
++ return new Promise((resolve, reject) => {
++ const have = document.querySelector(`script[data-hf-src="${src}"]`);
++ if (have) { if (have.dataset.loaded) resolve(); else { have.addEventListener('load', resolve); have.addEventListener('error', reject); } return; }
++ const s = document.createElement('script');
++ s.src = src; s.async = true; s.dataset.hfSrc = src;
++ s.onload = () => { s.dataset.loaded = '1'; resolve(); };
++ s.onerror = () => reject(new Error('script failed: ' + src));
++ document.head.appendChild(s);
++ });
++ }
++
++ // ── Players ────────────────────────────────────────────────────────────────
++ function destroyPlayer() {
++ const p = st.player;
++ st.player = null;
++ if (!p) return;
++ try { p.destroy(); } catch { /* */ }
++ }
++ async function playFlv(url, media) {
++ if (typeof flvjs === 'undefined') await loadScript('https://cdn.jsdelivr.net/npm/flv.js@latest/dist/flv.min.js');
++ if (typeof flvjs === 'undefined' || !flvjs.isSupported()) throw new Error('flv unsupported');
++ const video = document.createElement('video');
++ video.muted = !st.unmuted; video.autoplay = true; video.playsInline = true; video.setAttribute('playsinline', '');
++ media.appendChild(video);
++ const player = flvjs.createPlayer({ type: 'flv', url, isLive: true }, { enableStashBuffer: false, stashInitialSize: 128, lazyLoad: false, autoCleanupSourceBuffer: true, autoCleanupMaxBackwardDuration: 20, autoCleanupMinBackwardDuration: 8 });
++ player.attachMediaElement(video);
++ player.load();
++ video.play().catch(() => { /* muted autoplay is allowed; a refusal only means no sound */ });
++ return {
++ video,
++ pause() { try { video.pause(); } catch { /* */ } },
++ resume() { video.play().catch(() => { }); },
++ setMuted(m) { video.muted = m; if (!m) video.play().catch(() => { }); },
++ destroy() { try { player.pause(); player.unload(); player.detachMediaElement(); player.destroy(); } catch { /* */ } video.remove(); },
++ };
++ }
++ async function playJsmpeg(endpoint, media) {
++ if (typeof JSMpeg === 'undefined') await loadScript('/js/jsmpeg.min.js');
++ const port = endpoint.videoPort || endpoint.video_port || endpoint.wsPort || 9710;
++ const url = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.hostname}:${port}`;
++ const canvas = document.createElement('canvas');
++ media.appendChild(canvas);
++ const player = new JSMpeg.Player(url, { canvas, audio: st.unmuted, disableGl: false, pauseWhenHidden: true, videoBufferSize: 1024 * 1024 });
++ return {
++ pause() { try { player.pause(); } catch { /* */ } },
++ resume() { try { player.play(); } catch { /* */ } },
++ setMuted(m) { try { if (player.audioOut) player.audioOut.volume = m ? 0 : 1; if (!m && player.audioOut && player.audioOut.unlock) player.audioOut.unlock(); } catch { /* */ } },
++ destroy() { try { player.destroy(); } catch { /* */ } canvas.remove(); },
++ };
++ }
++ function playFrames(stream, media) {
++ const img = document.createElement('img');
++ img.className = 'hf-frame'; img.alt = ''; img.decoding = 'async';
++ img.src = stream.thumbnail_url || '';
++ img.onerror = () => { img.style.display = 'none'; }; // a frame that fails to load shows nothing, not a broken-image icon
++ media.appendChild(img);
++ const tick = () => {
++ if (!st.current || !st.visible || !stream.thumbnail_url) return;
++ const next = new Image();
++ const src = stream.thumbnail_url + (stream.thumbnail_url.includes('?') ? '&' : '?') + 't=' + Date.now();
++ next.onload = () => { img.style.opacity = '0'; setTimeout(() => { img.src = src; img.style.opacity = '1'; }, 250); };
++ next.src = src;
++ };
++ st.frame = setInterval(tick, FRAME_MS);
++ return { pause() { }, resume() { }, setMuted() { }, destroy() { clearInterval(st.frame); st.frame = 0; img.remove(); } };
++ }
++
++ // ── Render ─────────────────────────────────────────────────────────────────
++ function render(d) {
++ const s = d.stream;
++ const m = d.moment;
++ const chan = typeof channelPath === 'function' ? channelPath(s.username, s.managed_stream_slug || s.managed_stream_id || null) : `/@${s.username}`;
++ const viewers = Number(s.total_viewer_count || s.viewer_count || 0);
++ const up = (typeof formatUptime === 'function' && s.started_at) ? formatUptime(s.started_at) : '';
++ const canPlay = !constrained() && (s.protocol === 'rtmp' || s.protocol === 'jsmpeg');
++ st.el.innerHTML = `
++ <div class="hf" data-stream-id="${escText(s.id)}">
++ <div class="hf-media">
++ ${s.thumbnail_url ? `<div class="hf-poster" style="background-image:url('${escText(s.thumbnail_url)}')"></div>` : ''}
++ <span class="hf-live"><i class="fa-solid fa-circle"></i> LIVE</span>
++ <span class="hf-viewers"><i class="fa-solid fa-eye"></i> ${viewers.toLocaleString()}</span>
++ <a class="hf-media-link" href="${escText(chan)}" onclick="return handleLinkClick(event, '${escText(chan)}')" aria-label="Watch ${escText(s.display_name || s.username)}"></a>
++ ${canPlay ? `<button type="button" class="hf-sound" aria-pressed="${st.unmuted}"><i class="fa-solid ${st.unmuted ? 'fa-volume-high' : 'fa-volume-xmark'}"></i> ${st.unmuted ? 'Sound on' : 'Unmute'}</button>` : `<span class="hf-preview-note">${constrained() ? 'Live frames · saving data' : 'Live frames · tap to watch'}</span>`}
++ </div>
++ <div class="hf-panel">
++ <div class="hf-kicker"><b><i class="fa-solid fa-satellite-dish"></i> Featured live</b><button type="button" class="hf-hide" title="Hide the featured stream (you can bring it back from the Live Now header)" aria-label="Hide the featured stream"><i class="fa-solid fa-xmark"></i></button></div>
++ <h3 class="hf-title">${escText(s.title || 'Untitled Stream')}</h3>
++ <a class="hf-who" href="${escText(chan)}" onclick="return handleLinkClick(event, '${escText(chan)}')">
++ ${typeof _avatarSpan === 'function' ? _avatarSpan(s.avatar_url, s.username, s.profile_color) : ''}
++ <b>${escText(s.display_name || s.username)}</b>
++ ${s.category ? `<span class="hf-tag">${escText(s.category)}</span>` : ''}
++ ${up ? `<span class="muted"><i class="fa-solid fa-clock"></i> ${escText(up)}</span>` : ''}
++ </a>
++ ${m && m.description ? `<div class="hf-now"><div 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></div>${escText(m.description)}</div>` : ''}
++ ${s.ai_overview_short || s.ai_overview ? `<div class="hf-overview">${escText(s.ai_overview_short || s.ai_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 &amp; chat</a>
++ ${d.count > 1 ? `<button type="button" class="hf-next"><i class="fa-solid fa-forward"></i> Next <small>${d.count} live</small></button>` : ''}
++ <span class="hf-chat"><i class="fa-solid fa-comments"></i> Chat is open to everyone — no account needed</span>
++ </div>
++ </div>
++ </div>`;
++ $('.hf-hide').addEventListener('click', () => setOff(true));
++ const sound = $('.hf-sound');
++ if (sound) sound.addEventListener('click', () => {
++ st.unmuted = !st.unmuted;
++ if (st.player) st.player.setMuted(!st.unmuted);
++ sound.setAttribute('aria-pressed', String(st.unmuted));
++ sound.innerHTML = `<i class="fa-solid ${st.unmuted ? 'fa-volume-high' : 'fa-volume-xmark'}"></i> ${st.unmuted ? 'Sound on' : 'Unmute'}`;
++ });
++ const next = $('.hf-next');
++ if (next) next.addEventListener('click', () => { st.skip = s.id; load(true); });
++ startPlayer(s);
++ }
++
++ async function startPlayer(s) {
++ destroyPlayer();
++ const media = $('.hf-media');
++ if (!media) return;
++ const gen = ++st.gen;
++ let p = null;
++ try {
++ if (constrained()) p = playFrames(s, media);
++ else if (s.protocol === 'rtmp' && s.endpoint && s.endpoint.flvUrl) p = await playFlv(s.endpoint.flvUrl, media);
++ else if (s.protocol === 'jsmpeg' && s.endpoint) p = await playJsmpeg(s.endpoint, media);
++ else p = playFrames(s, media);
++ } catch {
++ p = playFrames(s, media);
++ }
++ if (gen !== st.gen) { try { p.destroy(); } catch { /* */ } return; }
++ st.player = p;
++ if (!st.visible || document.hidden) p.pause();
++ }
++
++ /** Only the parts that change between polls; the player stays put unless the stream changed. */
++ function update(d) {
++ const s = d.stream;
++ const v = $('.hf-viewers');
++ if (v) v.innerHTML = `<i class="fa-solid fa-eye"></i> ${Number(s.total_viewer_count || s.viewer_count || 0).toLocaleString()}`;
++ const t = $('.hf-title');
++ if (t && t.textContent !== (s.title || 'Untitled Stream')) t.textContent = s.title || 'Untitled Stream';
++ const now = $('.hf-now');
++ const m = d.moment;
++ if (m && m.description) {
++ const text = m.description;
++ if (now) {
++ const body = now.childNodes[now.childNodes.length - 1];
++ if (body && body.nodeType === 3 && body.textContent !== text) {
++ now.classList.add('is-updating');
++ setTimeout(() => { body.textContent = text; const span = now.querySelector('.hf-now-head span'); if (span) span.textContent = typeof timeAgo === 'function' && m.captured_at ? timeAgo(m.captured_at) : ''; now.classList.remove('is-updating'); }, 300);
++ }
++ }
++ }
++ if (st.player && st.player.video === undefined && s.thumbnail_url && st.current) st.current.thumbnail_url = s.thumbnail_url;
++ }
++
++ async function load(force) {
++ if (!st.el || isOff()) return;
++ let d;
++ try { d = await api('/home/featured' + (st.skip ? `?not=${encodeURIComponent(st.skip)}` : '')); } catch { return; }
++ st.skip = null;
++ if (!d || !d.stream) { stop(); return; }
++ const changed = !st.current || st.current.id !== d.stream.id || force;
++ if (changed) {
++ const media = $('.hf-media');
++ if (media) { media.classList.add('is-switching'); await new Promise((r) => setTimeout(r, 220)); }
++ st.current = d.stream;
++ render(d);
++ } else update(d);
++ st.el.hidden = false;
++ }
++
++ function watchVisibility() {
++ if (st.io) return;
++ const onVis = () => { if (!st.player) return; (document.hidden || !st.visible) ? st.player.pause() : st.player.resume(); };
++ document.addEventListener('visibilitychange', onVis);
++ try {
++ st.io = new IntersectionObserver((entries) => { st.visible = !!(entries[0] && entries[0].isIntersecting); onVis(); }, { threshold: 0.15 });
++ st.io.observe(st.el);
++ } catch { st.io = { disconnect() { } }; }
++ document.addEventListener('ov:page', (e) => { if (!e.detail || e.detail.page !== 'home') stop(); });
++ }
++
++ /** Called by the home page once the live list has arrived. */
++ function boot(hasLive) {
++ st.hasLive = !!hasLive;
++ const t = document.getElementById('home-featured-toggle');
++ if (t) { t.hidden = !(isOff() && st.hasLive); t.onclick = () => setOff(false); }
++ st.el = document.getElementById('home-featured');
++ if (!st.el || !st.hasLive || isOff()) { stop(); return; }
++ watchVisibility();
++ load(false);
++ clearInterval(st.poll);
++ st.poll = setInterval(() => { if (!document.hidden) load(false); }, POLL_MS);
++ }
++ function stop() {
++ clearInterval(st.poll); st.poll = 0;
++ destroyPlayer();
++ st.current = null;
++ if (st.el) { st.el.hidden = true; st.el.innerHTML = ''; }
++ }
++
++ window.homeFeatured = { boot, stop, setOff, isOff };
++})();
ddiff --git a/public/js/home-tour.js b/public/js/home-tour.js
iindex 5f4e31b1..e840b017 100644
---- a/public/js/home-tour.js
++++ b/public/js/home-tour.js
@@@ -10,11 +10,15 @@
*/
(function () {
'use strict';
++ // Every way in. The pairs are real ingest paths (server/streaming/*): WebRTC from the browser,
++ // RTMP and WHIP from encoders, the JSMPEG relay for scripts and small boards.
const SRC = [
-- { id: 'cam', icon: 'fa-solid fa-video', label: 'Your cam', sub: 'browser · phone' },
++ { id: 'cam', icon: 'fa-solid fa-video', label: 'Your cam', sub: 'browser · phone · WebRTC' },
{ id: 'screen', icon: 'fa-solid fa-display', label: 'Your screen', sub: 'share from the browser' },
-- { id: 'robot', icon: 'fa-solid fa-robot', label: 'Your robot', sub: 'viewer-controlled' },
-- { id: 'obs', icon: 'fa-solid fa-desktop', label: 'OBS / RTMP', sub: 'or WHIP' },
++ { id: 'obs', icon: 'fa-solid fa-sliders', label: 'OBS · Streamlabs', sub: 'RTMP or WHIP' },
++ { id: 'ffmpeg', icon: 'fa-solid fa-terminal', label: 'ffmpeg · scripts', sub: 'RTMP · WHIP · JSMPEG' },
++ { id: 'robot', icon: 'fa-solid fa-robot', label: 'Your robot · Pi', sub: 'viewer-controlled' },
++ { id: 'encoder', icon: 'fa-solid fa-microchip', label: 'Any encoder', sub: 'hardware · Larix · drones' },
];
const DST = [
{ id: 'twitch', icon: 'fa-brands fa-twitch', label: 'Twitch', color: '#a970ff' },
ddiff --git a/public/js/ov-density.js b/public/js/ov-density.js
iindex 99394d78..5af9d817 100644
---- a/public/js/ov-density.js
++++ b/public/js/ov-density.js
@@@ -170,6 +170,11 @@
}
}
const effective = active || Math.min(natural, max);
++ if (grid._ovdEffective !== effective) {
++ grid._ovdEffective = effective;
++ // Rails fill their last row to this count (app-home.js listens).
++ try { grid.dispatchEvent(new CustomEvent('ovd:columns', { detail: { columns: effective } })); } catch { /* */ }
++ }
for (const b of ctrl.querySelectorAll('.ovd-btn')) {
const pressed = String(Number(b.dataset.n) === effective);
if (b.getAttribute('aria-pressed') !== pressed) b.setAttribute('aria-pressed', pressed);
@@@ -213,5 +218,14 @@
/** Re-apply after a re-render replaced the grid's children. */
function apply(grid, key) { if (grid && typeof grid._ovdRender === 'function') grid._ovdRender(); }
-- window.OVDensity = { attach, apply, MIN_CARD, HARD_MAX };
++ /** How many cards a grid shows per row right now (the reader's choice, else what fits). */
++ function columns(grid) {
++ if (!grid) return 1;
++ const forced = Number(grid.dataset.ovdCols) || 0;
++ if (forced) return forced;
++ if (grid._ovdEffective) return grid._ovdEffective;
++ return autoColumns(grid, HARD_MAX);
++ }
++
++ window.OVDensity = { attach, apply, columns, MIN_CARD, HARD_MAX };
})();
ddiff --git a/public/js/voice-channels.js b/public/js/voice-channels.js
iindex df86d4d8..05d9cddc 100644
---- a/public/js/voice-channels.js
++++ b/public/js/voice-channels.js
@@@ -22,135 +22,151 @@ const vcState = {
testing: false,
settingsOpen: false,
pollTimer: null,
-- pollInterval: 4000,
++ pollInterval: 30000, // safety net; the server pushes the list on every change
lastJoinedChannelId: null,
++ joinSeq: 0,
++ status: 'idle',
};
/* ── Channel List ──────────────────────────────────────────── */
++let _vcFetchSeq = 0;
async function vcFetchChannels() {
++ const seq = ++_vcFetchSeq;
try {
-- const resp = await fetch('/api/streams/voice-channels');
-- if (!resp.ok) return;
++ const token = typeof _getAuthToken === 'function' ? _getAuthToken() : null;
++ const resp = await fetch('/api/streams/voice-channels', { headers: token ? { Authorization: `Bearer ${token}` } : {} });
++ if (!resp.ok || seq !== _vcFetchSeq) return; // a newer answer already landed
const data = await resp.json();
-- vcState.channels = data.channels || data || [];
-- vcRenderChannelList();
++ vcApplyChannels(data.channels || data || []);
} catch {}
}
++/** The list from a fetch or a server push. */
++function vcApplyChannels(channels) {
++ vcState.channels = Array.isArray(channels) ? channels : [];
++ vcRenderChannelList();
++ vcUpdateMiniBar();
++}
++/**
++ * The channel list is updated in place, keyed by channel id: an item that is still there keeps its
++ * node (a click that straddles an update is not lost, nothing flickers), new ones slide in, gone
++ * ones fade out.
++ */
function vcRenderChannelList() {
const list = document.getElementById('vc-channel-list');
if (!list) return;
-- list.innerHTML = '';
--
-- const joinedId = callState.joined ? (callState.channelId || null) : null;
--
++ const joinedId = (callState.joined || callState.connecting) ? (callState.channelId || null) : null;
++ const have = new Map([...list.querySelectorAll(':scope > .vc-channel-item')].map((el) => [el.dataset.channelId, el]));
++ let cursor = null;
for (const ch of vcState.channels) {
-- const item = document.createElement('div');
-- item.className = 'vc-channel-item' + (ch.id === joinedId ? ' joined' : '');
-- item.dataset.channelId = ch.id;
--
-- // Icon
-- const icon = document.createElement('span');
-- icon.className = 'vc-channel-icon';
-- if (ch.streamId) {
-- icon.innerHTML = '<i class="fa-solid fa-broadcast-tower"></i>';
-- icon.title = 'Stream channel';
-- } else if (ch.permanent) {
-- icon.innerHTML = '<i class="fa-solid fa-globe"></i>';
-- icon.title = 'Public channel';
++ const fresh = vcChannelItem(ch, joinedId);
++ const old = have.get(String(ch.id));
++ let node;
++ if (old) {
++ have.delete(String(ch.id));
++ if (old.dataset.sig !== fresh.dataset.sig) { old.replaceWith(fresh); node = fresh; } else node = old;
} else {
-- icon.innerHTML = '<i class="fa-solid fa-headset"></i>';
++ fresh.classList.add('is-entering');
++ node = fresh;
}
++ const anchor = cursor ? cursor.nextSibling : list.firstChild;
++ if (node !== anchor) list.insertBefore(node, anchor);
++ cursor = node;
++ }
++ for (const [, el] of have) {
++ el.classList.add('is-leaving');
++ el.style.pointerEvents = 'none';
++ setTimeout(() => el.remove(), 220);
++ }
++}
-- // Name + mode badge
-- const nameRow = document.createElement('div');
-- nameRow.className = 'vc-channel-name-row';
-- const name = document.createElement('span');
-- name.className = 'vc-channel-name';
-- name.textContent = ch.name;
-- nameRow.appendChild(name);
--
-- const mode = document.createElement('span');
-- mode.className = 'vc-channel-mode';
-- mode.textContent = ch.mode === 'mic' ? 'Voice' : ch.mode === 'cam+mic' ? 'Video' : 'Voice+Cam';
-- nameRow.appendChild(mode);
--
-- // Participant count badge (top-right)
-- const count = document.createElement('span');
-- count.className = 'vc-channel-count';
-- const pc = ch.participantCount || 0;
-- count.innerHTML = `<i class="fa-solid fa-user"></i> ${pc}`;
-- if (pc >= (ch.maxParticipants || 15)) count.classList.add('full');
--
-- // Participant names list (avatar + name per row)
-- const participantList = document.createElement('div');
-- participantList.className = 'vc-participant-list';
-- if (ch.participants && ch.participants.length) {
-- ch.participants.slice(0, 6).forEach(p => {
-- const row = document.createElement('div');
-- row.className = 'vc-participant-row';
--
-- const av = document.createElement('div');
-- av.className = 'vc-avatar-mini';
-- if (p.avatarUrl) {
-- av.style.backgroundImage = `url(${p.avatarUrl})`;
-- } else {
-- const initial = (p.displayName || p.username || p.anonId || '?')[0].toUpperCase();
-- if (p.profileColor) av.style.background = p.profileColor;
-- av.textContent = initial;
-- }
--
-- const pname = document.createElement('span');
-- pname.className = 'vc-participant-name';
-- pname.textContent = p.displayName || p.username || p.anonId || 'Anonymous';
--
-- row.appendChild(av);
-- row.appendChild(pname);
--
-- if (p.muted) {
-- const mu = document.createElement('i');
-- mu.className = 'fa-solid fa-microphone-slash vc-participant-muted';
-- row.appendChild(mu);
-- } else if (p.speaking) {
-- row.classList.add('speaking');
-- }
--
-- participantList.appendChild(row);
-- });
-- if (ch.participants.length > 6) {
-- const more = document.createElement('div');
-- more.className = 'vc-participant-more';
-- more.textContent = `+${ch.participants.length - 6} more`;
-- participantList.appendChild(more);
-- }
++function vcChannelItem(ch, joinedId) {
++ const item = document.createElement('div');
++ item.className = 'vc-channel-item' + (ch.id === joinedId ? ' joined' : '') + (ch.private ? ' is-private' : '');
++ item.dataset.channelId = ch.id;
++
++ const icon = document.createElement('span');
++ icon.className = 'vc-channel-icon';
++ if (ch.streamId) { icon.innerHTML = '<i class="fa-solid fa-broadcast-tower"></i>'; icon.title = 'Stream channel'; }
++ else if (ch.permanent) { icon.innerHTML = '<i class="fa-solid fa-globe"></i>'; icon.title = 'Public channel'; }
++ else if (ch.private) { icon.innerHTML = '<i class="fa-solid fa-phone"></i>'; icon.title = 'Private call'; }
++ else icon.innerHTML = '<i class="fa-solid fa-headset"></i>';
++
++ const nameRow = document.createElement('div');
++ nameRow.className = 'vc-channel-name-row';
++ const name = document.createElement('span');
++ name.className = 'vc-channel-name';
++ name.textContent = ch.name;
++ nameRow.appendChild(name);
++ const mode = document.createElement('span');
++ mode.className = 'vc-channel-mode';
++ mode.textContent = ch.mode === 'mic' ? 'Voice' : ch.mode === 'cam+mic' ? 'Video' : 'Voice+Cam';
++ nameRow.appendChild(mode);
++
++ const count = document.createElement('span');
++ count.className = 'vc-channel-count';
++ const pc = ch.participantCount || 0;
++ count.innerHTML = `<i class="fa-solid fa-user"></i> ${pc}`;
++ if (pc >= (ch.maxParticipants || 8)) count.classList.add('full');
++
++ const participantList = document.createElement('div');
++ participantList.className = 'vc-participant-list';
++ const parts = ch.participants || [];
++ parts.slice(0, 6).forEach(p => {
++ const row = document.createElement('div');
++ row.className = 'vc-participant-row';
++ const av = document.createElement('div');
++ av.className = 'vc-avatar-mini';
++ if (p.avatarUrl) {
++ av.style.backgroundImage = `url("${String(p.avatarUrl).replace(/["\\]/g, '')}")`;
++ } else {
++ const initial = (p.displayName || p.username || p.anonId || '?')[0].toUpperCase();
++ if (p.profileColor) av.style.background = p.profileColor;
++ av.textContent = initial;
}
--
-- const body = document.createElement('div');
-- body.className = 'vc-channel-body';
-- body.appendChild(nameRow);
-- body.appendChild(participantList);
--
-- item.appendChild(icon);
-- item.appendChild(body);
-- item.appendChild(count);
--
-- item.onclick = () => vcSelectChannel(ch.id);
-- list.appendChild(item);
++ const pname = document.createElement('span');
++ pname.className = 'vc-participant-name';
++ pname.textContent = p.displayName || p.username || p.anonId || 'Anonymous';
++ row.appendChild(av);
++ row.appendChild(pname);
++ if (p.muted) {
++ const mu = document.createElement('i');
++ mu.className = 'fa-solid fa-microphone-slash vc-participant-muted';
++ row.appendChild(mu);
++ } else if (p.speaking) row.classList.add('speaking');
++ participantList.appendChild(row);
++ });
++ if (parts.length > 6) {
++ const more = document.createElement('div');
++ more.className = 'vc-participant-more';
++ more.textContent = `+${parts.length - 6} more`;
++ participantList.appendChild(more);
}
++
++ const body = document.createElement('div');
++ body.className = 'vc-channel-body';
++ body.appendChild(nameRow);
++ body.appendChild(participantList);
++ item.appendChild(icon);
++ item.appendChild(body);
++ item.appendChild(count);
++ // What a re-render compares: identical content keeps the existing node.
++ item.dataset.sig = [ch.name, ch.mode, pc, ch.maxParticipants, ch.id === joinedId ? 1 : 0, ...parts.map(p => `${p.peerId}:${p.displayName || p.username || p.anonId}:${p.muted ? 1 : 0}:${p.speaking ? 1 : 0}:${p.avatarUrl || ''}`)].join('|');
++ item.onclick = () => vcSelectChannel(ch.id);
++ return item;
}
function vcSelectChannel(channelId) {
-- // If already connected to this channel, ignore
-- if (callState.joined && callState.channelId === channelId) return;
++ // Already in (or joining) this channel
++ if ((callState.joined || callState.connecting) && callState.channelId === channelId) return;
const ch = vcState.channels.find(c => c.id === channelId);
if (!ch) { vcFetchChannels(); return; }
-- // Leave current channel first if in a different one
-- if (callState.joined && callState.channelId && callState.channelId !== channelId) {
-- vcLeave();
-- }
++ // Leave the current one first, even one that is still connecting, so a quick second click
++ // cannot leave you in A with B's name on the panel.
++ if (callState.joined || callState.connecting) vcLeave({ switching: true });
vcJoinChannel(ch);
}
@@@ -159,6 +175,7 @@ function vcSelectChannel(channelId) {
async function vcJoinChannel(ch) {
if (!ch) return;
++ const seq = ++vcState.joinSeq;
// Configure callState for channel-based join
callState.channelId = ch.id;
@@@ -171,9 +188,13 @@ async function vcJoinChannel(ch) {
vcState.lastJoinedChannelId = ch.id;
-- // Show connected panel immediately
++ // Show the panel at once, empty and marked "connecting" (the previous channel's tiles used to
++ // stay on screen until the new welcome arrived).
const connPanel = document.getElementById('vc-connected-panel');
-- if (connPanel) connPanel.style.display = '';
++ if (connPanel) { connPanel.style.display = ''; connPanel.classList.add('is-open'); }
++ const grid = document.getElementById('vc-participants-grid');
++ if (grid) grid.innerHTML = '';
++ vcSetStatus('connecting');
const channelLabel = document.getElementById('vc-connected-channel');
if (channelLabel) channelLabel.textContent = ch.name;
@@@ -184,9 +205,6 @@ async function vcJoinChannel(ch) {
const camSwitchGroup = document.getElementById('vc-cam-switch-group');
if (camSwitchGroup) camSwitchGroup.style.display = ch.mode === 'mic' ? 'none' : '';
-- // Populate in-call device switchers (non-blocking)
-- vcEnumerateDevices(ch.mode).catch(() => {});
--
// Sync in-call input mode settings
const inputModeSwitch = document.getElementById('vc-input-mode-switch');
if (inputModeSwitch) inputModeSwitch.value = callState.inputMode;
@@@ -195,6 +213,7 @@ async function vcJoinChannel(ch) {
try {
await joinCall();
} catch (err) {
++ if (seq !== vcState.joinSeq) return;
console.error('[VC] Join failed:', err);
toast(`Failed to join voice channel: ${err.message || 'Unknown error'}`, 'error');
callState.channelId = null;
@@@ -202,7 +221,10 @@ async function vcJoinChannel(ch) {
if (connPanel) connPanel.style.display = 'none';
return;
}
++ if (seq !== vcState.joinSeq) return; // another channel was chosen meanwhile
++ // Device pickers, now that the microphone is open and labels are readable.
++ vcEnumerateDevices(ch.mode).catch(() => {});
vcRenderChannelList();
if (typeof updateChatModeVoiceOption === 'function') updateChatModeVoiceOption(true);
vcUpdateMiniBar();
@@@ -210,68 +232,15 @@ async function vcJoinChannel(ch) {
/* ── Device Setup Panel ────────────────────────────────────── */
--async function vcShowSetup(channel) {
-- const panel = document.getElementById('vc-setup-panel');
-- const connected = document.getElementById('vc-connected-panel');
-- if (!panel) return;
-- if (connected) connected.style.display = 'none';
--
-- const title = document.getElementById('vc-setup-title');
-- if (title) title.textContent = `Join "${channel.name}"`;
--
-- // Show/hide camera selector based on mode
-- const camGroup = document.getElementById('vc-cam-group');
-- if (camGroup) camGroup.style.display = channel.mode === 'mic' ? 'none' : '';
--
-- // Show camera-off toggle for modes that support camera
-- const camOffToggle = document.getElementById('vc-cam-off-toggle');
-- if (camOffToggle) camOffToggle.style.display = channel.mode === 'mic' ? 'none' : '';
-- // Always default to camera off — user must explicitly opt in
-- const startCamOff = document.getElementById('vc-start-cam-off');
-- if (startCamOff) startCamOff.checked = true;
--
-- // Set current input mode from callState (persisted settings)
-- const inputMode = document.getElementById('vc-input-mode');
-- if (inputMode) inputMode.value = callState.inputMode;
-- vcOnInputModeChange(callState.inputMode);
--
-- const pttKey = document.getElementById('vc-ptt-key');
-- if (pttKey) pttKey.value = callState.pttKey;
--
-- const vadThreshold = document.getElementById('vc-vad-threshold');
-- if (vadThreshold) vadThreshold.value = callState.vadThreshold;
-- const vadValue = document.getElementById('vc-vad-value');
-- if (vadValue) vadValue.textContent = `${callState.vadThreshold}%`;
--
-- panel.style.display = '';
--
-- // Enumerate devices (may prompt for permission if not yet granted)
-- await vcEnumerateDevices(channel.mode);
--
-- // Mic preview is optional — user can click "Test Mic" to start it
-- // vcStartPreview() is intentionally not called here so joining works
-- // even when mic permission hasn't been granted yet.
--}
--
async function vcEnumerateDevices(mode) {
try {
// First try enumerating without a temp stream — if permission was already
// granted, browsers return labeled devices. This avoids acquiring a temp
// getUserMedia stream that can steal the audio device from an active broadcast
// on Linux/PipeWire.
-- let devices = await navigator.mediaDevices.enumerateDevices();
-- const hasLabels = devices.some(d => d.kind === 'audioinput' && d.label);
--
-- if (!hasLabels) {
-- // No labels yet — need a temp stream to trigger permission prompt.
-- // Skip if actively broadcasting to avoid device contention.
-- const isBroadcasting = typeof isStreaming === 'function' && isStreaming();
-- if (!isBroadcasting) {
-- const tempStream = await navigator.mediaDevices.getUserMedia({ audio: true });
-- tempStream.getTracks().forEach(t => t.stop());
-- devices = await navigator.mediaDevices.enumerateDevices();
-- }
-- }
++ // Runs after the call's own microphone is open, so labels are readable without opening a
++ // second one (two getUserMedia calls at once fought over the device on PipeWire).
++ const devices = await navigator.mediaDevices.enumerateDevices();
const audioInputs = devices.filter(d => d.kind === 'audioinput');
const videoInputs = devices.filter(d => d.kind === 'videoinput');
@@@ -304,191 +273,6 @@ async function vcEnumerateDevices(mode) {
}
}
--async function vcStartPreview(mode) {
-- vcStopPreview();
--
-- try {
-- const constraints = {
-- audio: { deviceId: callState.selectedMic !== 'default' ? { exact: callState.selectedMic } : undefined }
-- };
-- if (mode !== 'mic') {
-- constraints.video = {
-- deviceId: callState.selectedCam !== 'default' ? { exact: callState.selectedCam } : undefined,
-- width: { ideal: 240 }, height: { ideal: 180 },
-- };
-- }
--
-- vcState.previewStream = await navigator.mediaDevices.getUserMedia(constraints);
--
-- // Show video preview in avatar area
-- const avatarEl = document.getElementById('vc-setup-avatar');
-- if (avatarEl && mode !== 'mic') {
-- const existingVideo = avatarEl.querySelector('video');
-- if (existingVideo) existingVideo.remove();
-- const video = document.createElement('video');
-- video.autoplay = true;
-- video.muted = true;
-- video.playsInline = true;
-- video.className = 'vc-preview-video';
-- video.srcObject = vcState.previewStream;
-- avatarEl.insertBefore(video, avatarEl.firstChild);
-- } else if (avatarEl) {
-- // Show user avatar / initial
-- const existingVideo = avatarEl.querySelector('video');
-- if (existingVideo) existingVideo.remove();
-- const av = avatarEl.querySelector('.vc-setup-avatar-img');
-- if (!av) {
-- const d = document.createElement('div');
-- d.className = 'vc-setup-avatar-img';
-- if (typeof currentUser !== 'undefined' && currentUser?.avatar_url) {
-- d.style.backgroundImage = `url(${currentUser.avatar_url})`;
-- } else {
-- d.textContent = (typeof currentUser !== 'undefined' && currentUser?.username || 'U')[0].toUpperCase();
-- }
-- avatarEl.insertBefore(d, avatarEl.firstChild);
-- }
-- }
--
-- // Setup mic level meter
-- const Ctx = window.AudioContext || window.webkitAudioContext;
-- if (Ctx) {
-- vcState.previewAudioCtx = new Ctx();
-- vcState.previewSource = vcState.previewAudioCtx.createMediaStreamSource(vcState.previewStream);
-- vcState.previewAnalyser = vcState.previewAudioCtx.createAnalyser();
-- vcState.previewAnalyser.fftSize = 512;
-- vcState.previewAnalyser.smoothingTimeConstant = 0.7;
-- vcState.previewSource.connect(vcState.previewAnalyser);
--
-- vcState.previewLevelInterval = setInterval(() => {
-- if (!vcState.previewAnalyser) return;
-- const bins = new Uint8Array(vcState.previewAnalyser.frequencyBinCount);
-- vcState.previewAnalyser.getByteFrequencyData(bins);
-- let sum = 0;
-- for (let i = 0; i < bins.length; i++) sum += bins[i];
-- const avg = bins.length ? sum / bins.length : 0;
-- const percent = Math.round((avg / 255) * 100);
--
-- const bar = document.getElementById('vc-meter-bar');
-- if (bar) bar.style.width = `${percent}%`;
--
-- const ring = document.getElementById('vc-mic-level');
-- if (ring) {
-- ring.classList.toggle('active', percent > callState.vadThreshold);
-- }
-- }, 60);
-- }
-- } catch (err) {
-- console.warn('[VC] Preview stream failed:', err.message);
-- }
--}
--
--function vcStopPreview() {
-- if (vcState.previewLevelInterval) {
-- clearInterval(vcState.previewLevelInterval);
-- vcState.previewLevelInterval = null;
-- }
-- if (vcState.previewSource) {
-- try { vcState.previewSource.disconnect(); } catch {}
-- vcState.previewSource = null;
-- }
-- if (vcState.previewTestNode) {
-- try { vcState.previewTestNode.disconnect(); } catch {}
-- vcState.previewTestNode = null;
-- }
-- vcState.previewAnalyser = null;
-- if (vcState.previewAudioCtx) {
-- try { vcState.previewAudioCtx.close(); } catch {}
-- vcState.previewAudioCtx = null;
-- }
-- if (vcState.previewStream) {
-- vcState.previewStream.getTracks().forEach(t => t.stop());
-- vcState.previewStream = null;
-- }
-- vcState.testing = false;
--
-- const avatarEl = document.getElementById('vc-setup-avatar');
-- if (avatarEl) {
-- const video = avatarEl.querySelector('video');
-- if (video) { video.srcObject = null; video.remove(); }
-- }
--
-- const bar = document.getElementById('vc-meter-bar');
-- if (bar) bar.style.width = '0%';
--
-- const testBtn = document.getElementById('vc-test-btn');
-- if (testBtn) testBtn.innerHTML = '<i class="fa-solid fa-play"></i> Test Mic';
--}
--
--function vcCancelSetup() {
-- vcStopPreview();
-- vcState.selectedChannelId = null;
--
-- const panel = document.getElementById('vc-setup-panel');
-- if (panel) panel.style.display = 'none';
-- vcRenderChannelList();
--}
--
--function vcToggleTest() {
-- if (!vcState.previewStream || !vcState.previewAudioCtx) {
-- // Preview not ready — attempt to start it then retry
-- const ch = vcState.channels.find(c => c.id === vcState.selectedChannelId);
-- if (!vcState.previewStream && ch) {
-- toast('Starting microphone preview…', 'info');
-- vcStartPreview(ch.mode || 'mic').then(() => {
-- if (vcState.previewStream && vcState.previewAudioCtx) {
-- vcToggleTest(); // retry after preview started
-- } else {
-- toast('Could not access microphone — check permissions', 'error');
-- }
-- }).catch(() => {
-- toast('Could not access microphone — check permissions', 'error');
-- });
-- } else {
-- toast('Microphone not available — check permissions and try again', 'error');
-- }
-- return;
-- }
--
-- if (vcState.testing) {
-- // Stop test
-- if (vcState.previewTestNode) {
-- try { vcState.previewTestNode.disconnect(); } catch {}
-- vcState.previewTestNode = null;
-- }
-- vcState.testing = false;
-- const btn = document.getElementById('vc-test-btn');
-- if (btn) btn.innerHTML = '<i class="fa-solid fa-play"></i> Test Mic';
-- } else {
-- // Start test — route mic to speakers
-- try {
-- if (!vcState.previewSource) {
-- toast('Microphone source not available — try re-selecting your mic', 'error');
-- return;
-- }
--
-- // Resume AudioContext if suspended (browsers require user gesture)
-- if (vcState.previewAudioCtx.state === 'suspended') {
-- vcState.previewAudioCtx.resume().catch(() => {});
-- }
--
-- const dest = vcState.previewAudioCtx.destination;
-- vcState.previewTestNode = vcState.previewAudioCtx.createGain();
-- vcState.previewTestNode.gain.value = 0.8;
-- vcState.previewSource.connect(vcState.previewTestNode);
-- vcState.previewTestNode.connect(dest);
-- vcState.testing = true;
-- const btn = document.getElementById('vc-test-btn');
-- if (btn) btn.innerHTML = '<i class="fa-solid fa-stop"></i> Stop Test';
-- toast('You should hear your microphone through your speakers', 'info');
-- } catch (err) {
-- console.warn('[VC] Test mic failed:', err.message);
-- toast(`Mic test failed: ${err.message}`, 'error');
-- }
-- }
--}
--
--/* ── Input Mode Handlers ───────────────────────────────────── */
--
function vcOnInputModeChange(value) {
if (!['open', 'ptt', 'vad'].includes(value)) return;
callState.inputMode = value;
@@@ -560,15 +344,8 @@ function vcOnVadChange(value) {
/* ── Join / Leave ──────────────────────────────────────────── */
--async function vcJoinFromSetup() {
-- // Legacy: called from setup panel (no longer shown). Delegate to vcJoinChannel.
-- const channelId = vcState.selectedChannelId;
-- if (!channelId) return;
-- const ch = vcState.channels.find(c => c.id === channelId);
-- if (ch) await vcJoinChannel(ch);
--}
--
--function vcLeave() {
++function vcLeave(opts = {}) {
++ vcState.joinSeq++;
leaveCall();
callState.channelId = null;
callState.vcMode = false;
@@@ -576,7 +353,13 @@ function vcLeave() {
vcState.settingsOpen = false;
const connPanel = document.getElementById('vc-connected-panel');
-- if (connPanel) connPanel.style.display = 'none';
++ if (connPanel) { connPanel.style.display = 'none'; connPanel.classList.remove('is-open'); }
++ const grid = document.getElementById('vc-participants-grid');
++ if (grid) grid.innerHTML = '';
++ // streamId doubles as the channel id in this mode; left set, call.js would ask the stream API
++ // about a channel that is not a stream.
++ callState.streamId = null;
++ vcSetStatus('idle');
const settingsPanel = document.getElementById('vc-incall-settings');
if (settingsPanel) settingsPanel.style.display = 'none';
@@@ -584,13 +367,23 @@ function vcLeave() {
vcState.selectedChannelId = null;
vcRenderChannelList();
-- // Disable voice call chat mode option & switch back to global
-- if (typeof updateChatModeVoiceOption === 'function') updateChatModeVoiceOption(false);
++ // Switching channels keeps the chat where it is; a real leave goes back to global.
++ if (!opts.switching && typeof updateChatModeVoiceOption === 'function') updateChatModeVoiceOption(false);
// Hide mini VC bar
vcUpdateMiniBar();
}
++/** Status pill: connecting / connected / reconnecting. */
++function vcSetStatus(state) {
++ vcState.status = state;
++ const badge = document.querySelector('#vc-connected-panel .vc-connected-badge');
++ if (!badge) return;
++ const label = { connecting: 'Connecting\u2026', connected: 'Voice Connected', reconnecting: 'Reconnecting\u2026' }[state] || 'Voice';
++ badge.className = `vc-connected-badge is-${state}`;
++ badge.innerHTML = `<i class="fa-solid fa-circle"></i> ${label}`;
++}
++
/* ── Controls ──────────────────────────────────────────────── */
function vcToggleMute() {
@@@ -600,14 +393,7 @@ function vcToggleMute() {
}
function vcToggleCamera() {
-- toggleCallCamera();
-- // toggleCallCamera is async, update after a tick
-- setTimeout(vcUpdateControlButtons, 50);
--}
--
--function vcToggleScreenShare() {
-- // Future feature placeholder
-- console.log('[VC] Screen share not yet implemented');
++ toggleCallCamera(); // re-renders the panel itself when the track is up
}
function vcToggleSettings() {
@@@ -684,10 +470,10 @@ function vcRenderUI() {
const grid = document.getElementById('vc-participants-grid');
if (!grid) return;
-- grid.innerHTML = '';
--
-- // Local tile
-- grid.appendChild(_createParticipantTile({
++ // Keyed by peer: a tile that is still there is swapped for its fresh version (video elements are
++ // cached on the peer, so the picture does not restart), newcomers pop in, leavers fade out.
++ const wanted = [];
++ wanted.push(_createParticipantTile({
peerId: callState.myPeerId,
username: callState.localUsername,
displayName: callState.localDisplayName || (typeof currentUser !== 'undefined' ? (currentUser?.display_name || currentUser?.username) : 'You'),
@@@ -704,10 +490,8 @@ function vcRenderUI() {
profileColor: callState.localProfileColor || (typeof currentUser !== 'undefined' ? currentUser?.profile_color : null),
isLocal: true,
}));
--
-- // Remote tiles
for (const [peerId, peer] of callState.peers) {
-- grid.appendChild(_createParticipantTile({
++ wanted.push(_createParticipantTile({
peerId,
username: peer.username,
anonId: peer.anonId,
@@@ -726,8 +510,20 @@ function vcRenderUI() {
videoStream: peer.videoStream,
avatarUrl: peer.avatarUrl,
profileColor: peer.profileColor,
++ connecting: peer.connecting,
}));
}
++ const have = new Map([...grid.querySelectorAll(':scope > .call-participant-tile:not(.is-leaving)')].map((el) => [el.dataset.peerId, el]));
++ let cursor = null;
++ for (const tile of wanted) {
++ const old = have.get(tile.dataset.peerId);
++ if (old) { have.delete(tile.dataset.peerId); if (old._ovLvl) { tile._ovLvl = old._ovLvl; tile.style.setProperty('--lvl', old.style.getPropertyValue('--lvl')); } old.replaceWith(tile); }
++ else tile.classList.add('is-entering');
++ const anchor = cursor ? cursor.nextSibling : grid.firstChild;
++ if (tile !== anchor) grid.insertBefore(tile, anchor);
++ cursor = tile;
++ }
++ for (const [, el] of have) { el.classList.add('is-leaving'); setTimeout(() => el.remove(), 260); }
// Hide camera switch in mic-only mode
const camSwitchGroup = document.getElementById('vc-cam-switch-group');
@@@ -768,7 +564,7 @@ function vcHideCreateModal() {
async function vcCreateChannel() {
const name = (document.getElementById('vc-create-name')?.value || '').trim();
const mode = document.getElementById('vc-create-mode')?.value || 'mic+cam';
-- const maxP = parseInt(document.getElementById('vc-create-max')?.value, 10) || 15;
++ const maxP = Math.min(8, parseInt(document.getElementById('vc-create-max')?.value, 10) || 8);
if (!name) {
const input = document.getElementById('vc-create-name');
@@@ -806,7 +602,7 @@ async function vcCreateChannel() {
function vcStartPolling() {
vcStopPolling();
vcFetchChannels();
-- vcState.pollTimer = setInterval(vcFetchChannels, vcState.pollInterval);
++ vcState.pollTimer = setInterval(() => { if (!document.hidden) vcFetchChannels(); }, vcState.pollInterval);
}
function vcStopPolling() {
ddiff --git a/scripts/perf/cdp.js b/scripts/perf/cdp.js
iindex cc9f1d4e..2af4d6c7 100644
---- a/scripts/perf/cdp.js
++++ b/scripts/perf/cdp.js
@@@ -31,14 +31,14 @@ async function launch(opts = {}, attempt = 0) {
catch (e) { if (attempt < 3) return launch(opts, attempt + 1); throw e; }
}
--async function launchOnce({ width = 1366, height = 900 } = {}) {
++async function launchOnce({ width = 1366, height = 900, args = [] } = {}) {
const bin = findChrome();
if (!bin) throw new Error('Chrome not found (set CHROME_BIN)');
// Random port: a collision with another Chrome just fails this attempt and launch() retries.
const port = 9800 + Math.floor(Math.random() * 5000);
const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-cdp-'));
const proc = spawn(bin, ['--headless=new', `--remote-debugging-port=${port}`, '--no-sandbox', '--no-first-run',
-- `--user-data-dir=${profile}`, `--window-size=${width},${height}`, 'about:blank'], { stdio: 'ignore' });
++ `--user-data-dir=${profile}`, `--window-size=${width},${height}`, ...args, 'about:blank'], { stdio: 'ignore' });
let targets;
for (let i = 0; i < 120; i++) {
try { targets = await getJson(`http://127.0.0.1:${port}/json`); break; } catch { await sleep(250); }
ddiff --git a/server/auth/routes.js b/server/auth/routes.js
iindex 185f85b0..e25e859f 100644
---- a/server/auth/routes.js
++++ b/server/auth/routes.js
@@@ -801,24 +801,27 @@ router.delete('/tokens/:id', requireAuth, (req, res) => {
});
// ── ICE / TURN server config (public — needed by unauthenticated voice chat) ─
++// One STUN server: with TURN present, extra STUN entries only slow candidate gathering (Chrome
++// warns at five or more servers). With TURN_AUTH_SECRET set (coturn `use-auth-secret`), every
++// response carries a credential that expires in an hour instead of the same static one forever.
router.get('/ice-servers', (req, res) => {
-- const servers = [
-- { urls: 'stun:stun.l.google.com:19302' },
-- { urls: 'stun:stun1.l.google.com:19302' },
-- { urls: 'stun:stun2.l.google.com:19302' },
-- ];
++ const servers = [{ urls: 'stun:stun.l.google.com:19302' }];
if (config.turn?.url) {
-- const hasTurnAuth = config.turn.username && config.turn.credential;
-- servers.push(
-- hasTurnAuth
-- ? { urls: config.turn.url, username: config.turn.username, credential: config.turn.credential }
-- : { urls: config.turn.url },
-- hasTurnAuth
-- ? { urls: `${config.turn.url}?transport=tcp`, username: config.turn.username, credential: config.turn.credential }
-- : { urls: `${config.turn.url}?transport=tcp` },
-- );
-- if (!hasTurnAuth && (config.turn.username || config.turn.credential)) {
-- console.warn('[ICE] Incomplete TURN credentials configured; emitting TURN URLs without auth.');
++ const secret = String(process.env.TURN_AUTH_SECRET || '').trim();
++ let username = config.turn.username, credential = config.turn.credential;
++ if (secret) {
++ const expires = Math.floor(Date.now() / 1000) + 3600;
++ username = `${expires}:${req.user ? req.user.id : 'anon'}`;
++ credential = require('crypto').createHmac('sha1', secret).update(username).digest('base64');
++ res.set('Cache-Control', 'private, max-age=300');
++ }
++ const hasTurnAuth = username && credential;
++ // A turn: URL without credentials makes RTCPeerConnection throw in the browser, so a box with
++ // no TURN secret configured gets STUN only.
++ if (hasTurnAuth) {
++ servers.push({ urls: config.turn.url, username, credential }, { urls: `${config.turn.url}?transport=tcp`, username, credential });
++ } else if (username || credential) {
++ console.warn('[ICE] Incomplete TURN credentials configured; TURN left out.');
}
}
res.json({ iceServers: servers });
ddiff --git a/server/home/routes.js b/server/home/routes.js
iindex bab0caba..5e117fb2 100644
---- a/server/home/routes.js
++++ b/server/home/routes.js
@@@ -239,6 +239,45 @@ async function heroStats(timing) {
return stats;
}
++// ── GET /api/home/featured ──────────────────────────────────────────
++// The stream the home page opens on. Everyone live, busiest first, and the pick moves along that
++// list every few minutes so a small stream gets its turn while the busy one is still one click
++// away. `?not=<id>` asks for the next one after that stream (the reader pressed Next).
++const FEATURED_ROTATE_MS = 4 * 60 * 1000;
++router.get('/featured', (req, res) => {
++ res.set('Cache-Control', 'public, max-age=10');
++ try {
++ const live = (db.getLiveStreams() || []).filter((s) => !s.is_nsfw);
++ if (!live.length) return res.json({ stream: null, count: 0 });
++ let idx = Math.floor(Date.now() / FEATURED_ROTATE_MS) % live.length;
++ const not = parseInt(req.query.not, 10);
++ if (not && live.length > 1) {
++ const at = live.findIndex((s) => s.id === not);
++ if (at >= 0) idx = (at + 1) % live.length;
++ }
++ const pick = live[idx];
++ const { publicStream } = require('../web/serializers');
++ const stream = publicStream(pick);
++ // Playback endpoint for the two protocols the home page can play itself; no keys travel.
++ if (pick.protocol === 'rtmp') stream.endpoint = { flvUrl: `/api/streams/rtmp-proxy/${pick.id}.flv` };
++ else if (pick.protocol === 'jsmpeg') {
++ try {
++ const key = pick.managed_stream_key || db.getUserById(pick.user_id)?.stream_key;
++ const info = require('../streaming/jsmpeg-relay').getChannelInfo(key) || {};
++ stream.endpoint = { videoPort: info.videoPort || info.video_port || info.wsPort || null };
++ } catch { stream.endpoint = null; }
++ } else stream.endpoint = null;
++ let moment = null;
++ try {
++ const m = db.getLatestStreamMemory(pick.id);
++ if (m && m.description) moment = { description: m.description, captured_at: m.captured_at || m.created_at || null, thumbnail_url: m.thumbnail_url || null };
++ } catch { /* */ }
++ res.json({ stream, moment, count: live.length, index: idx, next_in_ms: FEATURED_ROTATE_MS - (Date.now() % FEATURED_ROTATE_MS) });
++ } catch (err) {
++ res.status(500).json({ error: 'featured unavailable' });
++ }
++});
++
// ── GET /api/home/stats-live ────────────────────────────────────────
// Just the numbers the hero strip shows, so the home page can keep them current without
// re-fetching the whole hero payload (slogans, collage, moments) every few seconds. The
ddiff --git a/server/streaming/call-server.js b/server/streaming/call-server.js
iindex 68d90d47..e035ae90 100644
---- a/server/streaming/call-server.js
++++ b/server/streaming/call-server.js
@@@ -26,6 +26,9 @@ const cosmetics = require('../monetization/cosmetics');
const WS_HEARTBEAT_MS = 30000;
const MAX_PARTICIPANTS = 8;
++const MAX_SOCKETS_PER_IP = 3; // one household in one channel; never one host filling it
++const KICK_COOLDOWN_MS = 60 * 1000;
++const COSMETIC_TTL_MS = 60 * 1000;
const PUBLIC_CHANNEL_ID = 'public';
class CallServer {
@@@ -42,6 +45,11 @@ class CallServer {
this._nextPeerId = 1;
this._heartbeatInterval = null;
this._inactivityTimer = null;
++ /** `${channelId}:${identity}` → until (ms): a kicked participant waits before rejoining */
++ this.kickCooldown = new Map();
++ /** userId → { at, profile } */
++ this._cosmeticCache = new Map();
++ this._notifyTimer = null;
// Seed the permanent Public channel
this.channels.set(PUBLIC_CHANNEL_ID, {
@@@ -73,6 +81,7 @@ class CallServer {
_cleanupInactiveChannels() {
const ONE_HOUR = 60 * 60 * 1000;
const now = Date.now();
++ for (const [k, until] of this.kickCooldown) if (until <= now) this.kickCooldown.delete(k);
for (const [channelId, ch] of this.channels) {
if (ch.permanent || ch.streamId) continue;
const room = this.rooms.get(channelId);
@@@ -93,27 +102,56 @@ class CallServer {
/* ── Channel management ──────────────────────────────────── */
-- listChannels() {
++ /** Channels a viewer may see: a private call only for its creator, its invitees and staff. */
++ listChannels(viewer = null) {
const result = [];
for (const [id, ch] of this.channels) {
++ if (ch.private && !this._canSeePrivate(ch, viewer)) continue;
const room = this.rooms.get(id);
const participants = [];
if (room) { for (const [pid, info] of room) participants.push(this._buildParticipantInfo(pid, info)); }
-- result.push({ ...ch, participantCount: room ? room.size : 0, participants });
++ result.push({ ...this._publicChannel(ch), participantCount: room ? room.size : 0, participants });
}
return result;
}
++ _publicChannel(ch) { const { invited, ...rest } = ch; return rest; }
++ _canSeePrivate(ch, viewer) {
++ if (!ch.private) return true;
++ if (!viewer) return false;
++ if (viewer.role === 'admin' || viewer.role === 'global_mod') return true;
++ return ch.createdBy === viewer.id || !!(ch.invited && ch.invited.has(viewer.id));
++ }
++ /** A caller invited this user to their call (POST /voice-channels/call-user). */
++ invite(channelId, userId) {
++ const ch = this.channels.get(channelId);
++ if (!ch) return false;
++ if (!ch.invited) ch.invited = new Set();
++ ch.invited.add(userId);
++ return true;
++ }
++ hasInvite(channelId, userId) {
++ const ch = this.channels.get(channelId);
++ return !!(ch && ch.invited && ch.invited.has(userId));
++ }
++ /** Debounced "the list changed" for every chat socket (the sidebar used to poll every 4 s). */
++ _notifyChannelsChanged() {
++ if (this._notifyTimer) return;
++ this._notifyTimer = setTimeout(() => {
++ this._notifyTimer = null;
++ try { chatServer.broadcastAll({ type: 'voice-channels', channels: this.listChannels(null) }); } catch { /* */ }
++ }, 400);
++ }
-- getChannel(channelId) {
++ getChannel(channelId, viewer = null) {
const ch = this.channels.get(channelId);
-- if (!ch) return null;
++ if (!ch || !this._canSeePrivate(ch, viewer)) return null;
const room = this.rooms.get(channelId);
const participants = [];
if (room) { for (const [pid, info] of room) participants.push(this._buildParticipantInfo(pid, info)); }
-- return { ...ch, participantCount: room ? room.size : 0, participants };
++ return { ...this._publicChannel(ch), participantCount: room ? room.size : 0, participants };
}
-- createChannel({ name, mode, createdBy, maxParticipants }) {
++ createChannel({ name, mode, createdBy, maxParticipants, isPrivate = false }) {
// One user-created channel per user
for (const [, ch] of this.channels) {
if (!ch.permanent && !ch.streamId && ch.createdBy === createdBy) {
@@@ -127,9 +165,11 @@ class CallServer {
createdBy, streamId: null, permanent: false, createdAt: Date.now(),
maxParticipants: Math.min(Math.max(Number(maxParticipants) || MAX_PARTICIPANTS, 2), MAX_PARTICIPANTS),
lastEmptiedAt: Date.now(), // starts empty; inactivity timer tracks from creation
++ private: !!isPrivate,
};
this.channels.set(id, ch);
-- return ch;
++ this._notifyChannelsChanged();
++ return this._publicChannel(ch);
}
createStreamChannel(streamId, mode, streamerId) {
@@@ -163,6 +203,7 @@ class CallServer {
this.endCall(channelId);
this.channels.delete(channelId);
this.callBans.delete(channelId);
++ this._notifyChannelsChanged();
return true;
}
@@@ -172,7 +213,12 @@ class CallServer {
_buildParticipantInfo(peerId, info) {
let cosmeticProfile = {};
-- if (info.user?.id) { try { cosmeticProfile = cosmetics.getCosmeticProfile(info.user.id) || {}; } catch {} }
++ if (info.user?.id) {
++ // One query per participant per minute, not one per participant per list request.
++ const c = this._cosmeticCache.get(info.user.id);
++ if (c && Date.now() - c.at < COSMETIC_TTL_MS) cosmeticProfile = c.profile;
++ else { try { cosmeticProfile = cosmetics.getCosmeticProfile(info.user.id) || {}; } catch {} this._cosmeticCache.set(info.user.id, { at: Date.now(), profile: cosmeticProfile }); }
++ }
return {
peerId, username: info.user ? info.user.username : null,
anonId: info.anonId || null,
@@@ -214,9 +260,13 @@ class CallServer {
}
const user = authenticateWs(token);
++ if (channel.private && !this._canSeePrivate(channel, user)) { ws.send(JSON.stringify({ type: 'error', message: 'This is a private call' })); ws.close(); return; }
const peerId = this._generatePeerId();
const anonId = user ? null : chatServer.getAnonIdForConnection(ip, resolvedId);
++ const identity = user ? `u:${user.id}` : (anonId ? `a:${anonId}` : `ip:${ip}`);
++ const cooled = this.kickCooldown.get(`${resolvedId}:${identity}`);
++ if (cooled && cooled > Date.now()) { ws.send(JSON.stringify({ type: 'error', message: 'You were removed from this channel; try again in a minute' })); ws.close(); return; }
const isChannelCreator = user && channel.createdBy === user.id;
const isStreamer = channel.streamId ? (user && db.getStreamById(channel.streamId)?.user_id === user.id) : false;
const canModerate = this._canModerate(user, resolvedId);
@@@ -233,10 +283,24 @@ class CallServer {
if (!this.rooms.has(resolvedId)) this.rooms.set(resolvedId, new Map());
const room = this.rooms.get(resolvedId);
++ // The same account again (a second tab, or a reconnect before the old socket timed out)
++ // replaces its older socket instead of standing next to it as a ghost for a minute.
++ if (user) {
++ for (const [pid, info] of room) {
++ if (info.user && info.user.id === user.id) {
++ try { info.ws.send(JSON.stringify({ type: 'replaced' })); } catch { /* */ }
++ this._handleDisconnect(info.ws, resolvedId, pid);
++ try { info.ws.close(); } catch { /* */ }
++ }
++ }
++ }
++ let fromIp = 0;
++ for (const [, info] of room) if (info.ip === ip) fromIp++;
++ if (fromIp >= MAX_SOCKETS_PER_IP && !(user && (user.role === 'admin' || user.role === 'global_mod'))) { ws.send(JSON.stringify({ type: 'error', message: 'Too many connections from your network' })); ws.close(); return; }
const maxP = channel.maxParticipants || MAX_PARTICIPANTS;
if (room.size >= maxP) { ws.send(JSON.stringify({ type: 'error', message: `Channel full (max ${maxP})` })); ws.close(); return; }
-- const clientInfo = { ws, user, anonId, ip, peerId, muted: false, cameraOff: true, forceMuted: false, forceCameraOff: false, speaking: false, isChannelCreator, isStreamer, _msgCount: 0, _msgResetTime: Date.now() };
++ const clientInfo = { ws, user, anonId, ip, peerId, identity, muted: false, cameraOff: true, forceMuted: false, forceCameraOff: false, speaking: false, isChannelCreator, isStreamer, _msgCount: 0, _msgResetTime: Date.now() };
this.clients.set(ws, { channelId: resolvedId, peerId });
room.set(peerId, clientInfo);
@@@ -262,12 +326,14 @@ class CallServer {
const joinMsg = JSON.stringify({ type: 'peer-joined', ...this._buildParticipantInfo(peerId, clientInfo) });
for (const [pid, info] of room) { if (pid !== peerId && info.ws.readyState === WebSocket.OPEN) info.ws.send(joinMsg); }
this._broadcastParticipantCount(resolvedId);
++ this._notifyChannelsChanged();
ws.on('message', (data) => {
-- // Rate limit: drop messages if client exceeds 50/sec
++ // Rate limit: 200/s. A newcomer to a full room sends seven offers and their candidates in
++ // a burst; the old 50/s silently dropped some of them and the join stalled.
const now = Date.now();
if (now - clientInfo._msgResetTime > 1000) { clientInfo._msgCount = 0; clientInfo._msgResetTime = now; }
-- if (++clientInfo._msgCount > 50) return;
++ if (++clientInfo._msgCount > 200) return;
try { this._handleMessage(ws, JSON.parse(data), resolvedId, peerId); } catch (err) { console.warn('[Call] Message error for peer', peerId, ':', err.message); }
});
ws.on('close', () => this._handleDisconnect(ws, resolvedId, peerId));
@@@ -301,6 +367,7 @@ class CallServer {
c.muted = !!msg.muted;
const m = JSON.stringify({ type: 'peer-muted', peerId, muted: c.muted });
for (const [pid, info] of room) { if (pid !== peerId && info.ws.readyState === WebSocket.OPEN) info.ws.send(m); }
++ this._notifyChannelsChanged();
break;
}
case 'camera-off': {
@@@ -369,6 +436,7 @@ class CallServer {
case 'kick': {
const sender = room.get(peerId); if (!sender || !this._canModerate(sender.user, channelId)) break;
const target = room.get(msg.targetPeerId); if (!target || target.isChannelCreator || target.isStreamer) break;
++ if (target.identity) this.kickCooldown.set(`${channelId}:${target.identity}`, Date.now() + KICK_COOLDOWN_MS);
if (target.ws.readyState === WebSocket.OPEN) { target.ws.send(JSON.stringify({ type: 'kicked' })); target.ws.close(); }
room.delete(msg.targetPeerId); this.clients.delete(target.ws);
const m = JSON.stringify({ type: 'peer-left', peerId: msg.targetPeerId, reason: 'kicked' });
@@@ -425,6 +493,7 @@ class CallServer {
const m = JSON.stringify({ type: 'peer-left', ...leftInfo, reason: 'disconnect' });
for (const [pid, info] of room) { if (info.ws.readyState === WebSocket.OPEN) info.ws.send(m); }
this._broadcastParticipantCount(channelId);
++ this._notifyChannelsChanged();
if (room.size === 0) {
this.rooms.delete(channelId);
const ch = this.channels.get(channelId);
ddiff --git a/server/streaming/routes.js b/server/streaming/routes.js
iindex a7111943..67dd1e6b 100644
---- a/server/streaming/routes.js
++++ b/server/streaming/routes.js
@@@ -1222,19 +1222,19 @@ router.get('/recent-vods', async (req, res) => {
/* ── Voice Channels (global, non-stream) ───────────────────── */
--router.get('/voice-channels', (req, res) => {
++router.get('/voice-channels', optionalAuth, (req, res) => {
try {
-- res.set('Cache-Control', 'public, max-age=2');
-- res.json({ channels: callServer.listChannels() });
++ res.set('Cache-Control', 'no-store'); // private calls differ per viewer; the list is pushed anyway
++ res.json({ channels: callServer.listChannels(req.user || null) });
} catch (err) {
console.error('[Streaming]', err.message);
res.status(500).json({ error: 'Failed to list voice channels' });
}
});
--router.get('/voice-channels/:channelId', (req, res) => {
++router.get('/voice-channels/:channelId', optionalAuth, (req, res) => {
try {
-- const ch = callServer.getChannel(req.params.channelId);
++ const ch = callServer.getChannel(req.params.channelId, req.user || null);
if (!ch) return res.status(404).json({ error: 'Channel not found' });
res.json({ channel: ch });
} catch (err) {
@@@ -1266,8 +1266,14 @@ router.delete('/voice-channels/:channelId', requireAuth, (req, res) => {
}
});
++const _callUserRate = new Map(); // userId → [timestamps]
router.post('/voice-channels/call-user', requireAuth, (req, res) => {
try {
++ // Six calls a minute per account: a ring is a notification on someone else's screen.
++ const now = Date.now();
++ const recent = (_callUserRate.get(req.user.id) || []).filter((t) => now - t < 60000);
++ if (recent.length >= 6) return res.status(429).json({ error: 'Slow down — try again in a minute' });
++ recent.push(now); _callUserRate.set(req.user.id, recent);
const targetUserId = Number(req.body?.user_id || 0);
const targetUsername = String(req.body?.username || '').trim();
@@@ -1276,15 +1282,19 @@ router.post('/voice-channels/call-user', requireAuth, (req, res) => {
if (!targetUser && targetUsername) targetUser = db.getUserByUsername(targetUsername);
if (!targetUser) return res.status(404).json({ error: 'User not found' });
if (targetUser.id === req.user.id) return res.status(400).json({ error: 'You cannot call yourself' });
++ try { const dm = require('../chat/dm'); if (dm.isBlockedEither && dm.isBlockedEither(req.user.id, targetUser.id)) return res.status(403).json({ error: 'You cannot call this user' }); } catch { /* */ }
-- // Reuse caller's existing temp channel if present; otherwise create one.
-- const existing = (callServer.listChannels() || []).find(ch => !ch.permanent && !ch.streamId && ch.createdBy === req.user.id) || null;
++ // Reuse caller's existing temp channel if present; otherwise create a private one — a
++ // 1:1 call is not something the whole site should see listed and be able to walk into.
++ const existing = (callServer.listChannels(req.user) || []).find(ch => !ch.permanent && !ch.streamId && ch.createdBy === req.user.id) || null;
const channel = existing || callServer.createChannel({
name: `${req.user.display_name || req.user.username}'s call`,
mode: 'mic+cam',
createdBy: req.user.id,
maxParticipants: 8,
++ isPrivate: true,
});
++ callServer.invite(channel.id, targetUser.id);
const callerName = req.user.display_name || req.user.username || 'Someone';
const payload = {
@@@ -1338,6 +1348,9 @@ router.post('/voice-channels/call-user/respond', requireAuth, (req, res) => {
if (!channelId) return res.status(400).json({ error: 'channel_id is required' });
if (!allowed.has(status)) return res.status(400).json({ error: 'Invalid response status' });
if (callerUserId === req.user.id) return res.status(400).json({ error: 'Invalid caller target' });
++ // Only an invited user can answer, and only to the caller who owns that channel.
++ const ch = callServer.getChannel(channelId, req.user);
++ if (!ch || ch.createdBy !== callerUserId || !callServer.hasInvite(channelId, req.user.id)) return res.status(403).json({ error: 'No such invite' });
const fromDisplayName = req.user.display_name || req.user.username || 'Someone';
chatServer.sendDm(callerUserId, {