openvibers/openvibe.live Commits
Home: featured live stream, rails that fill their rows, stats without the panel; voice chat rebuilt
Changes
@@@ -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; } }nnew file mode 100644@@@ -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; }++}@@@ -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); }@@@ -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 howevermany 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 contentis capped well under the viewport, so a viewport breakpoint alone gave 133px buttons at 1280pxwith 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 itis 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); }@@@ -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;@@@ -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 thefull 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 ofleaving 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; }@@@ -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": [@@@ -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">@@@ -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, whichalready says the platform is built by one person who streams here. One of themis 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">@@@ -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>@@@ -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-initif ((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 inconst 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 replacedconst shouldReconnect = !callState.intentionalDisconnect && (callState.joined || callState.connecting) && (callState.channelId || callState.streamId);callState.ws = null;if (shouldReconnect) {++ _setConnState('reconnecting');// ReconnectcallState.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 connectionif (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 connectionpeer._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 negotiationpeer._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 placeholderconst 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) {@@@ -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, switchToChattoast('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) {nnew file mode 100644@@@ -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 & 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 };++})();@@@ -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' },@@@ -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 };})();@@@ -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 changelastJoinedChannelId: 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 landedconst 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 joincallState.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 settingsconst 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 barvcUpdateMiniBar();}++/** 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 tilesfor (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 modeconst 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() {@@@ -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); }@@@ -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 });@@@ -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@@@ -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 channelthis.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 userfor (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);@@@ -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, {
@@ -184,43 +184,6 @@
@@ -184,43 +184,6 @@
background-position: center;
background-position: center;
flex-shrink: 0;
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 {
.vc-device-group {
display: flex;
display: flex;
flex-direction: column;
flex-direction: column;
@@ -231,9 +194,6 @@
@@ -231,9 +194,6 @@
width: 100%;
width: 100%;
accent-color: var(--accent);
accent-color: var(--accent);
}
}
-.vc-test-btn {
- flex: 0 0 auto;
-}
.vc-connected-panel {
.vc-connected-panel {
border-top: 1px solid var(--border);
border-top: 1px solid var(--border);
background: var(--bg-primary);
background: var(--bg-primary);
@@ -358,13 +318,6 @@
@@ -358,13 +318,6 @@
.vc-sidebar-header {
.vc-sidebar-header {
padding: 10px 12px 8px;
padding: 10px 12px 8px;
}
}
- .vc-setup-panel {
- padding: 10px;
- }
- .vc-setup-avatar {
- width: 56px;
- height: 56px;
- }
.vc-connected-header {
.vc-connected-header {
padding: 8px 10px;
padding: 8px 10px;
}
}
@@ -377,3 +330,14 @@
@@ -377,3 +330,14 @@
align-items: center;
align-items: center;
gap: 8px;
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; } }
new file mode 100644
new file mode 100644
@@ -0,0 +1,93 @@
@@ -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; }
+}
@@ -81,7 +81,9 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
@@ -81,7 +81,9 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
}
}
.network-hub-grid {
.network-hub-grid {
display: 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;
gap: 12px;
max-width: 960px; margin: 0 auto;
max-width: 960px; margin: 0 auto;
}
}
@@ -112,15 +114,15 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
@@ -112,15 +114,15 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
.network-card:hover .network-card-arrow { opacity: 1; transform: translateX(3px); }
.network-card:hover .network-card-arrow { opacity: 1; transform: translateX(3px); }
@media (max-width: 960px) {
@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) {
@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 { padding: 24px 16px; }
.network-hub-header { flex-direction: column; gap: 8px; }
.network-hub-header { flex-direction: column; gap: 8px; }
}
}
@media (max-width: 480px) {
@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-hub { padding: 20px 12px; }
.network-card { padding: 12px; gap: 10px; }
.network-card { padding: 12px; gap: 10px; }
.network-card-icon { width: 36px; height: 36px; font-size: 1rem; border-radius: 8px; }
.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; }
@@ -203,10 +205,11 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
.hero-philosophy-links { margin-top: 24px; }
.hero-philosophy-links { margin-top: 24px; }
.hero-stat-group {
.hero-stat-group {
display: grid; grid-template-columns: 118px minmax(0, 1fr); gap: 12px; align-items: center;
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 {
.hero-stat-kicker {
display: flex; align-items: center; gap: 7px; padding-left: 16px; align-self: stretch;
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);
border-left: 3px solid color-mix(in srgb, var(--accent) 55%, transparent);
@@ -512,7 +515,7 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
@@ -512,7 +515,7 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
.hero-stat-strip[hidden] { display: none !important; }
.hero-stat-strip[hidden] { display: none !important; }
.hero-stat-strip {
.hero-stat-strip {
display: grid; grid-template-columns: repeat(3, minmax(0, 1fr));
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%;
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); }
.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; }
@@ -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-strip .hero-stat { background: color-mix(in srgb, var(--accent) 7%, transparent); }
.hero-stat-more {
.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;
font-size: 0.76rem; font-weight: 700; letter-spacing: 0.02em;
color: var(--text-primary);
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. */
/* The stats are something to be proud of — the toggle reads as an invitation, not a footnote. */
.hero-stat-more-icon {
.hero-stat-more-icon {
display: inline-grid; place-items: center; width: 22px; height: 22px; border-radius: 7px;
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; }
@@ -547,14 +554,12 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
.hero-stat-more-icon i { font-size: 0.7rem; }
.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-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-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: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); }
.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). */
/* Every number opens its history: say so without a hover (touch screens have none). */
.hero-stat--clickable { cursor: pointer; }
.hero-stat--clickable { cursor: pointer; }
@media (hover: none) { .hero-stat-chart-hint { opacity: 0.55; } }
@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) {
@media (max-width: 640px) {
.hero-stats { min-height: 182px; }
.hero-stats { min-height: 182px; }
}
}
@@ -670,7 +675,7 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
@@ -670,7 +675,7 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
.hero-stat-strip { grid-auto-rows: 1fr; align-items: stretch; }
.hero-stat-strip { grid-auto-rows: 1fr; align-items: stretch; }
.hero-stat--spark { display: flex; }
.hero-stat--spark { display: flex; }
.hero-stats.is-swapping { backdrop-filter: none !important; -webkit-backdrop-filter: none !important; }
.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) {
@media (max-width: 900px) {
.hero-stats {
.hero-stats {
backdrop-filter: none; -webkit-backdrop-filter: none;
backdrop-filter: none; -webkit-backdrop-filter: none;
@@ -978,3 +983,13 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
@@ -978,3 +983,13 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
.star-card.is-swapping { transition: none; }
.star-card.is-swapping { transition: none; }
.star-card.is-compact .star-avatar-ring { animation: 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); }
@@ -20,20 +20,19 @@
@@ -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
/* 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.
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. */
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 {
#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
/* 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
is capped well under the viewport, so a viewport breakpoint alone gave 133px buttons at 1280px
with every label cut to an ellipsis. */
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
/* 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. */
is what pushes the label into an ellipsis. The tooltip still says it. */
#page-home .hero-cta .hb-label small { display: none; }
#page-home .hero-cta .hb-label small { display: none; }
@@ -119,14 +118,16 @@
@@ -119,14 +118,16 @@
.hb-tools { --c: #2dd4bf; } .hb-game { --c: #4ade80; } .hb-github { --c: #e5e7eb; } .hb-discord { --c: #5865f2; } .hb-vibe { --c: #f472b6; }
.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. */
/* Narrow phones: two tidy columns instead of a ragged wrap. Landscape keeps a single row. */
@media (max-width: 560px) {
@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; }
.hb { font-size: 0.86rem; padding-right: 12px; }
/* The "API docs" note is what would push "Build & Mod" to an ellipsis at two columns. */
/* The "API docs" note is what would push "Build & Mod" to an ellipsis at two columns. */
.hb-label small { display: none; }
.hb-label small { display: none; }
}
}
/* A landscape phone is wide but short: three across keeps it to two rows, never a ragged wrap. */
/* 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) {
@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; }
.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); } }
@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 @@
@@ -283,7 +284,7 @@
#page-home .hero-float-badge {
#page-home .hero-float-badge {
backdrop-filter: none !important; -webkit-backdrop-filter: none !important;
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-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); }
#page-home .hero-latest { background: color-mix(in srgb, var(--bg-secondary) 88%, transparent); }
@@ -178,17 +178,17 @@
@@ -178,17 +178,17 @@
transition: height 1.8s cubic-bezier(.32, 1, .38, 1);
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));
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 {
.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 {
.ovg-quest-bub {
position: absolute; left: var(--x); bottom: -6px; width: 5px; height: 5px; border-radius: 50%;
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;
background: color-mix(in srgb, var(--q) 60%, #fff 25%); opacity: 0;
@@ -2027,16 +2027,13 @@ main#app { position: relative; z-index: 1; }
@@ -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
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. */
full board width — no more differently-sized islands stacked down the middle. */
.hero-stats {
.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. */
/* 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%);
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; }
.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
/* 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. */
leaving a ragged gap — every row reads as one full-width band. */
@@ -6502,13 +6499,28 @@ body.theme-light .user-dropdown {
@@ -6502,13 +6499,28 @@ body.theme-light .user-dropdown {
border: 3px solid transparent;
border: 3px solid transparent;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
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) */
/* Video (camera on) */
@@ -7927,123 +7939,16 @@ body.theme-light .user-dropdown {
@@ -7927,123 +7939,16 @@ body.theme-light .user-dropdown {
/* Small avatar used in participant rows */
/* 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 ───────────────────────────────────────── */
/* ── 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 {
@keyframes vc-pulse {
0%, 100% { opacity: 1; }
0%, 100% { opacity: 1; }
- 50% { opacity: 0.4; }
+ 50% { opacity: 0.35; }
}
}
/* Participant grid */
/* Participant grid */
@@ -8759,11 +8664,6 @@ body.theme-light .user-dropdown {
@@ -8759,11 +8664,6 @@ body.theme-light .user-dropdown {
font-size: 0.5rem;
font-size: 0.5rem;
color: #22c55e;
color: #22c55e;
margin-right: 4px;
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-status { font-weight: 600; white-space: nowrap; }
.vc-mini-channel { color: var(--accent, #6366f1); font-weight: 500; white-space: nowrap; }
.vc-mini-channel { color: var(--accent, #6366f1); font-weight: 500; white-space: nowrap; }
@@ -125,7 +125,6 @@
@@ -125,7 +125,6 @@
"vcCreateChannel",
"vcCreateChannel",
"vcToggleMute",
"vcToggleMute",
"vcToggleCamera",
"vcToggleCamera",
- "vcToggleScreenShare",
"vcToggleSettings",
"vcToggleSettings",
"vcLeave",
"vcLeave",
"vcSwitchMic",
"vcSwitchMic",
@@ -263,6 +262,14 @@
@@ -263,6 +262,14 @@
"stubs": [
"stubs": [
"openStatsNerds"
"openStatsNerds"
]
]
+ },
+ "featured": {
+ "css": [
+ "/css/features/featured.css"
+ ],
+ "js": [
+ "/js/home-featured.js"
+ ]
}
}
},
},
"routes": [
"routes": [
@@ -37,9 +37,6 @@
@@ -37,9 +37,6 @@
<button class="vc-ctrl-btn" id="vc-btn-camera" onclick="vcToggleCamera()" title="Toggle Camera">
<button class="vc-ctrl-btn" id="vc-btn-camera" onclick="vcToggleCamera()" title="Toggle Camera">
<i class="fa-solid fa-video"></i>
<i class="fa-solid fa-video"></i>
</button>
</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">
<button class="vc-ctrl-btn vc-ctrl-settings" id="vc-btn-settings" onclick="vcToggleSettings()" title="Settings">
<i class="fa-solid fa-gear"></i>
<i class="fa-solid fa-gear"></i>
</button>
</button>
@@ -214,7 +211,7 @@
@@ -214,7 +211,7 @@
</div>
</div>
<div class="form-group">
<div class="form-group">
<label>Max Participants</label>
<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>
</div>
<div class="modal-footer">
<div class="modal-footer">
@@ -358,10 +358,13 @@
@@ -358,10 +358,13 @@
</div>
</div>
<div class="container">
<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 -->
<!-- Live Streams -->
<div class="section-header" style="--sc:#ef4444">
<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>
<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>
<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>
<div class="stream-grid" id="stream-grid-live">
<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>
<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 @@
@@ -454,9 +457,14 @@
STREAM SITE") was a louder restatement of the lede two lines below it, which
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
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. -->
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>
<h2 class="cta-hero">Streaming the way it <span class="cta-hero-grad">should</span> be.</h2>
<p class="cta-lede">
<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>
</p>
<button class="home-about-toggle" type="button" onclick="toggleHomeAbout()" aria-expanded="false" aria-controls="home-about-body">
<button class="home-about-toggle" type="button" onclick="toggleHomeAbout()" aria-expanded="false" aria-controls="home-about-body">
@@ -1176,11 +1176,8 @@ async function loadHome() {
@@ -1176,11 +1176,8 @@ async function loadHome() {
setTimeout(() => OVSkeleton.clearAll(), 9000);
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
// 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
// slow /streams held up the clips, VODs, pastes, digest and star rails even though none of
@@ -1195,6 +1192,7 @@ async function loadHome() {
@@ -1195,6 +1192,7 @@ async function loadHome() {
const noLiveEl = document.getElementById('no-live-streams');
const noLiveEl = document.getElementById('no-live-streams');
if (noLiveEl) noLiveEl.style.display = streams.length ? 'none' : '';
if (noLiveEl) noLiveEl.style.display = streams.length ? 'none' : '';
renderStreamGrid('stream-grid-live', streams, true);
renderStreamGrid('stream-grid-live', streams, true);
+ _homeFeaturedSync(streams.length > 0);
}, { ttl: 20000 });
}, { ttl: 20000 });
} catch (e) {
} catch (e) {
console.error('Failed to load live streams', e);
console.error('Failed to load live streams', e);
@@ -1338,9 +1336,8 @@ async function loadHomeDigest() {
@@ -1338,9 +1336,8 @@ async function loadHomeDigest() {
box.style.display = '';
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 {
try {
const recentData = await api(`/streams/recently-online?limit=${limit}&offset=${offset}`);
const recentData = await api(`/streams/recently-online?limit=${limit}&offset=${offset}`);
renderRecentlyOnline('stream-grid-recent', recentData.streamers || [], append);
renderRecentlyOnline('stream-grid-recent', recentData.streamers || [], append);
@@ -1351,8 +1348,9 @@ async function loadHomeRecentOnline(page, append) {
@@ -1351,8 +1348,9 @@ async function loadHomeRecentOnline(page, append) {
OVDensity.attach(document.getElementById('stream-grid-recent'),
OVDensity.attach(document.getElementById('stream-grid-recent'),
{ key: 'recent', minCard: 188, header: document.getElementById('stream-grid-recent')?.previousElementSibling });
{ 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) {
function renderRecentlyOnline(containerId, streamers, append) {
@@ -1407,16 +1405,15 @@ function renderRecentlyOnline(containerId, streamers, append) {
@@ -1407,16 +1405,15 @@ function renderRecentlyOnline(containerId, streamers, append) {
}).join(''));
}).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 {
try {
const render = (data) => {
const render = (data) => {
const vods = data.vods || [];
const vods = data.vods || [];
const header = document.getElementById('home-recent-vods-header');
const header = document.getElementById('home-recent-vods-header');
const grid = document.getElementById('home-recent-vods-grid');
const grid = document.getElementById('home-recent-vods-grid');
if (!header || !grid) return;
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 = '';
header.style.display = '';
const _put = (html) => ovPutCards(grid, html, append);
const _put = (html) => ovPutCards(grid, html, append);
_put(vods.map(v => {
_put(vods.map(v => {
@@ -1441,21 +1438,21 @@ async function loadHomeRecentVods(page, append) {
@@ -1441,21 +1438,21 @@ async function loadHomeRecentVods(page, append) {
`;
`;
}).join(''));
}).join(''));
if (window.OVDensity) OVDensity.attach(grid, { key: 'vods', header: header });
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 });
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 {
try {
const render = (data) => {
const render = (data) => {
const clips = data.clips || [];
const clips = data.clips || [];
const header = document.getElementById('home-clips-header');
const header = document.getElementById('home-clips-header');
const grid = document.getElementById('home-clips-grid');
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 = '';
if (header) header.style.display = '';
const _put = (html) => ovPutCards(grid, html, append);
const _put = (html) => ovPutCards(grid, html, append);
_put(clips.map(c => `
_put(clips.map(c => `
@@ -1477,21 +1474,21 @@ async function loadHomeClips(page, append) {
@@ -1477,21 +1474,21 @@ async function loadHomeClips(page, append) {
</a>
</a>
`).join(''));
`).join(''));
if (window.OVDensity) OVDensity.attach(grid, { key: 'clips', header: header });
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 });
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 {
try {
const render = (data) => {
const render = (data) => {
const pastes = data.pastes || [];
const pastes = data.pastes || [];
const header = document.getElementById('home-pastes-header');
const header = document.getElementById('home-pastes-header');
const list = document.getElementById('home-pastes-list');
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 = '';
if (header) header.style.display = '';
const _put = (html) => ovPutCards(list, html, append);
const _put = (html) => ovPutCards(list, html, append);
_put(pastes.map(p => {
_put(pastes.map(p => {
@@ -1517,10 +1514,11 @@ async function loadHomePastes(page, append) {
@@ -1517,10 +1514,11 @@ async function loadHomePastes(page, append) {
</a>`;
</a>`;
}).join(''));
}).join(''));
if (window.OVDensity) OVDensity.attach(list, { key: 'pastes', header: document.getElementById('home-pastes-header') });
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 });
await apiSWR(`/pastes?limit=${limit}&offset=${offset}`, render, { ttl: 180000 });
- } catch { /* silent */ }
+ } catch { rail.filling = false; }
}
}
async function loadHomeLeaderboards() {
async function loadHomeLeaderboards() {
@@ -1682,6 +1680,22 @@ function streamCardHTML(s, isLive) {
@@ -1682,6 +1680,22 @@ function streamCardHTML(s, isLive) {
</a>`;
</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) {
function renderStreamGrid(containerId, streams, isLive) {
const c = document.getElementById(containerId);
const c = document.getElementById(containerId);
if (!c) return;
if (!c) return;
@@ -1867,10 +1881,12 @@ async function refreshHomeLive() {
@@ -1867,10 +1881,12 @@ async function refreshHomeLive() {
// never disrupt someone paging through or reading below the fold).
// never disrupt someone paging through or reading below the fold).
function refreshHomeSections() {
function refreshHomeSections() {
if (!_homeIsActive() || document.visibilityState !== 'visible') return;
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();
if (typeof loadHomeLeaderboards === 'function') loadHomeLeaderboards();
}
}
@@ -1885,10 +1901,6 @@ function stopHomeRefresh() {
@@ -1885,10 +1901,6 @@ function stopHomeRefresh() {
if (_homeSectionsTimer) { clearInterval(_homeSectionsTimer); _homeSectionsTimer = null; }
if (_homeSectionsTimer) { clearInterval(_homeSectionsTimer); _homeSectionsTimer = null; }
} // populated on channel load, used for filter bar
} // populated on channel load, used for filter bar
// Homepage pagination state
// 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
// 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
// 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
// worth each is plenty for a front page; the pagination underneath still reaches the rest, and
@@ -1900,27 +1912,51 @@ let _homePastesPage = 1;
@@ -1900,27 +1912,51 @@ let _homePastesPage = 1;
// matchMedia, not innerWidth: reading innerWidth while the page is still being styled forces a
// matchMedia, not innerWidth: reading innerWidth while the page is still being styled forces a
// synchronous style and layout pass of the whole document.
// synchronous style and layout pass of the whole document.
const _narrow = typeof window !== 'undefined' && window.matchMedia('(max-width: 759px)').matches;
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.
// Thin wrapper for homepage section pagination — same visual style as renderVodsPagination.
@@ -1932,19 +1968,21 @@ function homeShownCount(page, firstSize) {
@@ -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
* 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.
* 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);
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
// 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.
// 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; }
if (left <= 0) { el.innerHTML = ''; el.style.display = 'none'; return; }
el.style.display = 'block';
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>
<i class="fa-solid fa-arrow-down"></i>
<span>Load ${next} more</span>
<span>Load ${next} more</span>
<small>${left.toLocaleString()} left</small>
<small>${left.toLocaleString()} left</small>
@@ -64,13 +64,19 @@ let _iceServers = [
@@ -64,13 +64,19 @@ let _iceServers = [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun1.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() {
async function _ensureIceServers() {
+ if (Date.now() - _iceServersAt < 5 * 60 * 1000) return;
try {
try {
const res = await fetch('/api/auth/ice-servers');
const res = await fetch('/api/auth/ice-servers');
if (res.ok) {
if (res.ok) {
const data = await res.json();
const data = await res.json();
if (Array.isArray(data.iceServers) && data.iceServers.length) {
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) {
} catch (e) {
@@ -346,6 +352,43 @@ function _scheduleRender() {
@@ -346,6 +352,43 @@ function _scheduleRender() {
* frequent event (~12 updates/sec per speaking participant).
* frequent event (~12 updates/sec per speaking participant).
* Returns true if the tile was found and updated in-place.
* 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) {
function _updateTileSpeaking(peerId, speaking, muted, forceMuted) {
const gridId = callState.vcMode ? 'vc-participants-grid' : (callState.broadcastMode ? 'bc-call-participants-grid' : 'call-participants-grid');
const gridId = callState.vcMode ? 'vc-participants-grid' : (callState.broadcastMode ? 'bc-call-participants-grid' : 'call-participants-grid');
const grid = document.getElementById(gridId);
const grid = document.getElementById(gridId);
@@ -426,71 +469,6 @@ function _updateTileMuted(peerId, muted, forceMuted) {
@@ -426,71 +469,6 @@ function _updateTileMuted(peerId, muted, forceMuted) {
/* ── Public API (called from stream page) ──────────────────── */
/* ── 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) {
function initBroadcastCallPanel(streamId, callMode) {
const panel = document.getElementById('bc-call-panel');
const panel = document.getElementById('bc-call-panel');
if (!panel || !callMode) {
if (!panel || !callMode) {
@@ -501,11 +479,11 @@ function initBroadcastCallPanel(streamId, callMode) {
@@ -501,11 +479,11 @@ function initBroadcastCallPanel(streamId, callMode) {
// If already in a call for this stream, skip re-init
// 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;
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) {
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;
callState.streamId = streamId;
@@ -542,9 +520,10 @@ async function joinCall() {
@@ -542,9 +520,10 @@ async function joinCall() {
if (callState.joined || callState.connecting || !(callState.channelId || callState.streamId)) return;
if (callState.joined || callState.connecting || !(callState.channelId || callState.streamId)) return;
callState.connecting = true;
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) {
if (callState.reconnectTimer) {
clearTimeout(callState.reconnectTimer);
clearTimeout(callState.reconnectTimer);
@@ -555,35 +534,7 @@ async function joinCall() {
@@ -555,35 +534,7 @@ async function joinCall() {
// Camera is always off by default — user must explicitly opt in
// Camera is always off by default — user must explicitly opt in
const wantCameraOff = callState.startCameraOff !== undefined ? callState.startCameraOff : true;
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
// If we're broadcasting, clone the broadcast's audio track instead of
// calling getUserMedia again — avoids device contention on Linux/PipeWire
// calling getUserMedia again — avoids device contention on Linux/PipeWire
// where a second getUserMedia can steal the mic from the broadcast or
// where a second getUserMedia can steal the mic from the broadcast or
@@ -654,10 +605,19 @@ async function joinCall() {
@@ -654,10 +605,19 @@ async function joinCall() {
// Show local preview
// Show local preview
_updateLocalPreview();
_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();
_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 */
/** Leave the group call */
function leaveCall() {
function leaveCall() {
const streamId = callState.streamId;
const streamId = callState.streamId;
@@ -720,11 +680,16 @@ function _cleanupCall() {
@@ -720,11 +680,16 @@ function _cleanupCall() {
_sharedPeerAudioCtx = null;
_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) {
if (callState.ws) {
- try { callState.ws.close(); } catch {}
+ const w = callState.ws;
callState.ws = null;
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) {
if (callState.reconnectTimer) {
clearTimeout(callState.reconnectTimer);
clearTimeout(callState.reconnectTimer);
@@ -928,7 +893,10 @@ function _connectCallWs() {
@@ -928,7 +893,10 @@ function _connectCallWs() {
const token = _getAuthToken();
const token = _getAuthToken();
const channelParam = callState.channelId || callState.streamId;
const channelParam = callState.channelId || callState.streamId;
let url = `${proto}//${location.host}/ws/call?channelId=${encodeURIComponent(channelParam)}`;
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) {
if (callState.reconnectTimer) {
clearTimeout(callState.reconnectTimer);
clearTimeout(callState.reconnectTimer);
@@ -954,23 +922,28 @@ function _connectCallWs() {
@@ -954,23 +922,28 @@ function _connectCallWs() {
}
}
callState.peers.clear();
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.reconnectDelay = 3000;
};
};
- callState.ws.onmessage = (e) => {
+ ws.onmessage = (e) => {
+ if (callState.ws !== ws) return;
try {
try {
const msg = JSON.parse(e.data);
const msg = JSON.parse(e.data);
_handleCallMessage(msg);
_handleCallMessage(msg);
} catch {}
} 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);
const shouldReconnect = !callState.intentionalDisconnect && (callState.joined || callState.connecting) && (callState.channelId || callState.streamId);
callState.ws = null;
callState.ws = null;
if (shouldReconnect) {
if (shouldReconnect) {
+ _setConnState('reconnecting');
// Reconnect
// Reconnect
callState.reconnectTimer = setTimeout(() => {
callState.reconnectTimer = setTimeout(() => {
if (!callState.intentionalDisconnect && (callState.joined || callState.connecting) && (callState.channelId || callState.streamId)) {
if (!callState.intentionalDisconnect && (callState.joined || callState.connecting) && (callState.channelId || callState.streamId)) {
@@ -983,7 +956,8 @@ function _connectCallWs() {
@@ -983,7 +956,8 @@ function _connectCallWs() {
}
}
};
};
- callState.ws.onerror = (e) => {
+ ws.onerror = (e) => {
+ if (callState.ws !== ws) return;
console.warn('[Call] WebSocket error:', e);
console.warn('[Call] WebSocket error:', e);
};
};
}
}
@@ -991,9 +965,20 @@ function _connectCallWs() {
@@ -991,9 +965,20 @@ function _connectCallWs() {
function _handleCallMessage(msg) {
function _handleCallMessage(msg) {
switch (msg.type) {
switch (msg.type) {
case 'welcome':
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.myPeerId = msg.peerId;
callState.joined = true;
callState.joined = true;
callState.connecting = false;
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;
callState.isStreamer = msg.isStreamer;
// Server provides canonical moderation capability for this channel.
// Server provides canonical moderation capability for this channel.
callState.canModerate = !!msg.canModerate;
callState.canModerate = !!msg.canModerate;
@@ -1038,7 +1023,9 @@ function _handleCallMessage(msg) {
@@ -1038,7 +1023,9 @@ function _handleCallMessage(msg) {
break;
break;
case 'peer-joined': {
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();
_renderCallUI();
_callSystemMessage(`${_resolveParticipantDisplayName(msg)} joined the call`);
_callSystemMessage(`${_resolveParticipantDisplayName(msg)} joined the call`);
_callSounds.play('join');
_callSounds.play('join');
@@ -1226,10 +1213,12 @@ function _handleCallMessage(msg) {
@@ -1226,10 +1213,12 @@ function _handleCallMessage(msg) {
case 'error': {
case 'error': {
_callSystemMessage(msg.message || 'Call 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;
break;
}
}
@@ -1242,11 +1231,10 @@ function _createPeerConnection(peerId, initiator, peerInfo) {
@@ -1242,11 +1231,10 @@ function _createPeerConnection(peerId, initiator, peerInfo) {
if (callState.peers.has(peerId)) {
if (callState.peers.has(peerId)) {
_closePeer(peerId);
_closePeer(peerId);
}
}
-
- const pc = new RTCPeerConnection({ iceServers: _iceServers });
-
const peer = {
const peer = {
- pc,
+ pc: null,
+ connecting: true,
+ _pendingIce: [],
username: peerInfo?.username || null,
username: peerInfo?.username || null,
anonId: peerInfo?.anonId || null,
anonId: peerInfo?.anonId || null,
displayName: _resolveParticipantDisplayName(peerInfo),
displayName: _resolveParticipantDisplayName(peerInfo),
@@ -1269,6 +1257,18 @@ function _createPeerConnection(peerId, initiator, peerInfo) {
@@ -1269,6 +1257,18 @@ function _createPeerConnection(peerId, initiator, peerInfo) {
videoStream: null,
videoStream: null,
};
};
callState.peers.set(peerId, peer);
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
// Add local tracks to the connection
if (callState.localStream) {
if (callState.localStream) {
@@ -1319,18 +1319,11 @@ function _createPeerConnection(peerId, initiator, peerInfo) {
@@ -1319,18 +1319,11 @@ function _createPeerConnection(peerId, initiator, peerInfo) {
return;
return;
}
}
console.log(`[Call] Attempting ICE restart for peer ${peerId} (attempt ${peer._iceRestartCount})`);
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 {
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 {
} catch {
_closePeer(peerId);
_closePeer(peerId);
callState.peers.delete(peerId);
callState.peers.delete(peerId);
@@ -1342,6 +1335,7 @@ function _createPeerConnection(peerId, initiator, peerInfo) {
@@ -1342,6 +1335,7 @@ function _createPeerConnection(peerId, initiator, peerInfo) {
if (peer._disconnectTimer) { clearTimeout(peer._disconnectTimer); peer._disconnectTimer = null; }
if (peer._disconnectTimer) { clearTimeout(peer._disconnectTimer); peer._disconnectTimer = null; }
// Reset restart counter on successful connection
// Reset restart counter on successful connection
peer._iceRestartCount = 0;
peer._iceRestartCount = 0;
+ if (peer.connecting) { peer.connecting = false; _updateTileConnecting(peerId, false); }
} else if (state === 'failed') {
} else if (state === 'failed') {
if (peer._disconnectTimer) { clearTimeout(peer._disconnectTimer); peer._disconnectTimer = null; }
if (peer._disconnectTimer) { clearTimeout(peer._disconnectTimer); peer._disconnectTimer = null; }
// One ICE restart attempt before giving up
// One ICE restart attempt before giving up
@@ -1349,16 +1343,8 @@ function _createPeerConnection(peerId, initiator, peerInfo) {
@@ -1349,16 +1343,8 @@ function _createPeerConnection(peerId, initiator, peerInfo) {
peer._iceRestartAttempted = true;
peer._iceRestartAttempted = true;
console.log(`[Call] Peer ${peerId} ICE failed — attempting one restart`);
console.log(`[Call] Peer ${peerId} ICE failed — attempting one restart`);
try {
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 {
} catch {
_closePeer(peerId);
_closePeer(peerId);
callState.peers.delete(peerId);
callState.peers.delete(peerId);
@@ -1387,8 +1373,8 @@ function _createPeerConnection(peerId, initiator, peerInfo) {
@@ -1387,8 +1373,8 @@ function _createPeerConnection(peerId, initiator, peerInfo) {
if (peer._negotiating || peer._handlingRemoteOffer) return; // prevent re-entrant negotiation
if (peer._negotiating || peer._handlingRemoteOffer) return; // prevent re-entrant negotiation
peer._negotiating = true;
peer._negotiating = true;
try {
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({
_sendCallMsg({
type: 'offer',
type: 'offer',
targetPeerId: peerId,
targetPeerId: peerId,
@@ -1412,6 +1398,7 @@ async function _handleOffer(msg) {
@@ -1412,6 +1398,7 @@ async function _handleOffer(msg) {
}
}
const peer = callState.peers.get(peerId);
const peer = callState.peers.get(peerId);
if (!peer) return;
if (!peer) return;
+ if (!peer.pc) _attachPeerPc(peerId, peer);
try {
try {
// Perfect negotiation: detect offer collision (both sides sent offers simultaneously)
// Perfect negotiation: detect offer collision (both sides sent offers simultaneously)
@@ -1434,8 +1421,8 @@ async function _handleOffer(msg) {
@@ -1434,8 +1421,8 @@ async function _handleOffer(msg) {
peer._handlingRemoteOffer = true;
peer._handlingRemoteOffer = true;
await peer.pc.setRemoteDescription(new RTCSessionDescription(msg.sdp));
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({
_sendCallMsg({
type: 'answer',
type: 'answer',
targetPeerId: peerId,
targetPeerId: peerId,
@@ -1458,21 +1445,22 @@ async function _handleAnswer(msg) {
@@ -1458,21 +1445,22 @@ async function _handleAnswer(msg) {
}
}
try {
try {
await peer.pc.setRemoteDescription(new RTCSessionDescription(msg.sdp));
await peer.pc.setRemoteDescription(new RTCSessionDescription(msg.sdp));
+ _flushPendingIce(peer);
} catch (err) {
} catch (err) {
console.error('[Call] Handle answer failed:', err);
console.error('[Call] Handle answer failed:', err);
}
}
}
}
+/** Candidates that arrive before the remote description are kept, not thrown away. */
async function _handleIceCandidate(msg) {
async function _handleIceCandidate(msg) {
const peer = callState.peers.get(msg.fromPeerId);
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) {
function _closePeer(peerId) {
@@ -1751,9 +1739,13 @@ function _closeCallVideoPopout() {
@@ -1751,9 +1739,13 @@ function _closeCallVideoPopout() {
function _renderCallUI() {
function _renderCallUI() {
_syncCallSettingsUI();
_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') {
if (callState.vcMode && typeof vcRenderUI === 'function') {
vcRenderUI();
vcRenderUI();
+ _syncCallVideoPopout();
+ _renderFloatingPeerContextMenu();
return;
return;
}
}
@@ -1876,6 +1868,7 @@ function _createParticipantTile(opts) {
@@ -1876,6 +1868,7 @@ function _createParticipantTile(opts) {
if (opts.muted || opts.forceMuted) tile.classList.add('is-muted');
if (opts.muted || opts.forceMuted) tile.classList.add('is-muted');
if (opts.localMuted) tile.classList.add('is-local-muted');
if (opts.localMuted) tile.classList.add('is-local-muted');
if (opts.speaking && !opts.muted && !opts.forceMuted) tile.classList.add('is-speaking');
if (opts.speaking && !opts.muted && !opts.forceMuted) tile.classList.add('is-speaking');
+ if (opts.connecting) tile.classList.add('is-connecting');
// Video or avatar placeholder
// Video or avatar placeholder
const showVideo = _peerHasActiveVideo(opts);
const showVideo = _peerHasActiveVideo(opts);
@@ -2410,7 +2403,8 @@ function _setupLocalAudioProcessing() {
@@ -2410,7 +2403,8 @@ function _setupLocalAudioProcessing() {
function _processLocalSpeechFrame() {
function _processLocalSpeechFrame() {
if (!callState.localAnalyser) return;
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);
callState.localAnalyser.getByteFrequencyData(bins);
// Only average bins in the speech frequency band (~85–3400 Hz) to reduce
// Only average bins in the speech frequency band (~85–3400 Hz) to reduce
// false positives from fans, keyboard clicks, and low-frequency rumble
// false positives from fans, keyboard clicks, and low-frequency rumble
@@ -2432,6 +2426,7 @@ function _processLocalSpeechFrame() {
@@ -2432,6 +2426,7 @@ function _processLocalSpeechFrame() {
callState.localSpeechDetected = detected || Date.now() < callState.speechHoldUntil;
callState.localSpeechDetected = detected || Date.now() < callState.speechHoldUntil;
_applyLocalAudioGate();
_applyLocalAudioGate();
+ if (callState.myPeerId && !document.hidden) _setTileLevel(callState.myPeerId, _shouldTransmitAudio() ? percent / 60 : 0);
const speaking = _shouldBeSpeaking();
const speaking = _shouldBeSpeaking();
if (speaking !== callState.localSpeaking) {
if (speaking !== callState.localSpeaking) {
@@ -2482,7 +2477,8 @@ function _applyLocalAudioGate() {
@@ -2482,7 +2477,8 @@ function _applyLocalAudioGate() {
if (t.enabled !== enabled) t.enabled = enabled;
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) {
function onCallInputModeChange(value) {
@@ -3182,6 +3182,11 @@ function handleChatMessage(msg) {
@@ -3182,6 +3182,11 @@ function handleChatMessage(msg) {
_handleVcCallResponse(msg);
_handleVcCallResponse(msg);
break;
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
@@ -4829,10 +4834,11 @@ async function _joinVoiceChannelFromInvite(channelId, channelName, switchToChat
toast('That call channel is no longer available', 'error');
toast('That call channel is no longer available', 'error');
return;
return;
}
}
- if (typeof callState !== 'undefined' && callState.joined && callState.channelId === ch.id) {
+ if (typeof callState !== 'undefined' && (callState.joined || callState.connecting) && callState.channelId === ch.id) {
return;
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) {
async function acceptVcInvite(channelId, channelName) {
new file mode 100644
new file mode 100644
@@ -0,0 +1,251 @@
@@ -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 & 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 };
+})();
@@ -10,11 +10,15 @@
@@ -10,11 +10,15 @@
*/
*/
(function () {
(function () {
'use strict';
'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 = [
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: '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 = [
const DST = [
{ id: 'twitch', icon: 'fa-brands fa-twitch', label: 'Twitch', color: '#a970ff' },
{ id: 'twitch', icon: 'fa-brands fa-twitch', label: 'Twitch', color: '#a970ff' },
@@ -170,6 +170,11 @@
@@ -170,6 +170,11 @@
}
}
}
}
const effective = active || Math.min(natural, max);
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')) {
for (const b of ctrl.querySelectorAll('.ovd-btn')) {
const pressed = String(Number(b.dataset.n) === effective);
const pressed = String(Number(b.dataset.n) === effective);
if (b.getAttribute('aria-pressed') !== pressed) b.setAttribute('aria-pressed', pressed);
if (b.getAttribute('aria-pressed') !== pressed) b.setAttribute('aria-pressed', pressed);
@@ -213,5 +218,14 @@
@@ -213,5 +218,14 @@
/** Re-apply after a re-render replaced the grid's children. */
/** Re-apply after a re-render replaced the grid's children. */
function apply(grid, key) { if (grid && typeof grid._ovdRender === 'function') grid._ovdRender(); }
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 };
})();
})();
@@ -22,135 +22,151 @@ const vcState = {
@@ -22,135 +22,151 @@ const vcState = {
testing: false,
testing: false,
settingsOpen: false,
settingsOpen: false,
pollTimer: null,
pollTimer: null,
- pollInterval: 4000,
+ pollInterval: 30000, // safety net; the server pushes the list on every change
lastJoinedChannelId: null,
lastJoinedChannelId: null,
+ joinSeq: 0,
+ status: 'idle',
};
};
/* ── Channel List ──────────────────────────────────────────── */
/* ── Channel List ──────────────────────────────────────────── */
+let _vcFetchSeq = 0;
async function vcFetchChannels() {
async function vcFetchChannels() {
+ const seq = ++_vcFetchSeq;
try {
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();
const data = await resp.json();
- vcState.channels = data.channels || data || [];
- vcRenderChannelList();
+ vcApplyChannels(data.channels || data || []);
} catch {}
} 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() {
function vcRenderChannelList() {
const list = document.getElementById('vc-channel-list');
const list = document.getElementById('vc-channel-list');
if (!list) return;
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) {
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 {
} 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) {
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);
const ch = vcState.channels.find(c => c.id === channelId);
if (!ch) { vcFetchChannels(); return; }
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);
vcJoinChannel(ch);
}
}
@@ -159,6 +175,7 @@ function vcSelectChannel(channelId) {
@@ -159,6 +175,7 @@ function vcSelectChannel(channelId) {
async function vcJoinChannel(ch) {
async function vcJoinChannel(ch) {
if (!ch) return;
if (!ch) return;
+ const seq = ++vcState.joinSeq;
// Configure callState for channel-based join
// Configure callState for channel-based join
callState.channelId = ch.id;
callState.channelId = ch.id;
@@ -171,9 +188,13 @@ async function vcJoinChannel(ch) {
@@ -171,9 +188,13 @@ async function vcJoinChannel(ch) {
vcState.lastJoinedChannelId = ch.id;
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');
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');
const channelLabel = document.getElementById('vc-connected-channel');
if (channelLabel) channelLabel.textContent = ch.name;
if (channelLabel) channelLabel.textContent = ch.name;
@@ -184,9 +205,6 @@ async function vcJoinChannel(ch) {
@@ -184,9 +205,6 @@ async function vcJoinChannel(ch) {
const camSwitchGroup = document.getElementById('vc-cam-switch-group');
const camSwitchGroup = document.getElementById('vc-cam-switch-group');
if (camSwitchGroup) camSwitchGroup.style.display = ch.mode === 'mic' ? 'none' : '';
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
// Sync in-call input mode settings
const inputModeSwitch = document.getElementById('vc-input-mode-switch');
const inputModeSwitch = document.getElementById('vc-input-mode-switch');
if (inputModeSwitch) inputModeSwitch.value = callState.inputMode;
if (inputModeSwitch) inputModeSwitch.value = callState.inputMode;
@@ -195,6 +213,7 @@ async function vcJoinChannel(ch) {
@@ -195,6 +213,7 @@ async function vcJoinChannel(ch) {
try {
try {
await joinCall();
await joinCall();
} catch (err) {
} catch (err) {
+ if (seq !== vcState.joinSeq) return;
console.error('[VC] Join failed:', err);
console.error('[VC] Join failed:', err);
toast(`Failed to join voice channel: ${err.message || 'Unknown error'}`, 'error');
toast(`Failed to join voice channel: ${err.message || 'Unknown error'}`, 'error');
callState.channelId = null;
callState.channelId = null;
@@ -202,7 +221,10 @@ async function vcJoinChannel(ch) {
@@ -202,7 +221,10 @@ async function vcJoinChannel(ch) {
if (connPanel) connPanel.style.display = 'none';
if (connPanel) connPanel.style.display = 'none';
return;
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();
vcRenderChannelList();
if (typeof updateChatModeVoiceOption === 'function') updateChatModeVoiceOption(true);
if (typeof updateChatModeVoiceOption === 'function') updateChatModeVoiceOption(true);
vcUpdateMiniBar();
vcUpdateMiniBar();
@@ -210,68 +232,15 @@ async function vcJoinChannel(ch) {
@@ -210,68 +232,15 @@ async function vcJoinChannel(ch) {
/* ── Device Setup Panel ────────────────────────────────────── */
/* ── 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) {
async function vcEnumerateDevices(mode) {
try {
try {
// First try enumerating without a temp stream — if permission was already
// First try enumerating without a temp stream — if permission was already
// granted, browsers return labeled devices. This avoids acquiring a temp
// granted, browsers return labeled devices. This avoids acquiring a temp
// getUserMedia stream that can steal the audio device from an active broadcast
// getUserMedia stream that can steal the audio device from an active broadcast
// on Linux/PipeWire.
// 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 audioInputs = devices.filter(d => d.kind === 'audioinput');
const videoInputs = devices.filter(d => d.kind === 'videoinput');
const videoInputs = devices.filter(d => d.kind === 'videoinput');
@@ -304,191 +273,6 @@ async function vcEnumerateDevices(mode) {
@@ -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) {
function vcOnInputModeChange(value) {
if (!['open', 'ptt', 'vad'].includes(value)) return;
if (!['open', 'ptt', 'vad'].includes(value)) return;
callState.inputMode = value;
callState.inputMode = value;
@@ -560,15 +344,8 @@ function vcOnVadChange(value) {
@@ -560,15 +344,8 @@ function vcOnVadChange(value) {
/* ── Join / Leave ──────────────────────────────────────────── */
/* ── 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();
leaveCall();
callState.channelId = null;
callState.channelId = null;
callState.vcMode = false;
callState.vcMode = false;
@@ -576,7 +353,13 @@ function vcLeave() {
@@ -576,7 +353,13 @@ function vcLeave() {
vcState.settingsOpen = false;
vcState.settingsOpen = false;
const connPanel = document.getElementById('vc-connected-panel');
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');
const settingsPanel = document.getElementById('vc-incall-settings');
if (settingsPanel) settingsPanel.style.display = 'none';
if (settingsPanel) settingsPanel.style.display = 'none';
@@ -584,13 +367,23 @@ function vcLeave() {
@@ -584,13 +367,23 @@ function vcLeave() {
vcState.selectedChannelId = null;
vcState.selectedChannelId = null;
vcRenderChannelList();
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
// Hide mini VC bar
vcUpdateMiniBar();
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 ──────────────────────────────────────────────── */
/* ── Controls ──────────────────────────────────────────────── */
function vcToggleMute() {
function vcToggleMute() {
@@ -600,14 +393,7 @@ function vcToggleMute() {
@@ -600,14 +393,7 @@ function vcToggleMute() {
}
}
function vcToggleCamera() {
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() {
function vcToggleSettings() {
@@ -684,10 +470,10 @@ function vcRenderUI() {
@@ -684,10 +470,10 @@ function vcRenderUI() {
const grid = document.getElementById('vc-participants-grid');
const grid = document.getElementById('vc-participants-grid');
if (!grid) return;
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,
peerId: callState.myPeerId,
username: callState.localUsername,
username: callState.localUsername,
displayName: callState.localDisplayName || (typeof currentUser !== 'undefined' ? (currentUser?.display_name || currentUser?.username) : 'You'),
displayName: callState.localDisplayName || (typeof currentUser !== 'undefined' ? (currentUser?.display_name || currentUser?.username) : 'You'),
@@ -704,10 +490,8 @@ function vcRenderUI() {
@@ -704,10 +490,8 @@ function vcRenderUI() {
profileColor: callState.localProfileColor || (typeof currentUser !== 'undefined' ? currentUser?.profile_color : null),
profileColor: callState.localProfileColor || (typeof currentUser !== 'undefined' ? currentUser?.profile_color : null),
isLocal: true,
isLocal: true,
}));
}));
-
- // Remote tiles
for (const [peerId, peer] of callState.peers) {
for (const [peerId, peer] of callState.peers) {
- grid.appendChild(_createParticipantTile({
+ wanted.push(_createParticipantTile({
peerId,
peerId,
username: peer.username,
username: peer.username,
anonId: peer.anonId,
anonId: peer.anonId,
@@ -726,8 +510,20 @@ function vcRenderUI() {
@@ -726,8 +510,20 @@ function vcRenderUI() {
videoStream: peer.videoStream,
videoStream: peer.videoStream,
avatarUrl: peer.avatarUrl,
avatarUrl: peer.avatarUrl,
profileColor: peer.profileColor,
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
// Hide camera switch in mic-only mode
const camSwitchGroup = document.getElementById('vc-cam-switch-group');
const camSwitchGroup = document.getElementById('vc-cam-switch-group');
@@ -768,7 +564,7 @@ function vcHideCreateModal() {
@@ -768,7 +564,7 @@ function vcHideCreateModal() {
async function vcCreateChannel() {
async function vcCreateChannel() {
const name = (document.getElementById('vc-create-name')?.value || '').trim();
const name = (document.getElementById('vc-create-name')?.value || '').trim();
const mode = document.getElementById('vc-create-mode')?.value || 'mic+cam';
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) {
if (!name) {
const input = document.getElementById('vc-create-name');
const input = document.getElementById('vc-create-name');
@@ -806,7 +602,7 @@ async function vcCreateChannel() {
@@ -806,7 +602,7 @@ async function vcCreateChannel() {
function vcStartPolling() {
function vcStartPolling() {
vcStopPolling();
vcStopPolling();
vcFetchChannels();
vcFetchChannels();
- vcState.pollTimer = setInterval(vcFetchChannels, vcState.pollInterval);
+ vcState.pollTimer = setInterval(() => { if (!document.hidden) vcFetchChannels(); }, vcState.pollInterval);
}
}
function vcStopPolling() {
function vcStopPolling() {
@@ -31,14 +31,14 @@ async function launch(opts = {}, attempt = 0) {
@@ -31,14 +31,14 @@ async function launch(opts = {}, attempt = 0) {
catch (e) { if (attempt < 3) return launch(opts, attempt + 1); throw e; }
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();
const bin = findChrome();
if (!bin) throw new Error('Chrome not found (set CHROME_BIN)');
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.
// Random port: a collision with another Chrome just fails this attempt and launch() retries.
const port = 9800 + Math.floor(Math.random() * 5000);
const port = 9800 + Math.floor(Math.random() * 5000);
const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-cdp-'));
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',
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;
let targets;
for (let i = 0; i < 120; i++) {
for (let i = 0; i < 120; i++) {
try { targets = await getJson(`http://127.0.0.1:${port}/json`); break; } catch { await sleep(250); }
try { targets = await getJson(`http://127.0.0.1:${port}/json`); break; } catch { await sleep(250); }
@@ -801,24 +801,27 @@ router.delete('/tokens/:id', requireAuth, (req, res) => {
@@ -801,24 +801,27 @@ router.delete('/tokens/:id', requireAuth, (req, res) => {
});
});
// ── ICE / TURN server config (public — needed by unauthenticated voice chat) ─
// ── 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) => {
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) {
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 });
res.json({ iceServers: servers });
@@ -239,6 +239,45 @@ async function heroStats(timing) {
@@ -239,6 +239,45 @@ async function heroStats(timing) {
return stats;
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 ────────────────────────────────────────
// ── GET /api/home/stats-live ────────────────────────────────────────
// Just the numbers the hero strip shows, so the home page can keep them current without
// 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
// re-fetching the whole hero payload (slogans, collage, moments) every few seconds. The
@@ -26,6 +26,9 @@ const cosmetics = require('../monetization/cosmetics');
@@ -26,6 +26,9 @@ const cosmetics = require('../monetization/cosmetics');
const WS_HEARTBEAT_MS = 30000;
const WS_HEARTBEAT_MS = 30000;
const MAX_PARTICIPANTS = 8;
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';
const PUBLIC_CHANNEL_ID = 'public';
class CallServer {
class CallServer {
@@ -42,6 +45,11 @@ class CallServer {
@@ -42,6 +45,11 @@ class CallServer {
this._nextPeerId = 1;
this._nextPeerId = 1;
this._heartbeatInterval = null;
this._heartbeatInterval = null;
this._inactivityTimer = 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
// Seed the permanent Public channel
this.channels.set(PUBLIC_CHANNEL_ID, {
this.channels.set(PUBLIC_CHANNEL_ID, {
@@ -73,6 +81,7 @@ class CallServer {
@@ -73,6 +81,7 @@ class CallServer {
_cleanupInactiveChannels() {
_cleanupInactiveChannels() {
const ONE_HOUR = 60 * 60 * 1000;
const ONE_HOUR = 60 * 60 * 1000;
const now = Date.now();
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) {
for (const [channelId, ch] of this.channels) {
if (ch.permanent || ch.streamId) continue;
if (ch.permanent || ch.streamId) continue;
const room = this.rooms.get(channelId);
const room = this.rooms.get(channelId);
@@ -93,27 +102,56 @@ class CallServer {
@@ -93,27 +102,56 @@ class CallServer {
/* ── Channel management ──────────────────────────────────── */
/* ── Channel management ──────────────────────────────────── */
- listChannels() {
+ /** Channels a viewer may see: a private call only for its creator, its invitees and staff. */
+ listChannels(viewer = null) {
const result = [];
const result = [];
for (const [id, ch] of this.channels) {
for (const [id, ch] of this.channels) {
+ if (ch.private && !this._canSeePrivate(ch, viewer)) continue;
const room = this.rooms.get(id);
const room = this.rooms.get(id);
const participants = [];
const participants = [];
if (room) { for (const [pid, info] of room) participants.push(this._buildParticipantInfo(pid, info)); }
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;
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);
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 room = this.rooms.get(channelId);
const participants = [];
const participants = [];
if (room) { for (const [pid, info] of room) participants.push(this._buildParticipantInfo(pid, info)); }
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
// One user-created channel per user
for (const [, ch] of this.channels) {
for (const [, ch] of this.channels) {
if (!ch.permanent && !ch.streamId && ch.createdBy === createdBy) {
if (!ch.permanent && !ch.streamId && ch.createdBy === createdBy) {
@@ -127,9 +165,11 @@ class CallServer {
@@ -127,9 +165,11 @@ class CallServer {
createdBy, streamId: null, permanent: false, createdAt: Date.now(),
createdBy, streamId: null, permanent: false, createdAt: Date.now(),
maxParticipants: Math.min(Math.max(Number(maxParticipants) || MAX_PARTICIPANTS, 2), MAX_PARTICIPANTS),
maxParticipants: Math.min(Math.max(Number(maxParticipants) || MAX_PARTICIPANTS, 2), MAX_PARTICIPANTS),
lastEmptiedAt: Date.now(), // starts empty; inactivity timer tracks from creation
lastEmptiedAt: Date.now(), // starts empty; inactivity timer tracks from creation
+ private: !!isPrivate,
};
};
this.channels.set(id, ch);
this.channels.set(id, ch);
- return ch;
+ this._notifyChannelsChanged();
+ return this._publicChannel(ch);
}
}
createStreamChannel(streamId, mode, streamerId) {
createStreamChannel(streamId, mode, streamerId) {
@@ -163,6 +203,7 @@ class CallServer {
@@ -163,6 +203,7 @@ class CallServer {
this.endCall(channelId);
this.endCall(channelId);
this.channels.delete(channelId);
this.channels.delete(channelId);
this.callBans.delete(channelId);
this.callBans.delete(channelId);
+ this._notifyChannelsChanged();
return true;
return true;
}
}
@@ -172,7 +213,12 @@ class CallServer {
@@ -172,7 +213,12 @@ class CallServer {
_buildParticipantInfo(peerId, info) {
_buildParticipantInfo(peerId, info) {
let cosmeticProfile = {};
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 {
return {
peerId, username: info.user ? info.user.username : null,
peerId, username: info.user ? info.user.username : null,
anonId: info.anonId || null,
anonId: info.anonId || null,
@@ -214,9 +260,13 @@ class CallServer {
@@ -214,9 +260,13 @@ class CallServer {
}
}
const user = authenticateWs(token);
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 peerId = this._generatePeerId();
const anonId = user ? null : chatServer.getAnonIdForConnection(ip, resolvedId);
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 isChannelCreator = user && channel.createdBy === user.id;
const isStreamer = channel.streamId ? (user && db.getStreamById(channel.streamId)?.user_id === user.id) : false;
const isStreamer = channel.streamId ? (user && db.getStreamById(channel.streamId)?.user_id === user.id) : false;
const canModerate = this._canModerate(user, resolvedId);
const canModerate = this._canModerate(user, resolvedId);
@@ -233,10 +283,24 @@ class CallServer {
@@ -233,10 +283,24 @@ class CallServer {
if (!this.rooms.has(resolvedId)) this.rooms.set(resolvedId, new Map());
if (!this.rooms.has(resolvedId)) this.rooms.set(resolvedId, new Map());
const room = this.rooms.get(resolvedId);
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;
const maxP = channel.maxParticipants || MAX_PARTICIPANTS;
if (room.size >= maxP) { ws.send(JSON.stringify({ type: 'error', message: `Channel full (max ${maxP})` })); ws.close(); return; }
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 });
this.clients.set(ws, { channelId: resolvedId, peerId });
room.set(peerId, clientInfo);
room.set(peerId, clientInfo);
@@ -262,12 +326,14 @@ class CallServer {
@@ -262,12 +326,14 @@ class CallServer {
const joinMsg = JSON.stringify({ type: 'peer-joined', ...this._buildParticipantInfo(peerId, clientInfo) });
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); }
for (const [pid, info] of room) { if (pid !== peerId && info.ws.readyState === WebSocket.OPEN) info.ws.send(joinMsg); }
this._broadcastParticipantCount(resolvedId);
this._broadcastParticipantCount(resolvedId);
+ this._notifyChannelsChanged();
ws.on('message', (data) => {
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();
const now = Date.now();
if (now - clientInfo._msgResetTime > 1000) { clientInfo._msgCount = 0; clientInfo._msgResetTime = 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); }
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));
ws.on('close', () => this._handleDisconnect(ws, resolvedId, peerId));
@@ -301,6 +367,7 @@ class CallServer {
@@ -301,6 +367,7 @@ class CallServer {
c.muted = !!msg.muted;
c.muted = !!msg.muted;
const m = JSON.stringify({ type: 'peer-muted', peerId, muted: c.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); }
for (const [pid, info] of room) { if (pid !== peerId && info.ws.readyState === WebSocket.OPEN) info.ws.send(m); }
+ this._notifyChannelsChanged();
break;
break;
}
}
case 'camera-off': {
case 'camera-off': {
@@ -369,6 +436,7 @@ class CallServer {
@@ -369,6 +436,7 @@ class CallServer {
case 'kick': {
case 'kick': {
const sender = room.get(peerId); if (!sender || !this._canModerate(sender.user, channelId)) break;
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;
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(); }
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);
room.delete(msg.targetPeerId); this.clients.delete(target.ws);
const m = JSON.stringify({ type: 'peer-left', peerId: msg.targetPeerId, reason: 'kicked' });
const m = JSON.stringify({ type: 'peer-left', peerId: msg.targetPeerId, reason: 'kicked' });
@@ -425,6 +493,7 @@ class CallServer {
@@ -425,6 +493,7 @@ class CallServer {
const m = JSON.stringify({ type: 'peer-left', ...leftInfo, reason: 'disconnect' });
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); }
for (const [pid, info] of room) { if (info.ws.readyState === WebSocket.OPEN) info.ws.send(m); }
this._broadcastParticipantCount(channelId);
this._broadcastParticipantCount(channelId);
+ this._notifyChannelsChanged();
if (room.size === 0) {
if (room.size === 0) {
this.rooms.delete(channelId);
this.rooms.delete(channelId);
const ch = this.channels.get(channelId);
const ch = this.channels.get(channelId);
@@ -1222,19 +1222,19 @@ router.get('/recent-vods', async (req, res) => {
@@ -1222,19 +1222,19 @@ router.get('/recent-vods', async (req, res) => {
/* ── Voice Channels (global, non-stream) ───────────────────── */
/* ── Voice Channels (global, non-stream) ───────────────────── */
-router.get('/voice-channels', (req, res) => {
+router.get('/voice-channels', optionalAuth, (req, res) => {
try {
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) {
} catch (err) {
console.error('[Streaming]', err.message);
console.error('[Streaming]', err.message);
res.status(500).json({ error: 'Failed to list voice channels' });
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 {
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' });
if (!ch) return res.status(404).json({ error: 'Channel not found' });
res.json({ channel: ch });
res.json({ channel: ch });
} catch (err) {
} catch (err) {
@@ -1266,8 +1266,14 @@ router.delete('/voice-channels/:channelId', requireAuth, (req, res) => {
@@ -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) => {
router.post('/voice-channels/call-user', requireAuth, (req, res) => {
try {
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 targetUserId = Number(req.body?.user_id || 0);
const targetUsername = String(req.body?.username || '').trim();
const targetUsername = String(req.body?.username || '').trim();
@@ -1276,15 +1282,19 @@ router.post('/voice-channels/call-user', requireAuth, (req, res) => {
@@ -1276,15 +1282,19 @@ router.post('/voice-channels/call-user', requireAuth, (req, res) => {
if (!targetUser && targetUsername) targetUser = db.getUserByUsername(targetUsername);
if (!targetUser && targetUsername) targetUser = db.getUserByUsername(targetUsername);
if (!targetUser) return res.status(404).json({ error: 'User not found' });
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' });
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({
const channel = existing || callServer.createChannel({
name: `${req.user.display_name || req.user.username}'s call`,
name: `${req.user.display_name || req.user.username}'s call`,
mode: 'mic+cam',
mode: 'mic+cam',
createdBy: req.user.id,
createdBy: req.user.id,
maxParticipants: 8,
maxParticipants: 8,
+ isPrivate: true,
});
});
+ callServer.invite(channel.id, targetUser.id);
const callerName = req.user.display_name || req.user.username || 'Someone';
const callerName = req.user.display_name || req.user.username || 'Someone';
const payload = {
const payload = {
@@ -1338,6 +1348,9 @@ router.post('/voice-channels/call-user/respond', requireAuth, (req, res) => {
@@ -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 (!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 (!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' });
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';
const fromDisplayName = req.user.display_name || req.user.username || 'Someone';
chatServer.sendDm(callerUserId, {
chatServer.sendDm(callerUserId, {