openvibers/openvibe.live Commits
Home hero buttons + restream tour redesign; clip↔VOD context cards; offline channel discover board; server-side WebRTC thumbnails; failed-clip retry state
Changes
nnew file mode 100644@@@ -0,0 +1,102 @@++/* ── Context cards: clip ↔ VOD ↔ stream ↔ channel (public/js/context-cards.js) ── */++.cc-host { margin-top: 14px; }++.cc-card { position: relative; border-radius: 16px; padding: 14px 16px; background: var(--bg-secondary); border: 1px solid var(--border); }++.cc-kicker { font-size: 0.7rem; font-weight: 800; letter-spacing: 0.12em; text-transform: uppercase; color: var(--text-muted); margin-bottom: 10px; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }++.cc-kicker i { color: var(--accent); }++.cc-live { font-size: 0.66rem; color: #fff; background: #e0245e; border-radius: 999px; padding: 2px 8px; letter-spacing: 0.06em; }++.cc-main { display: grid; grid-template-columns: minmax(200px, 300px) 1fr; gap: 16px; align-items: start; }++.cc-thumb { position: relative; display: block; aspect-ratio: 16 / 9; border-radius: 12px; overflow: hidden; background: var(--bg-tertiary); border: 1px solid var(--border); }++.cc-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; transition: transform 0.35s ease; }++.cc-thumb:hover img { transform: scale(1.04); }++.cc-thumb-dur { position: absolute; right: 8px; top: 8px; font-size: 0.68rem; font-weight: 800; padding: 2px 7px; border-radius: 6px; background: rgba(0,0,0,0.72); color: #fff; }++.cc-thumb-play { position: absolute; inset: 0; display: grid; place-items: center; color: #fff; font-size: 1.4rem; opacity: 0; transition: opacity 0.2s; background: rgba(0,0,0,0.25); }++.cc-thumb:hover .cc-thumb-play { opacity: 1; }++.cc-thumb-play i { width: 46px; height: 46px; border-radius: 50%; display: grid; place-items: center; background: var(--accent); box-shadow: 0 8px 24px rgba(0,0,0,0.5); padding-left: 3px; }++.cc-timeline { position: absolute; left: 0; right: 0; bottom: 0; height: 6px; background: rgba(255,255,255,0.18); }++.cc-timeline i { position: absolute; top: -2px; bottom: -2px; min-width: 6px; border-radius: 3px; background: var(--accent); box-shadow: 0 0 10px var(--accent), 0 0 0 2px rgba(0,0,0,0.5); animation: ccMark 1.8s ease-in-out infinite; }++@keyframes ccMark { 0%, 100% { filter: brightness(1); } 50% { filter: brightness(1.5); } }++.cc-body { min-width: 0; display: grid; gap: 8px; align-content: start; }++.cc-title-row { display: flex; align-items: center; gap: 10px; }++.cc-title { font-size: 1.08rem; font-weight: 800; color: var(--text-primary); text-decoration: none; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }++.cc-title:hover { color: var(--accent-light, var(--accent)); }++.cc-grade { --g: #a78bfa; flex: 0 0 auto; width: 28px; height: 28px; border-radius: 8px; display: inline-grid; place-items: center; font-weight: 900; font-size: 0.9rem; color: var(--g); border: 1.5px solid color-mix(in srgb, var(--g) 60%, transparent); background: color-mix(in srgb, var(--g) 12%, transparent); text-decoration: none; }++.cc-sub { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; font-size: 0.82rem; color: var(--text-muted); }++.cc-name { font-weight: 700; color: var(--text-primary); text-decoration: none; }++.cc-avatar { display: inline-grid; place-items: center; border-radius: 50%; overflow: hidden; background: var(--accent); color: #fff; font-weight: 800; font-size: 0.7rem; flex: 0 0 auto; vertical-align: middle; }++.cc-avatar img { width: 100%; height: 100%; object-fit: cover; }++.cc-when { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }++.cc-badge { display: inline-flex; align-items: center; gap: 6px; padding: 5px 11px; border-radius: 999px; font-weight: 800; font-size: 0.86rem; font-variant-numeric: tabular-nums; color: #fff; background: linear-gradient(135deg, var(--accent), color-mix(in srgb, var(--accent) 60%, #f472b6)); box-shadow: 0 6px 18px color-mix(in srgb, var(--accent) 35%, transparent); }++.cc-when-text { font-size: 0.8rem; color: var(--text-secondary); }++.cc-stats { display: flex; flex-wrap: wrap; gap: 6px; }++.cc-stat { display: inline-flex; align-items: baseline; gap: 5px; padding: 5px 10px; border-radius: 10px; background: var(--bg-tertiary); border: 1px solid var(--border); font-size: 0.8rem; }++.cc-stat i { color: var(--accent); font-size: 0.72rem; align-self: center; }++.cc-stat b { font-weight: 800; color: var(--text-primary); }++.cc-stat small { color: var(--text-muted); font-size: 0.72rem; }++.cc-recap { display: inline-flex; align-items: center; gap: 8px; font-size: 0.84rem; color: var(--text-secondary); text-decoration: none; padding: 6px 10px; border-radius: 10px; background: color-mix(in srgb, #fbbf24 8%, var(--bg-tertiary)); border: 1px solid color-mix(in srgb, #fbbf24 30%, var(--border)); }++.cc-recap b { color: var(--text-primary); }++.cc-recap:hover { border-color: #fbbf24; }++.cc-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 2px; }++.cc-more { margin-top: 14px; padding-top: 12px; border-top: 1px dashed var(--border); }++.cc-more-head { font-size: 0.82rem; font-weight: 800; margin-bottom: 8px; }++.cc-more-head i { color: var(--accent); margin-right: 4px; }++.cc-clips { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 8px; }++.cc-clip { position: relative; display: block; aspect-ratio: 16 / 10; border-radius: 10px; overflow: hidden; background: var(--bg-tertiary); border: 1px solid var(--border); text-decoration: none; color: #fff; transition: transform 0.15s, border-color 0.15s; }++.cc-clip:hover { transform: translateY(-2px); border-color: var(--accent); }++.cc-clip img, .cc-clip-ph { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; display: grid; place-items: center; color: var(--text-muted); font-size: 1.4rem; }++.cc-clip-dur { position: absolute; right: 6px; top: 6px; font-size: 0.64rem; font-weight: 800; padding: 2px 6px; border-radius: 5px; background: rgba(0,0,0,0.72); }++.cc-clip-at { position: absolute; left: 6px; top: 6px; font-size: 0.64rem; font-weight: 700; padding: 2px 6px; border-radius: 5px; background: rgba(0,0,0,0.6); }++.cc-clip-title { position: absolute; left: 0; right: 0; bottom: 14px; padding: 14px 7px 2px; font-size: 0.76rem; font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; background: linear-gradient(transparent, rgba(0,0,0,0.85)); }++.cc-clip-meta { position: absolute; left: 7px; right: 7px; bottom: 4px; font-size: 0.64rem; color: rgba(255,255,255,0.8); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }++.cc-strip .cc-stats { margin-bottom: 8px; }++.cc-strip-foot { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; font-size: 0.82rem; color: var(--text-secondary); }++.cc-top a { color: var(--text-primary); font-weight: 700; text-decoration: none; }++.cc-top small { color: var(--text-muted); }++@media (max-width: 720px) { .cc-main { grid-template-columns: 1fr; } .cc-title { white-space: normal; } }++++/* ── Offline channel: discover board ── */++.dsc { width: 100%; max-width: 1040px; margin: 22px auto 6px; padding: 0 clamp(8px, 2vw, 18px); box-sizing: border-box; text-align: left; }++.dsc-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 6px 14px; margin-bottom: 14px; }++.dsc-title { font-size: 1rem; font-weight: 900; color: var(--text-primary); }++.dsc-title i { color: var(--accent); margin-right: 6px; }++.dsc-sub { font-size: 0.8rem; color: var(--text-muted); }++.dsc-sec { margin-bottom: 18px; }++.dsc-sec h4 { margin: 0 0 8px; font-size: 0.8rem; font-weight: 800; letter-spacing: 0.06em; text-transform: uppercase; color: var(--text-secondary); display: flex; align-items: center; gap: 6px; }++.dsc-sec h4 i { color: var(--accent); }++.dsc-sec h4 .muted { text-transform: none; letter-spacing: 0; font-weight: 600; }++.dsc-live { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 10px; }++.dsc-live-card { display: block; border-radius: 12px; overflow: hidden; background: var(--bg-secondary); border: 1px solid color-mix(in srgb, #e0245e 40%, var(--border)); text-decoration: none; color: var(--text-primary); transition: transform 0.15s, border-color 0.15s; }++.dsc-live-card:hover { transform: translateY(-2px); border-color: #e0245e; }++.dsc-live-thumb { position: relative; display: block; aspect-ratio: 16 / 9; background: var(--bg-tertiary); }++.dsc-live-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }++.dsc-live-pill { position: absolute; left: 8px; top: 8px; font-size: 0.62rem; font-weight: 900; letter-spacing: 0.08em; padding: 2px 7px; border-radius: 5px; background: #e0245e; color: #fff; }++.dsc-star .dsc-live-pill { position: static; margin-left: 6px; }++.dsc-live-viewers { position: absolute; right: 8px; bottom: 8px; font-size: 0.68rem; font-weight: 700; padding: 2px 7px; border-radius: 5px; background: rgba(0,0,0,0.7); color: #fff; }++.dsc-live-body { display: flex; align-items: center; gap: 8px; padding: 8px 10px; }++.dsc-live-body > span { display: grid; min-width: 0; }++.dsc-live-body b { font-size: 0.88rem; } .dsc-live-body small { color: var(--text-muted); font-size: 0.74rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }++.dsc-clips { grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); }++.dsc-two { display: grid; grid-template-columns: minmax(240px, 1fr) 1.4fr; gap: 12px; }++.dsc-star { position: relative; display: grid; grid-template-columns: auto 1fr; gap: 12px; align-items: center; padding: 16px 14px 14px; border-radius: 14px; text-decoration: none; color: var(--text-primary);++ background: linear-gradient(135deg, rgba(251,191,36,0.12), rgba(244,114,182,0.08)); border: 1px solid rgba(251,191,36,0.4); }++.dsc-star:hover { border-color: #fbbf24; }++.dsc-star-badge { position: absolute; top: -10px; left: 12px; font-size: 0.64rem; font-weight: 900; letter-spacing: 0.06em; padding: 2px 8px; border-radius: 999px; background: linear-gradient(135deg, #fde68a, #fbbf24); color: #1c1002; }++.dsc-star-body { display: grid; gap: 2px; min-width: 0; }++.dsc-star-body b { font-size: 1rem; }++.dsc-star-head { font-weight: 700; font-size: 0.86rem; color: #fde68a; }++.dsc-star-body small { color: var(--text-secondary); font-size: 0.76rem; line-height: 1.4; }++.dsc-recaps { display: grid; gap: 6px; align-content: start; }++.dsc-recaps-head { font-size: 0.74rem; font-weight: 800; letter-spacing: 0.06em; text-transform: uppercase; color: var(--text-secondary); margin-bottom: 2px; }++.dsc-recaps-head i { color: var(--accent); margin-right: 4px; }++.dsc-recap { display: grid; grid-template-columns: 28px 1fr; gap: 10px; align-items: center; padding: 7px 10px; border-radius: 10px; background: var(--bg-secondary); border: 1px solid var(--border); text-decoration: none; color: var(--text-primary); }++.dsc-recap:hover { border-color: var(--accent); }++.dsc-recap-body { display: grid; min-width: 0; }++.dsc-recap-body b { font-size: 0.84rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }++.dsc-recap-body small { font-size: 0.7rem; color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }++.dsc-people { display: flex; flex-wrap: wrap; gap: 8px; }++.dsc-person { display: grid; grid-template-columns: auto 1fr; grid-template-rows: auto auto; column-gap: 10px; align-items: center; padding: 8px 12px 8px 8px; border-radius: 999px; background: var(--bg-secondary); border: 1px solid var(--border); text-decoration: none; color: var(--text-primary); min-width: 200px; }++.dsc-person:hover { border-color: var(--accent); }++.dsc-person .cc-avatar { grid-row: 1 / 3; }++.dsc-person b { font-size: 0.86rem; } .dsc-person small { font-size: 0.7rem; color: var(--text-muted); }++.dsc-person em { grid-column: 2; font-style: normal; font-size: 0.64rem; color: var(--accent); font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; }++.dsc-live-dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: #e0245e; margin-right: 3px; animation: ccMark 1.4s ease-in-out infinite; }++@media (max-width: 760px) { .dsc-two { grid-template-columns: 1fr; } .dsc-person { min-width: 0; flex: 1 1 46%; } }@@@ -10,8 +10,30 @@background: radial-gradient(360px circle at var(--x, 50%) var(--y, 30%), color-mix(in srgb, var(--accent) 22%, transparent), transparent 70%);}#page-home .hero-content { position: relative; z-index: 2; }--#page-home .hero-cta .btn { transition: transform 0.18s cubic-bezier(.2,1.4,.3,1), background 0.2s, box-shadow 0.2s; will-change: transform; }--#page-home .hero-cta .btn-primary { box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 50%, transparent); animation: hfxPulse 2.6s ease-out infinite; }++/* ── Hero buttons: icon tile + label, brand colour per destination, one clean hover ── */++#page-home .hero-cta { display: flex; flex-wrap: wrap; justify-content: center; gap: 10px; }++.hb {++ --c: var(--accent); position: relative; display: inline-flex; align-items: center; gap: 10px; padding: 7px 16px 7px 7px; border-radius: 999px;++ font: inherit; font-weight: 700; font-size: 0.95rem; color: var(--text-primary); text-decoration: none; cursor: pointer; isolation: isolate;++ background: color-mix(in srgb, var(--bg-card) 78%, transparent); border: 1px solid color-mix(in srgb, var(--c) 28%, var(--border));++ box-shadow: 0 6px 18px rgba(0,0,0,0.22); transition: transform 0.22s cubic-bezier(.2,1.3,.3,1), border-color 0.22s, box-shadow 0.22s, color 0.22s;++}++.hb::before { content: ''; position: absolute; inset: 0; border-radius: inherit; z-index: -1; background: linear-gradient(120deg, color-mix(in srgb, var(--c) 22%, transparent), transparent 60%); opacity: 0; transition: opacity 0.25s; }++.hb::after { content: ''; position: absolute; inset: -1px; border-radius: inherit; z-index: -2; padding: 1px; background: conic-gradient(from var(--a, 0deg), transparent 0 70%, var(--c) 85%, transparent 100%); -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); -webkit-mask-composite: xor; mask-composite: exclude; opacity: 0; transition: opacity 0.25s; }++.hb-ico { width: 34px; height: 34px; border-radius: 50%; display: grid; place-items: center; font-size: 0.95rem; color: var(--c); background: color-mix(in srgb, var(--c) 14%, transparent); border: 1px solid color-mix(in srgb, var(--c) 35%, transparent); transition: transform 0.35s cubic-bezier(.2,1.6,.3,1), background 0.25s, color 0.25s, box-shadow 0.25s; }++.hb:hover, .hb:focus-visible { transform: translateY(-2px); border-color: color-mix(in srgb, var(--c) 70%, transparent); box-shadow: 0 12px 30px rgba(0,0,0,0.3), 0 0 24px color-mix(in srgb, var(--c) 30%, transparent); outline: none; }++.hb:hover::before, .hb:focus-visible::before { opacity: 1; }++.hb:hover::after { opacity: 1; animation: hbOrbit 1.6s linear infinite; }++.hb:hover .hb-ico { transform: rotate(-12deg) scale(1.12); background: var(--c); color: #fff; box-shadow: 0 0 18px color-mix(in srgb, var(--c) 60%, transparent); }++.hb:active { transform: translateY(0) scale(0.98); }++@property --a { syntax: '<angle>'; inherits: false; initial-value: 0deg; }++@keyframes hbOrbit { to { --a: 360deg; } }++.hb-stream { --c: var(--accent); color: #fff; background: linear-gradient(135deg, var(--accent), color-mix(in srgb, var(--accent) 65%, #22d3ee)); border-color: transparent; box-shadow: 0 10px 28px color-mix(in srgb, var(--accent) 40%, transparent); }++.hb-stream .hb-ico { background: rgba(255,255,255,0.18); border-color: rgba(255,255,255,0.35); color: #fff; }++.hb-stream:hover .hb-ico { background: #fff; color: var(--accent); }++.hb-stream::before { background: linear-gradient(120deg, rgba(255,255,255,0.18), transparent 60%); }++.hb-tools { --c: #2dd4bf; } .hb-game { --c: #4ade80; } .hb-github { --c: #e5e7eb; } .hb-discord { --c: #5865f2; } .hb-vibe { --c: #f472b6; }++@media (max-width: 560px) { .hb { padding: 6px 13px 6px 6px; font-size: 0.88rem; } .hb-ico { width: 30px; height: 30px; font-size: 0.85rem; } }@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); } }/* Headline: subtle breathing glow (the sweeping sheen rendered as a block over gradient text, so it is gone) */@@@ -34,12 +56,12 @@@media (prefers-reduced-motion: reduce) {.hfx-sparks, .hfx-spot { display: none; }-- .hfx-shimmer, .hfx-glow, #page-home .hero-cta .btn-primary, #page-home .section-header h2 .live-dot { animation: none !important; }++ .hfx-shimmer, .hfx-glow, .hb::after, #page-home .section-header h2 .live-dot { animation: none !important; }.hfx-reveal { opacity: 1; transform: none; transition: none; }}.rs-lite .hfx-sparks, .rs-lite .hfx-spot { display: none; }--.rs-lite .hfx-shimmer, .rs-lite .hfx-glow, .rs-lite #page-home .hero-cta .btn-primary, .rs-lite #page-home .section-header h2 .live-dot { animation: none !important; text-shadow: none; }++.rs-lite .hfx-shimmer, .rs-lite .hfx-glow, .rs-lite .hb::after, .rs-lite #page-home .section-header h2 .live-dot { animation: none !important; text-shadow: none; }.rs-lite #page-home .stream-card { transition: none; }/* ── Mobile / lite: the home page's own heavy effects (pre-existing) ──────────────@@@ -1,55 +1,82 @@/* ═══════════════════════════════════════════════════════════════-- HOME TOUR — "One stream. Everywhere. Every toy." (public/js/home-tour.js)-- Transform/opacity animations only, so it stays smooth on phones.++ HOME TOUR — "One stream. Everywhere." (public/js/home-tour.js)++ Sources → core → destinations with measured SVG wires and travelling packets.++ Subtle glass card, brand-coloured destinations, transform-only motion.═══════════════════════════════════════════════════════════════ */--.home-tour-mount { max-width: 860px; margin: 22px auto 0; }++.home-tour-mount { max-width: 940px; margin: 26px auto 0; }.tour {-- position: relative; border-radius: 20px; padding: 20px 22px 18px; text-align: center; color: var(--text-primary);-- background: linear-gradient(160deg, color-mix(in srgb, var(--accent) 12%, var(--bg-card)), var(--bg-card));-- border: 1px solid color-mix(in srgb, var(--accent) 35%, var(--border)); box-shadow: 0 20px 50px rgba(0,0,0,0.35);++ position: relative; border-radius: 22px; padding: 24px 24px 22px; color: var(--text-primary); overflow: hidden;++ background: color-mix(in srgb, var(--bg-card) 62%, transparent);++ border: 1px solid rgba(255,255,255,0.08);++ box-shadow: 0 24px 60px rgba(0,0,0,0.35), inset 0 1px 0 rgba(255,255,255,0.06);++ backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px);}--.tour-head h2 { margin: 0 0 6px; font-size: clamp(1.2rem, 2.6vw, 1.6rem); font-weight: 900; }--.tour-head h2 i { color: var(--accent-light); }--.tour-head p { margin: 0 auto 14px; max-width: 640px; color: var(--text-secondary); font-size: 0.92rem; line-height: 1.5; }--.tour-flow { display: grid; gap: 6px; justify-items: center; }--.tour-src, .tour-out { display: flex; flex-wrap: wrap; justify-content: center; gap: 8px; }--.tour-node {-- display: inline-flex; align-items: center; gap: 6px; padding: 5px 12px; border-radius: 999px; font-weight: 700; font-size: 0.8rem;-- color: var(--text-primary); background: color-mix(in srgb, var(--bg-tertiary) 80%, transparent); border: 1px solid var(--border-light);++.tour::before { content: ''; position: absolute; inset: 0; pointer-events: none; opacity: 0.35;++ background-image: radial-gradient(rgba(255,255,255,0.10) 0.8px, transparent 0.9px); background-size: 22px 22px;++ -webkit-mask-image: radial-gradient(70% 70% at 50% 40%, #000 30%, transparent 100%); mask-image: radial-gradient(70% 70% at 50% 40%, #000 30%, transparent 100%); }++.tour::after { content: ''; position: absolute; left: 10%; right: 10%; top: 0; height: 1px; background: linear-gradient(90deg, transparent, color-mix(in srgb, var(--accent) 70%, transparent), transparent); pointer-events: none; }++.tour > * { position: relative; }++.tour-head { text-align: center; margin-bottom: 18px; }++.tour-kicker { display: inline-flex; align-items: center; gap: 6px; font-size: 0.68rem; font-weight: 800; letter-spacing: 0.14em; text-transform: uppercase; color: var(--accent-light, var(--accent)); margin-bottom: 6px; }++.tour-head h2 { margin: 0 0 6px; font-size: clamp(1.35rem, 3vw, 1.9rem); font-weight: 900; letter-spacing: -0.01em; }++.tour-head p { margin: 0 auto; max-width: 620px; color: var(--text-secondary); font-size: 0.92rem; line-height: 1.55; }++++/* Stage: 3 columns on wide screens, a vertical stack on phones */++.tour-stage { --tour-vertical: 0; position: relative; display: grid; grid-template-columns: minmax(150px, 1fr) minmax(220px, 1.25fr) minmax(150px, 1fr); gap: 18px clamp(28px, 6vw, 70px); align-items: center; padding: 8px 4px; }++.tour-wires { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; overflow: visible; }++.tour-wire { fill: none; stroke-width: 1.6; stroke-linecap: round; stroke: color-mix(in srgb, var(--text-primary) 22%, transparent); stroke-dasharray: 5 7; animation: tourFlow 1.1s linear infinite; transition: stroke 0.2s, stroke-width 0.2s; }++.tour-wire--out { stroke: color-mix(in srgb, var(--c, #fff) 45%, transparent); }++@keyframes tourFlow { to { stroke-dashoffset: -12; } }++.tour-pk { fill: var(--c, #fde68a); filter: drop-shadow(0 0 4px var(--c, #fde68a)); }++.tour-pk--in { --c: #fde68a; }++.tour-wires[class*="hot-"] .tour-wire--out { stroke: color-mix(in srgb, var(--c, #fff) 18%, transparent); }++.tour-wires.hot-twitch .tour-wire--twitch, .tour-wires.hot-youtube .tour-wire--youtube, .tour-wires.hot-kick .tour-wire--kick, .tour-wires.hot-rs .tour-wire--rs, .tour-wires.hot-rtmp .tour-wire--rtmp { stroke: var(--c); stroke-width: 2.6; stroke-dasharray: none; filter: drop-shadow(0 0 6px var(--c)); }++++.tour-col { display: grid; gap: 8px; align-content: center; position: relative; z-index: 1; }++.tour-col-label { font-size: 0.64rem; font-weight: 800; letter-spacing: 0.14em; text-transform: uppercase; color: var(--text-muted); margin-bottom: 2px; }++.tour-col--src .tour-col-label { text-align: right; }++.tour-col--src .tour-node { justify-self: end; flex-direction: row-reverse; text-align: right; }++.tour-node { --c: var(--text-primary); display: inline-flex; align-items: center; gap: 9px; padding: 6px 12px 6px 6px; border-radius: 999px; max-width: 100%;++ background: color-mix(in srgb, var(--bg-tertiary) 80%, transparent); border: 1px solid color-mix(in srgb, var(--c) 30%, var(--border)); transition: transform 0.2s, border-color 0.2s, box-shadow 0.2s; }++.tour-node--src { --c: #fde68a; }++.tour-node--dst { justify-self: start; padding-right: 14px; cursor: default; }++.tour-node:hover { transform: translateY(-1px); border-color: color-mix(in srgb, var(--c) 75%, transparent); box-shadow: 0 8px 22px rgba(0,0,0,0.3), 0 0 18px color-mix(in srgb, var(--c) 25%, transparent); }++.tour-node-ico { width: 30px; height: 30px; border-radius: 50%; display: grid; place-items: center; flex: none; font-size: 0.86rem; color: var(--c); background: color-mix(in srgb, var(--c) 14%, transparent); border: 1px solid color-mix(in srgb, var(--c) 35%, transparent); }++.tour-node-text { display: grid; line-height: 1.15; min-width: 0; }++.tour-node-text b { font-size: 0.84rem; font-weight: 800; white-space: nowrap; }++.tour-node-text small { font-size: 0.66rem; color: var(--text-muted); font-weight: 600; }++.tour-node-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--c); box-shadow: 0 0 8px var(--c); margin-left: 2px; animation: tourDot 1.8s ease-in-out infinite; }++@keyframes tourDot { 0%, 100% { opacity: 0.5; transform: scale(0.85); } 50% { opacity: 1; transform: scale(1.15); } }++++.tour-core-wrap { display: grid; place-items: center; position: relative; z-index: 1; }++.tour-core { position: relative; display: grid; justify-items: center; gap: 4px; padding: 18px 20px 16px; border-radius: 20px; text-align: center; width: 100%; max-width: 300px;++ background: radial-gradient(90% 90% at 50% 0%, color-mix(in srgb, var(--accent) 35%, transparent), transparent 70%), color-mix(in srgb, var(--bg-card) 85%, transparent);++ border: 1px solid color-mix(in srgb, var(--accent) 60%, transparent); box-shadow: 0 0 0 1px rgba(255,255,255,0.04), 0 18px 40px rgba(0,0,0,0.35), 0 0 40px color-mix(in srgb, var(--accent) 25%, transparent); }++.tour-core b { font-size: 1.02rem; font-weight: 900; }++.tour-core small { font-size: 0.68rem; color: var(--text-secondary); font-weight: 600; line-height: 1.4; }++.tour-core-logo { width: 44px; height: 44px; border-radius: 50%; display: grid; place-items: center; font-size: 1.15rem; color: #fff; background: linear-gradient(135deg, var(--accent), color-mix(in srgb, var(--accent) 60%, #22d3ee)); box-shadow: 0 8px 20px color-mix(in srgb, var(--accent) 45%, transparent); margin-bottom: 4px; }++.tour-core-ring { position: absolute; left: 50%; top: 50%; width: 120%; height: 120%; border-radius: 26px; border: 1px solid color-mix(in srgb, var(--accent) 55%, transparent); transform: translate(-50%, -50%); animation: tourRing 3.2s ease-out infinite; pointer-events: none; }++.tour-core-ring--2 { animation-delay: 1.6s; }++@keyframes tourRing { 0% { transform: translate(-50%, -50%) scale(0.82); opacity: 0.7; } 100% { transform: translate(-50%, -50%) scale(1.12); opacity: 0; } }++++.tour-features { display: flex; flex-wrap: wrap; justify-content: center; gap: 6px; margin: 20px 0 0; }++.tour-feat { display: inline-flex; align-items: center; gap: 6px; padding: 5px 11px; border-radius: 999px; font-size: 0.76rem; font-weight: 700; color: var(--text-secondary);++ background: color-mix(in srgb, var(--bg-tertiary) 70%, transparent); border: 1px solid rgba(255,255,255,0.08); transition: color 0.2s, border-color 0.2s, transform 0.2s;++ animation: tourPop 0.5s cubic-bezier(.2,1.5,.3,1) both; animation-delay: calc(0.15s + var(--i, 0) * 55ms); }++.tour-feat i { color: var(--accent-light, var(--accent)); }++.tour-feat:hover { color: var(--text-primary); border-color: color-mix(in srgb, var(--accent) 50%, transparent); transform: translateY(-1px); }++@keyframes tourPop { from { opacity: 0; transform: translateY(8px) scale(0.9); } to { opacity: 1; transform: none; } }++.tour-actions { display: flex; flex-wrap: wrap; justify-content: center; gap: 10px; margin-top: 18px; }++++@media (max-width: 760px) {++ .tour { padding: 18px 14px 16px; border-radius: 18px; backdrop-filter: none; -webkit-backdrop-filter: none; background: color-mix(in srgb, var(--bg-card) 88%, transparent); }++ .tour-stage { --tour-vertical: 1; grid-template-columns: 1fr; gap: 22px; }++ .tour-col { display: flex; flex-wrap: wrap; justify-content: center; gap: 8px; }++ .tour-col-label { display: none; }++ .tour-col--src .tour-node { flex-direction: row; text-align: left; }++ .tour-node-text small { display: none; }++ .tour-core { max-width: 340px; }}--.tour-twitch { border-color: #9146ff; background: rgba(145,70,255,0.22); color: #fff; }--.tour-yt { border-color: #ff0000; background: rgba(255,0,0,0.18); color: #fff; }--.tour-kick { border-color: #53fc18; background: rgba(83,252,24,0.14); color: #d9ffcc; }--.tour-rs { border-color: #7dd3fc; background: rgba(125,211,252,0.14); color: #fff; }--.tour-hub {-- display: grid; justify-items: center; gap: 2px; padding: 10px 18px; border-radius: 14px;-- background: linear-gradient(135deg, color-mix(in srgb, var(--accent) 45%, transparent), color-mix(in srgb, var(--accent-dark) 25%, transparent));-- border: 1px solid color-mix(in srgb, var(--accent-light) 75%, transparent); color: #fff;--}--.tour-hub b { font-size: 1rem; }--.tour-hub small { color: #e9d5ff; font-size: 0.74rem; font-weight: 600; }--.tour-wire { position: relative; width: 4px; height: 26px; border-radius: 2px; background: color-mix(in srgb, var(--text-primary) 18%, transparent); }--.tour-fan { width: min(70%, 420px); height: 26px; background: none; }--.tour-fan::before { content: ''; position: absolute; left: 0; right: 0; top: 50%; height: 3px; background: repeating-linear-gradient(90deg, color-mix(in srgb, var(--text-primary) 45%, transparent) 0 8px, transparent 8px 16px); animation: tourDash 0.6s linear infinite; }--.tour-fan::after { content: ''; position: absolute; left: 50%; top: 0; width: 4px; height: 50%; background: color-mix(in srgb, var(--text-primary) 18%, transparent); transform: translateX(-50%); }--@keyframes tourDash { to { background-position: 20px 0; } }--.tour-packet { position: absolute; left: 50%; top: -6px; width: 10px; height: 10px; margin-left: -5px; border-radius: 50%; background: #fde68a; animation: tourDown 1.2s linear infinite; }--.tour-packet:nth-child(2) { animation-delay: 0.6s; }--.tour-fan .tour-packet { top: 50%; margin-top: -5px; animation: tourFan 1.4s ease-in-out infinite; }--.tour-fan .tour-packet:nth-child(2) { animation-name: tourFanL; animation-delay: 0.45s; }--.tour-fan .tour-packet:nth-child(3) { animation-name: tourFanR; animation-delay: 0.9s; }--@keyframes tourDown { 0% { transform: translateY(0); opacity: 0; } 15% { opacity: 1; } 100% { transform: translateY(26px); opacity: 0; } }--@keyframes tourFan { 0% { transform: translate(0, -14px) scale(0.6); opacity: 0; } 30% { opacity: 1; } 100% { transform: translate(0, 12px) scale(1); opacity: 0; } }--@keyframes tourFanL { 0% { transform: translate(0, -14px) scale(0.6); opacity: 0; } 30% { opacity: 1; } 100% { transform: translate(-160px, 12px) scale(1); opacity: 0; } }--@keyframes tourFanR { 0% { transform: translate(0, -14px) scale(0.6); opacity: 0; } 30% { opacity: 1; } 100% { transform: translate(160px, 12px) scale(1); opacity: 0; } }--.tour-features { display: flex; flex-wrap: wrap; justify-content: center; gap: 6px; margin: 14px 0 0; }--.tour-feat {-- display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px; border-radius: 999px; font-size: 0.78rem; font-weight: 700; color: var(--text-primary);-- background: rgba(34,197,94,0.12); border: 1px solid rgba(34,197,94,0.45);-- animation: tourPop 0.5s cubic-bezier(.2,1.5,.3,1) both; animation-delay: calc(0.2s + var(--i, 0) * 70ms);--}--.tour-feat i { color: #86efac; }--@keyframes tourPop { from { opacity: 0; transform: translateY(8px) scale(0.85); } to { opacity: 1; transform: none; } }--.tour-actions { display: flex; flex-wrap: wrap; justify-content: center; gap: 10px; margin-top: 16px; }--@media (max-width: 720px) { .tour { padding: 16px 12px 14px; } .tour-fan { width: 90%; } .tour-node { font-size: 0.74rem; padding: 4px 9px; } }--@media (prefers-reduced-motion: reduce) { .tour-fan::before, .tour-packet, .tour-feat { animation: none; } .tour-feat { opacity: 1; transform: none; } }++@media (prefers-reduced-motion: reduce) { .tour-wire, .tour-node-dot, .tour-core-ring, .tour-feat { animation: none; } .tour-feat { opacity: 1; transform: none; } }++.rs-lite .tour::before, .rs-lite .tour-core-ring { display: none; }++.rs-lite .tour-wire { animation: none; }@@@ -67,11 +67,12 @@<link rel="stylesheet" href="/css/broadcast.css?v=11"><link rel="stylesheet" href="/css/cosmetics.css?v=2"><link rel="stylesheet" href="/css/messenger.css?v=1">-- <link rel="stylesheet" href="/css/home-fx.css?v=4">-- <link rel="stylesheet" href="/css/home-tour.css?v=1">++ <link rel="stylesheet" href="/css/home-fx.css?v=5">++ <link rel="stylesheet" href="/css/home-tour.css?v=2"><link rel="stylesheet" href="/css/broadcast-guide.css?v=1"><link rel="stylesheet" href="/css/i18n-star.css?v=5"><link rel="stylesheet" href="/css/recap.css?v=1">++ <link rel="stylesheet" href="/css/context-cards.css?v=1"><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"><!-- JSON-LD Structured Data -->@@@ -232,24 +233,12 @@</div></div><div class="hero-cta">-- <button class="btn btn-primary btn-lg" onclick="currentUser ? navigate('/broadcast') : showModal('register')">-- <i class="fa-solid fa-broadcast-tower"></i> Stream-- </button>-- <a href="https://openvibe.network" class="btn btn-outline btn-lg">-- <i class="fa-solid fa-screwdriver-wrench"></i> Tools-- </a>-- <a href="https://openvibe.games/game" class="btn btn-outline btn-lg">-- <i class="fa-solid fa-gamepad"></i> Game-- </a>-- <a href="https://github.com/OpenVibers/OpenVibe.Live" target="_blank" class="btn btn-outline btn-lg">-- <i class="fa-brands fa-github"></i> GitHub-- </a>-- <a href="https://discord.gg/M6MuRUaeJj" target="_blank" class="btn btn-outline btn-lg" rel="noopener noreferrer">-- <i class="fa-brands fa-discord"></i> Discord-- </a>-- <a href="/documentation" onclick="return handleLinkClick(event, '/documentation')" class="btn btn-outline btn-lg">-- <i class="fa-solid fa-wand-magic-sparkles"></i> Vibe Coders-- </a>++ <button class="hb hb-stream" onclick="currentUser ? navigate('/broadcast') : showModal('register')"><span class="hb-ico"><i class="fa-solid fa-broadcast-tower"></i></span><span class="hb-label">Stream</span></button>++ <a href="https://openvibe.network" class="hb hb-tools"><span class="hb-ico"><i class="fa-solid fa-screwdriver-wrench"></i></span><span class="hb-label">Tools</span></a>++ <a href="https://openvibe.games/game" class="hb hb-game"><span class="hb-ico"><i class="fa-solid fa-gamepad"></i></span><span class="hb-label">Game</span></a>++ <a href="https://github.com/OpenVibers/OpenVibe.Live" target="_blank" class="hb hb-github" rel="noopener"><span class="hb-ico"><i class="fa-brands fa-github"></i></span><span class="hb-label">GitHub</span></a>++ <a href="https://discord.gg/M6MuRUaeJj" target="_blank" class="hb hb-discord" rel="noopener noreferrer"><span class="hb-ico"><i class="fa-brands fa-discord"></i></span><span class="hb-label">Discord</span></a>++ <a href="/documentation" onclick="return handleLinkClick(event, '/documentation')" class="hb hb-vibe"><span class="hb-ico"><i class="fa-solid fa-wand-magic-sparkles"></i></span><span class="hb-label">Vibe Coders</span></a></div><!-- One stream, everywhere — restream pipeline + feature tour (public/js/home-tour.js) --><div class="home-tour-mount" id="home-tour-mount"></div>@@@ -5053,7 +5042,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig<!-- Scripts --><script src="/js/theme-engine.js?v=4"></script><script src="/live-notify.js?v=3" defer></script>--<script src="/js/app.js?v=137" defer></script>++<script src="/js/app.js?v=138" defer></script><script src="/js/stream-player.js?v=21" defer></script><script src="/js/stream-pip.js?v=1" defer></script><script src="/js/chat-shared.js?v=3" defer></script>@@@ -5127,12 +5116,13 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig<script src="/js/ai-viewers.js?v=2" defer></script><script src="/js/arena.js?v=18" defer></script><script src="/js/recap.js?v=1" defer></script>++<script src="/js/context-cards.js?v=1" defer></script><script src="/js/powerchat.js?v=10" defer></script><script src="/js/pastes.js?v=15" defer></script><script src="/js/draggable-fab.js?v=2" defer></script><script src="/js/messenger.js?v=2" defer></script>--<script src="/js/home-fx.js?v=2" defer></script>--<script src="/js/home-tour.js?v=2" defer></script>++<script src="/js/home-fx.js?v=3" defer></script>++<script src="/js/home-tour.js?v=3" defer></script><script src="/js/broadcast-guide.js?v=1" defer></script><!-- ── OpenVibe shared libs (notifications, account sync, themes) ── -->@@@ -5938,6 +5938,14 @@ async function _fillOfflineExplore(username) {if (!built.length && (data.vod || data.clip)) {built.push({ key: 'all', label: 'Top content', vod: data.vod || null, clip: data.clip || null });}++ // The discover board (live now, hot clips, fresh reports, star of the day, streamers like++ // this one) fills the rest of the offline screen — with or without this channel's own content.++ const mountDiscover = () => {++ if (host.closest('.ch-offline-overlay')) return; // over a custom image: keep it light++ let d = document.getElementById('ch-discover');++ if (!d) { d = document.createElement('div'); d.id = 'ch-discover'; host.insertAdjacentElement('afterend', d); }++ if (typeof renderOfflineDiscover === 'function') renderOfflineDiscover(username, d);++ };if (!built.length) {host.innerHTML = '';// Nothing to show → don't leave an empty overlay (just an X) floating over the image.@@@ -5945,8 +5953,10 @@ async function _fillOfflineExplore(username) {const re = document.getElementById('ch-offline-reopen');if (ov) ov.style.display = 'none';if (re) re.classList.remove('show');++ mountDiscover();return;}++ mountDiscover();_offlineRanges = built;_offlineIdx = 0;@@@ -6774,6 +6784,8 @@ async function loadVodPlayer(vodId, seekTo) {} else {vpStream.style.display = 'none';}++ // Activity strip: chat lines, chatters, viewers, mic moments, follows, report.++ if (typeof renderVodContextStrip === 'function') renderVodContextStrip(v.id);}const filename = v.file_path.split('/').pop();@@@ -7470,6 +7482,8 @@ async function loadClipPlayer(clipId) {} else {clpSource.style.display = 'none';}++ // Upgrade the plain line into the rich "from this stream" card when the VOD context loads.++ if (cl.vod_id && typeof renderClipSourceCard === 'function') renderClipSourceCard(cl);}const video = document.getElementById('clp-video');@@@ -7489,7 +7503,9 @@ async function loadClipPlayer(clipId) {note.style.cssText = 'display:flex;flex-direction:column;align-items:center;justify-content:center;height:300px;gap:12px;color:var(--text-muted,#999)';note.innerHTML = failed ? `<i class="fa-solid fa-triangle-exclamation" style="font-size:2.5rem;color:var(--danger,#e74c3c)"></i>-- <p style="font-size:1.05rem;font-weight:600">This clip failed to render</p>++ <p style="font-size:1.05rem;font-weight:600">${cl.will_retry ? 'This clip is being re-cut' : 'This clip failed to render'}</p>++ ${cl.will_retry ? `<p class="muted" style="font-size:0.82rem;margin:-4px 0 8px"><i class="fa-solid fa-rotate fa-spin"></i> The server retries automatically${cl.cut_next_at ? ' · next try ' + esc(timeAgo(cl.cut_next_at)) : ''}${cl.cut_attempts ? ' · attempt ' + cl.cut_attempts : ''}</p>` : ''}++ ${cl.cut_error ? `<p class="muted" style="font-size:0.74rem;max-width:520px;margin:0 auto 8px;opacity:0.75">${esc(String(cl.cut_error).split(' | ')[0].slice(0, 160))}</p>` : ''}<p class="muted" style="font-size:0.85rem">The server couldn't cut it from the recording.</p><button class="btn btn-sm btn-outline" onclick="recutClip(${cl.id})"><i class="fa-solid fa-rotate-right"></i> Try again</button>` : `<i class="fa-solid fa-scissors fa-bounce" style="font-size:2.5rem;color:var(--accent)"></i>nnew file mode 100644@@@ -0,0 +1,169 @@++/**++ * context-cards.js — the connective tissue between clips, VODs, streams and channels.++ *++ * renderClipSourceCard(clip) clip page: the stream this clip came from as a rich card —++ * VOD thumbnail with the clip's window marked on a timeline,++ * timestamp badge, the stream's activity numbers, the++ * after-show report, and the other clips cut from it.++ * renderVodContextStrip(vodId) VOD page: activity strip (chat lines, chatters, peak/avg++ * viewers, mic moments, sound commands, follows, tips,++ * loudest chatters) + after-show report link.++ * renderOfflineDiscover(username) offline channel screen: a discover board — live now,++ * hot clips, fresh reports, the star of the day, streamers++ * like this one — so an empty channel still sends people++ * somewhere good.++ *++ * All three read the same two endpoints: GET /api/vods/:id/context and GET /api/home/discover.++ */++(function () {++ 'use strict';++ const n = (v) => Number(v || 0).toLocaleString();++ const dur = (sec) => { sec = Math.max(0, Math.round(sec || 0)); const h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60), s = sec % 60; return h ? `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}` : `${m}:${String(s).padStart(2, '0')}`; };++ const durWords = (sec) => { sec = Math.max(0, Math.round(sec || 0)); const h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60); return h ? `${h}h ${m}m` : `${m}m`; };++ const go = (href) => `href="${esc(href)}" onclick="return handleLinkClick(event, '${esc(href)}')"`;++ const ago = (ts) => { try { return timeAgo(ts); } catch { return ''; } };++ const GRADE = { S: '#fbbf24', A: '#a78bfa', B: '#38bdf8', C: '#94a3b8' };++ const av = (u, size) => `<span class="cc-avatar" style="width:${size}px;height:${size}px;${u && u.profile_color ? `background:${esc(u.profile_color)}` : ''}">${u && u.avatar_url ? `<img src="${esc(u.avatar_url)}" alt="" loading="lazy">` : esc(String((u && (u.display_name || u.username)) || '?').charAt(0).toUpperCase())}</span>`;++ const statPills = (st) => {++ if (!st) return '';++ const items = [++ ['fa-eye', n(st.peak_viewers), 'peak'],++ st.avg_viewers != null ? ['fa-chart-line', n(st.avg_viewers), 'avg'] : null,++ ['fa-comments', n(st.chat_messages), 'chat lines'],++ ['fa-users', n(st.chatters), 'chatters'],++ st.mic_moments ? ['fa-microphone-lines', n(st.mic_moments), 'mic moments'] : null,++ st.sound_commands ? ['fa-volume-high', n(st.sound_commands), 'sounds'] : null,++ st.follows_gained ? ['fa-heart', `+${n(st.follows_gained)}`, 'follows'] : null,++ st.tips && st.tips.n ? ['fa-coins', n(st.tips.t), `in ${st.tips.n} tip${st.tips.n === 1 ? '' : 's'}`] : null,++ ].filter(Boolean);++ return `<div class="cc-stats">${items.map(([i, v, l]) => `<span class="cc-stat"><i class="fa-solid ${i}"></i><b>${v}</b><small>${esc(l)}</small></span>`).join('')}</div>`;++ };++ const clipTile = (c, opts = {}) => `<a class="cc-clip" ${go(`/clip/${c.id}`)} title="${esc(c.title || 'Clip')}">++ ${c.thumbnail_url ? `<img src="${esc(c.thumbnail_url)}" alt="" loading="lazy">` : '<span class="cc-clip-ph"><i class="fa-solid fa-scissors"></i></span>'}++ <span class="cc-clip-dur">${dur(c.duration_seconds)}</span>++ ${opts.at && c.start_time != null ? `<span class="cc-clip-at"><i class="fa-regular fa-clock"></i> ${dur(c.start_time)}</span>` : ''}++ <span class="cc-clip-title">${esc(c.title || 'Clip')}</span>++ <span class="cc-clip-meta">${c.view_count ? `<i class="fa-solid fa-eye"></i> ${n(c.view_count)} · ` : ''}${c.display_name || c.by ? esc(c.display_name || c.by) : ''}${c.created_at ? ` · ${esc(ago(c.created_at))}` : ''}</span>++ </a>`;++++ async function fetchContext(vodId) {++ if (!vodId) return null;++ try { return await api(`/vods/${encodeURIComponent(vodId)}/context`); } catch { return null; }++ }++++ // ── Clip page: "From this stream" card ─────────────────────++ window.renderClipSourceCard = async function (clip) {++ const host = document.getElementById('clp-stream-source');++ if (!host || !clip) return;++ const ctx = await fetchContext(clip.vod_id);++ if (!ctx || !ctx.vod) return; // keep the plain text fallback++ const total = Math.max(1, ctx.vod.duration_seconds || 0);++ const start = Math.max(0, Number(clip.start_time) || 0), end = Math.min(total, Math.max(start + 1, Number(clip.end_time) || start + (clip.duration_seconds || 0)));++ const left = Math.min(100, start / total * 100), width = Math.max(0.8, (end - start) / total * 100);++ const canWatch = clip.vod_available !== false && ctx.vod.visibility !== 'private';++ const vodHref = `/vod/${ctx.vod.id}?t=${Math.floor(start)}`;++ const s = ctx.stream || {}, st = ctx.stats;++ const others = (ctx.clips || []).filter(c => c.id !== clip.id).slice(0, 6);++ const rc = ctx.recap;++ host.classList.add('cc-host');++ host.innerHTML = `++ <div class="cc-card">++ <div class="cc-kicker"><i class="fa-solid fa-tower-broadcast"></i> From this stream</div>++ <div class="cc-main">++ <a class="cc-thumb" ${canWatch ? go(vodHref) : 'href="#" onclick="return false"'} title="${canWatch ? 'Watch this moment in the full VOD' : 'VOD unavailable'}">++ ${ctx.vod.thumbnail_url ? `<img src="${esc(ctx.vod.thumbnail_url)}" alt="" loading="lazy">` : '<span class="cc-clip-ph"><i class="fa-solid fa-video"></i></span>'}++ <span class="cc-thumb-dur">${dur(total)}</span>++ <span class="cc-thumb-play"><i class="fa-solid fa-play"></i></span>++ <span class="cc-timeline" aria-hidden="true"><i style="left:${left.toFixed(2)}%;width:${width.toFixed(2)}%"></i></span>++ </a>++ <div class="cc-body">++ <div class="cc-title-row">++ <a class="cc-title" ${canWatch ? go(`/vod/${ctx.vod.id}`) : 'href="#" onclick="return false"'}>${esc(s.title || ctx.vod.title || 'Stream')}</a>++ ${rc ? `<a class="cc-grade" ${go(`/recap/${rc.stream_id}`)} title="${esc(rc.headline || 'After-show report')}" style="--g:${GRADE[rc.grade] || GRADE.B}">${esc(rc.grade)}</a>` : ''}++ </div>++ <div class="cc-sub">++ ${ctx.streamer ? `${av(ctx.streamer, 20)} <a class="cc-name" ${go(`/@${ctx.streamer.username}`)}>${esc(ctx.streamer.display_name)}</a>` : ''}++ ${s.started_at ? `<span>· ${esc(new Date(String(s.started_at).replace(' ', 'T') + 'Z').toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }))}</span>` : ''}++ ${s.category ? `<span>· ${esc(s.category)}</span>` : ''}++ ${ctx.vod.view_count ? `<span>· <i class="fa-solid fa-eye"></i> ${n(ctx.vod.view_count)} VOD views</span>` : ''}++ </div>++ <div class="cc-when">++ <span class="cc-badge"><i class="fa-regular fa-clock"></i> ${dur(start)} → ${dur(end)}</span>++ <span class="cc-when-text">${Math.round(start / total * 100)}% into a ${durWords(total)} stream</span>++ </div>++ ${statPills(st)}++ ${rc && rc.headline ? `<a class="cc-recap" ${go(`/recap/${rc.stream_id}`)}><i class="fa-solid fa-clipboard-list"></i> <b>After-show report:</b> ${esc(rc.headline)}</a>` : ''}++ <div class="cc-actions">++ ${canWatch ? `<a class="btn btn-primary btn-sm" ${go(vodHref)}><i class="fa-solid fa-forward"></i> Watch this moment in the VOD</a>` : '<span class="muted" style="font-size:0.8rem"><i class="fa-solid fa-eye-slash"></i> The full VOD is no longer available</span>'}++ ${ctx.streamer ? `<a class="btn btn-outline btn-sm" ${go(`/@${ctx.streamer.username}`)}><i class="fa-solid fa-user"></i> Channel</a>` : ''}++ </div>++ </div>++ </div>++ ${others.length ? `<div class="cc-more"><div class="cc-more-head"><i class="fa-solid fa-scissors"></i> More clips from this stream <span class="muted">(${others.length})</span></div><div class="cc-clips">${others.map(c => clipTile(c, { at: true })).join('')}</div></div>` : ''}++ </div>`;++ host.style.display = '';++ };++++ // ── VOD page: activity strip ────────────────────────────────++ window.renderVodContextStrip = async function (vodId) {++ const anchor = document.getElementById('vp-stream-source');++ if (!anchor) return;++ let host = document.getElementById('vp-context');++ if (!host) { host = document.createElement('div'); host.id = 'vp-context'; host.className = 'cc-host'; anchor.insertAdjacentElement('afterend', host); }++ host.innerHTML = '';++ const ctx = await fetchContext(vodId);++ if (!ctx || !ctx.stats) { host.style.display = 'none'; return; }++ const st = ctx.stats, rc = ctx.recap;++ const top = (st.top_chatters || []).slice(0, 3);++ host.innerHTML = `++ <div class="cc-card cc-strip">++ <div class="cc-kicker"><i class="fa-solid fa-bolt"></i> The room during this stream ${ctx.stream && ctx.stream.is_live ? '<span class="cc-live">LIVE · numbers so far</span>' : ''}</div>++ ${statPills(st)}++ <div class="cc-strip-foot">++ ${top.length ? `<span class="cc-top"><i class="fa-solid fa-ranking-star"></i> Loudest: ${top.map((t, i) => `${['🥇', '🥈', '🥉'][i]} <a ${go(`/@${t.username}`)}>${esc(t.display_name || t.username)}</a> <small>${n(t.n)}</small>`).join(' ')}</span>` : ''}++ ${rc ? `<a class="cc-recap" ${go(`/recap/${rc.stream_id}`)}><span class="cc-grade" style="--g:${GRADE[rc.grade] || GRADE.B}">${esc(rc.grade)}</span> <b>After-show report:</b> ${esc(rc.headline || '')}</a>` : ''}++ </div>++ </div>`;++ host.style.display = '';++ };++++ // ── Offline channel: discover board ─────────────────────────++ window.renderOfflineDiscover = async function (username, mountEl) {++ const host = mountEl || document.getElementById('ch-discover');++ if (!host) return;++ let d = null;++ try { d = await api(`/home/discover${username ? `?channel=${encodeURIComponent(username)}` : ''}`); } catch { d = null; }++ if (!d) { host.innerHTML = ''; return; }++ const name = d.channel ? d.channel.display_name : 'this channel';++ const sections = [];++ if ((d.live || []).length) {++ sections.push(`<section class="dsc-sec"><h4><i class="fa-solid fa-circle live-dot"></i> Live right now</h4><div class="dsc-live">${d.live.map(s => `++ <a class="dsc-live-card" ${go(`/@${s.username}`)}>++ <span class="dsc-live-thumb">${s.thumbnail_url ? `<img src="${esc(s.thumbnail_url)}" alt="" loading="lazy">` : '<span class="cc-clip-ph"><i class="fa-solid fa-tower-broadcast"></i></span>'}<span class="dsc-live-pill">LIVE</span><span class="dsc-live-viewers"><i class="fa-solid fa-eye"></i> ${n(s.viewer_count)}</span></span>++ <span class="dsc-live-body">${av(s, 26)}<span><b>${esc(s.display_name || s.username)}</b><small>${esc(s.title || '')}</small></span></span>++ </a>`).join('')}</div></section>`);++ }++ if ((d.clips || []).length) {++ sections.push(`<section class="dsc-sec"><h4><i class="fa-solid fa-fire"></i> ${d.clips[0].fresh ? 'Hot clips this week' : 'Most-watched clips'} <span class="muted">across OpenVibe</span></h4><div class="cc-clips dsc-clips">${d.clips.map(c => clipTile(c)).join('')}</div></section>`);++ }++ if (d.star || (d.recaps || []).length) {++ const star = d.star ? `<a class="dsc-star" ${go(`/@${d.star.username}`)}>++ <span class="dsc-star-badge">⭐ Star of the day</span>++ ${av(d.star, 56)}++ <span class="dsc-star-body"><b>${esc(d.star.display_name)}${d.star.live ? ' <span class="dsc-live-pill">LIVE</span>' : ''}</b>${d.star.headline ? `<span class="dsc-star-head">${esc(d.star.headline)}</span>` : ''}${d.star.reason ? `<small>${esc(d.star.reason)}</small>` : ''}</span>++ </a>` : '';++ const recaps = (d.recaps || []).length ? `<div class="dsc-recaps"><div class="dsc-recaps-head"><i class="fa-solid fa-clipboard-list"></i> Fresh after-show reports</div>${d.recaps.slice(0, 5).map(r => `++ <a class="dsc-recap" ${go(`/recap/${r.stream_id}`)}>++ <span class="cc-grade" style="--g:${GRADE[r.grade] || GRADE.B}">${esc(r.grade)}</span>++ <span class="dsc-recap-body"><b>${esc(r.headline || r.title)}</b><small>${esc(r.display_name)} · ${esc(ago(r.ended_at))} · ${durWords(r.duration_seconds)} · peak ${n(r.peak_viewers)}${r.chat_messages ? ` · ${n(r.chat_messages)} chat lines` : ''}</small></span>++ </a>`).join('')}</div>` : '';++ sections.push(`<section class="dsc-sec dsc-two">${star}${recaps}</section>`);++ }++ if ((d.similar || []).length) {++ sections.push(`<section class="dsc-sec"><h4><i class="fa-solid fa-people-group"></i> Streamers to check out</h4><div class="dsc-people">${d.similar.map(p => `++ <a class="dsc-person" ${go(`/@${p.username}`)}>${av(p, 44)}<b>${esc(p.display_name || p.username)}</b><small>${p.live ? '<span class="dsc-live-dot"></span> live now' : (p.last_live_at ? `live ${esc(ago(p.last_live_at))}` : '')}${p.followers ? ` · ${n(p.followers)} follower${p.followers === 1 ? '' : 's'}` : ''}</small>${p.same_category && p.category ? `<em>${esc(p.category)}</em>` : ''}</a>`).join('')}</div></section>`);++ }++ if (!sections.length) { host.innerHTML = ''; return; }++ host.innerHTML = `<div class="dsc"><div class="dsc-head"><span class="dsc-title"><i class="fa-solid fa-compass"></i> While ${esc(name)} is away</span><span class="dsc-sub">the rest of OpenVibe is right here — follow ${esc(name)} to get pinged when they're back</span></div>${sections.join('')}</div>`;++ };++})();@@@ -4,8 +4,7 @@* Purely decorative and fully additive: nothing here is required for the page to work.* - hero sparks: a lightweight canvas of drifting embers / orbs behind the hero* - cursor spotlight: a soft radial glow that follows the pointer over the hero-- * - magnetic buttons: the hero CTA buttons lean toward the cursor-- * - scroll reveal: section headers, cards and grids slide/fade in as they enter the viewport++ * - scroll reveal: section headers, cards and grids slide/fade in as they enter the viewport* - count-up: hero stat numbers tick up from 0 when first painted* - card tilt: stream cards tilt slightly toward the pointer* Everything is disabled under prefers-reduced-motion, paused when the tab is hidden, and@@@ -77,16 +76,9 @@cancelAnimationFrame(raf);raf = requestAnimationFrame(() => {spot.style.setProperty('--x', `${x}px`); spot.style.setProperty('--y', `${y}px`); spot.style.opacity = '1';-- hero.querySelectorAll('.hero-cta .btn').forEach(btn => {-- const b = btn.getBoundingClientRect();-- const dx = e.clientX - (b.left + b.width / 2), dy = e.clientY - (b.top + b.height / 2);-- const dist = Math.hypot(dx, dy);-- if (dist < 140) { const k = (1 - dist / 140) * 10; btn.style.transform = `translate(${(dx / dist) * k}px, ${(dy / dist) * k}px)`; }-- else btn.style.transform = '';-- });});});-- hero.addEventListener('pointerleave', () => { spot.style.opacity = '0'; hero.querySelectorAll('.hero-cta .btn').forEach(btn => { btn.style.transform = ''; }); });++ hero.addEventListener('pointerleave', () => { spot.style.opacity = '0'; });}// ── Scroll reveal ──────────────────────────────────────────@@@ -1,54 +1,121 @@/**-- * home-tour.js — "One stream. Everywhere. Every toy." — the animated feature tour on the home page.++ * home-tour.js — "One stream. Everywhere." — the restream pipeline on the home page.*-- * Static content, no API: the restream pipeline (your cam or robot → OpenVibe.Live → Twitch,-- * YouTube, Kick, RobotStreamer, any RTMP) with packets flowing along it, and the feature chips-- * that only exist here. Mounted into #home-tour-mount under the hero CTA. Transform-only-- * animations, so it stays smooth in mobile lite mode.++ * Three columns: what you send (cam / robot / OBS) → the OpenVibe.Live core → where it goes++ * (Twitch, YouTube, Kick, RobotStreamer, any RTMP). The wires are an SVG drawn between the real++ * DOM nodes after layout (so it is exact at every width, and re-flows to a vertical stack on++ * phones), and packets travel along them with SVG <animateMotion> — brand-coloured per++ * destination, staggered, transform-only. Hovering a destination lights up its wire.++ * Static content, no API. Mounted into #home-tour-mount under the hero buttons.*/(function () {'use strict';++ const SRC = [++ { id: 'cam', icon: 'fa-solid fa-video', label: 'Your cam', sub: 'browser · phone' },++ { 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' },++ ];++ const DST = [++ { id: 'twitch', icon: 'fa-brands fa-twitch', label: 'Twitch', color: '#a970ff' },++ { id: 'youtube', icon: 'fa-brands fa-youtube', label: 'YouTube', color: '#ff4b4b' },++ { id: 'kick', icon: 'fa-solid fa-bolt', label: 'Kick', color: '#53fc18' },++ { id: 'rs', icon: 'fa-solid fa-robot', label: 'RobotStreamer', color: '#7dd3fc' },++ { id: 'rtmp', icon: 'fa-solid fa-tower-broadcast', label: 'Any RTMP', color: '#e5e7eb' },++ ];++ const FEATURES = [++ ['fa-gauge-high', 'Sub-second WebRTC'], ['fa-face-grin-squint-tears', '7TV / BTTV / FFZ emotes'], ['fa-volume-high', 'Sound commands'],++ ['fa-gamepad', 'Robot & hardware controls'], ['fa-satellite-dish', 'Free restream, all at once'], ['fa-scissors', 'VODs, clips, AI moments'],++ ['fa-comments', 'One chat from every platform'], ['fa-language', 'Auto-translated chat'], ['fa-microphone-lines', 'The Arena: mic-judged beefs'],++ ['fa-clipboard-list', 'After-show reports'], ['fa-brands fa-github', '100% open source'],++ ];++ const go = (href) => `href="${href}" onclick="return handleLinkClick(event, '${href}')"`;++function mount() {const el = document.getElementById('home-tour-mount');if (!el || el.dataset.mounted) return;el.dataset.mounted = '1';-- const go = (href) => `href="${href}" onclick="return handleLinkClick(event, '${href}')"`;el.innerHTML = `-- <div class="tour">++ <section class="tour" aria-label="Stream from OpenVibe.Live and restream everywhere"><div class="tour-head">-- <h2><i class="fa-solid fa-satellite-dish"></i> One stream. Everywhere. Every toy.</h2>-- <p>Go live here once and mirror it to every platform at the same time — while your chat, emotes, sound commands, robot controls, VODs and clips all live in one place.</p>++ <div class="tour-kicker"><i class="fa-solid fa-satellite-dish"></i> Restream</div>++ <h2>One stream. Everywhere.</h2>++ <p>Go live here once and mirror it to every platform at the same time — your chat, emotes, sound commands, robot controls, VODs and clips stay in one place.</p></div>-- <div class="tour-flow" aria-label="Stream from OpenVibe.Live and restream everywhere">-- <div class="tour-src"><span class="tour-node"><i class="fa-solid fa-video"></i> your cam</span><span class="tour-node"><i class="fa-solid fa-robot"></i> your robot</span><span class="tour-node"><i class="fa-solid fa-desktop"></i> OBS / RTMP</span></div>-- <div class="tour-wire" aria-hidden="true"><span class="tour-packet"></span><span class="tour-packet"></span></div>-- <div class="tour-hub"><b><i class="fa-solid fa-circle-nodes"></i> OpenVibe.Live</b><small>WebRTC <1s latency · emotes · sound commands · robot controls · VODs · clips · AI moments</small></div>-- <div class="tour-wire tour-fan" aria-hidden="true"><span class="tour-packet"></span><span class="tour-packet"></span><span class="tour-packet"></span></div>-- <div class="tour-out">-- <span class="tour-node tour-twitch"><i class="fa-brands fa-twitch"></i> Twitch</span>-- <span class="tour-node tour-yt"><i class="fa-brands fa-youtube"></i> YouTube</span>-- <span class="tour-node tour-kick"><i class="fa-solid fa-bolt"></i> Kick</span>-- <span class="tour-node tour-rs"><i class="fa-solid fa-robot"></i> RobotStreamer</span>-- <span class="tour-node"><i class="fa-solid fa-tower-broadcast"></i> any RTMP</span>++ <div class="tour-stage" id="tour-stage">++ <svg class="tour-wires" id="tour-wires" aria-hidden="true"></svg>++ <div class="tour-col tour-col--src">++ <div class="tour-col-label">You send</div>++ ${SRC.map(s => `<div class="tour-node tour-node--src" data-node="${s.id}"><span class="tour-node-ico"><i class="${s.icon}"></i></span><span class="tour-node-text"><b>${s.label}</b><small>${s.sub}</small></span></div>`).join('')}++ </div>++ <div class="tour-core-wrap">++ <div class="tour-core" data-node="core">++ <span class="tour-core-ring"></span><span class="tour-core-ring tour-core-ring--2"></span>++ <span class="tour-core-logo"><i class="fa-solid fa-circle-nodes"></i></span>++ <b>OpenVibe.Live</b>++ <small><1s latency · chat · emotes · sounds · controls · VOD · clips</small>++ </div>++ </div>++ <div class="tour-col tour-col--dst">++ <div class="tour-col-label">It lands on</div>++ ${DST.map(d => `<div class="tour-node tour-node--dst" data-node="${d.id}" style="--c:${d.color}"><span class="tour-node-ico"><i class="${d.icon}"></i></span><span class="tour-node-text"><b>${d.label}</b></span><span class="tour-node-dot"></span></div>`).join('')}</div></div>-- <div class="tour-features">-- <span class="tour-feat" style="--i:0"><i class="fa-solid fa-gauge-high"></i> Sub-second WebRTC latency</span>-- <span class="tour-feat" style="--i:1"><i class="fa-solid fa-face-grin-squint-tears"></i> 7TV / BTTV / FFZ + custom emotes</span>-- <span class="tour-feat" style="--i:2"><i class="fa-solid fa-volume-high"></i> Sound commands & soundboard</span>-- <span class="tour-feat" style="--i:3"><i class="fa-solid fa-gamepad"></i> Robot & hardware controls</span>-- <span class="tour-feat" style="--i:4"><i class="fa-solid fa-satellite-dish"></i> Free restream, all at once</span>-- <span class="tour-feat" style="--i:5"><i class="fa-solid fa-scissors"></i> VODs, clips, AI moments</span>-- <span class="tour-feat" style="--i:6"><i class="fa-solid fa-comments"></i> One chat from every platform</span>-- <span class="tour-feat" style="--i:7"><i class="fa-solid fa-language"></i> Auto-translated chat</span>-- <span class="tour-feat" style="--i:8"><i class="fa-solid fa-microphone-lines"></i> The Arena: mic-judged beefs</span>-- <span class="tour-feat" style="--i:9"><i class="fa-brands fa-github"></i> 100% open source</span>-- </div>++ <div class="tour-features">${FEATURES.map(([i, t], k) => `<span class="tour-feat" style="--i:${k}"><i class="${i.startsWith('fa-brands') ? i : 'fa-solid ' + i}"></i> ${t}</span>`).join('')}</div><div class="tour-actions"><a class="btn btn-primary btn-lg" ${go('/broadcast')}><i class="fa-solid fa-tower-broadcast"></i> Go live</a><a class="btn btn-outline btn-lg" href="/broadcast?setup=restream" onclick="event.preventDefault(); startRestreamGuide();"><i class="fa-solid fa-satellite-dish"></i> Set up restreams (guided)</a></div>-- </div>`;++ </section>`;++ wire();++ let raf = 0;++ const redraw = () => { cancelAnimationFrame(raf); raf = requestAnimationFrame(wire); };++ if ('ResizeObserver' in window) new ResizeObserver(redraw).observe(el.querySelector('#tour-stage'));++ window.addEventListener('resize', redraw);++ document.fonts && document.fonts.ready && document.fonts.ready.then(redraw);++ el.querySelectorAll('.tour-node--dst').forEach(nd => {++ nd.addEventListener('pointerenter', () => el.querySelector('#tour-wires').classList.add(`hot-${nd.dataset.node}`));++ nd.addEventListener('pointerleave', () => el.querySelector('#tour-wires').classList.remove(`hot-${nd.dataset.node}`));++ });++ }++++ /** Draw the wires between the real nodes and start the packets. */++ function wire() {++ const stage = document.getElementById('tour-stage'), svg = document.getElementById('tour-wires');++ if (!stage || !svg) return;++ const R = stage.getBoundingClientRect();++ svg.setAttribute('viewBox', `0 0 ${R.width} ${R.height}`);++ svg.setAttribute('width', R.width); svg.setAttribute('height', R.height);++ const vertical = getComputedStyle(stage).getPropertyValue('--tour-vertical').trim() === '1';++ const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches || document.documentElement.classList.contains('rs-lite');++ const core = stage.querySelector('[data-node="core"]');++ if (!core) return;++ const c = rel(core.getBoundingClientRect(), R);++ const anchorOf = (node, side) => {++ const b = rel(node.getBoundingClientRect(), R);++ if (vertical) return { x: b.x + b.w / 2, y: side === 'out' ? b.y + b.h : b.y };++ return { x: side === 'out' ? b.x + b.w : b.x, y: b.y + b.h / 2 };++ };++ const coreIn = vertical ? { x: c.x + c.w / 2, y: c.y } : { x: c.x, y: c.y + c.h / 2 };++ const coreOut = vertical ? { x: c.x + c.w / 2, y: c.y + c.h } : { x: c.x + c.w, y: c.y + c.h / 2 };++ const curve = (a, b) => vertical++ ? `M${a.x},${a.y} C${a.x},${(a.y + b.y) / 2} ${b.x},${(a.y + b.y) / 2} ${b.x},${b.y}`++ : `M${a.x},${a.y} C${(a.x + b.x) / 2},${a.y} ${(a.x + b.x) / 2},${b.y} ${b.x},${b.y}`;++ let defs = '', paths = '', packets = '';++ const srcNodes = [...stage.querySelectorAll('.tour-node--src')], dstNodes = [...stage.querySelectorAll('.tour-node--dst')];++ srcNodes.forEach((nd, i) => {++ const d = curve(anchorOf(nd, 'out'), coreIn);++ paths += `<path id="tw-in-${i}" class="tour-wire tour-wire--in" d="${d}"/>`;++ if (!reduce) packets += `<circle class="tour-pk tour-pk--in" r="3.5"><animateMotion dur="${(1.6 + i * 0.2).toFixed(2)}s" begin="${(i * 0.55).toFixed(2)}s" repeatCount="indefinite"><mpath href="#tw-in-${i}"/></animateMotion></circle>`;++ });++ dstNodes.forEach((nd, i) => {++ const d = curve(coreOut, anchorOf(nd, 'in'));++ const col = nd.style.getPropertyValue('--c') || '#fff';++ paths += `<path id="tw-out-${i}" class="tour-wire tour-wire--out tour-wire--${nd.dataset.node}" style="--c:${col}" d="${d}"/>`;++ if (!reduce) for (let k = 0; k < 2; k++) packets += `<circle class="tour-pk" style="--c:${col}" r="3.5"><animateMotion dur="${(1.7 + i * 0.12).toFixed(2)}s" begin="${(i * 0.3 + k * 0.9).toFixed(2)}s" repeatCount="indefinite"><mpath href="#tw-out-${i}"/></animateMotion></circle>`;++ });++ svg.innerHTML = defs + paths + packets;}++ function rel(b, R) { return { x: b.left - R.left, y: b.top - R.top, w: b.width, h: b.height }; }++if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', mount); else mount();})();@@@ -344,6 +344,59 @@ router.get('/digest', optionalAuth, (req, res) => {}});++// ── Discover: what to watch instead when a channel is offline (or anywhere) ────────────++// ?channel=<username> excludes that streamer from the picks and finds "streamers like them".++// Live now, this week's hottest clips (site-wide), the latest after-show reports, the star of++// the day, and streamers in the same category who were live recently. Cached ~60s per channel.++const _discoverCache = new Map();++router.get('/discover', optionalAuth, async (req, res) => {++ try {++ const uname = String(req.query.channel || '').trim().toLowerCase();++ const hit = _discoverCache.get(uname);++ if (hit && Date.now() - hit.at < 60000) { res.set('Cache-Control', 'public, max-age=30'); return res.json(hit.data); }++ const me = uname ? db.getUserByUsername(uname) : null;++ const exclude = me ? me.id : null;++ const media = require('../media-client');++ const strip = (s) => { if (!s) return s; const { stream_key, managed_stream_key, ...rest } = s; return rest; };++ const live = (db.getLiveStreams() || []).filter(s => s.user_id !== exclude).slice(0, 6).map(strip);++ let clips = [];++ try {++ const co = await media.listClips({ order: 'views', limit: 40 });++ const all = (co?.clips || (Array.isArray(co) ? co : [])).filter(c => (c.status === 'ready' || !c.status) && (c.visibility == null || c.visibility === 'public') && (c.channel_user_id || c.user_id) !== exclude);++ const weekAgo = Date.now() - 7 * 86400000;++ const fresh = all.filter(c => Date.parse(String(c.created_at || '').replace(' ', 'T') + 'Z') >= weekAgo);++ clips = (fresh.length >= 4 ? fresh : all).slice(0, 8).map(c => ({ id: c.id, title: c.title, thumbnail_url: c.thumbnail_url ? media.publicUrl(c.thumbnail_url) : null, duration_seconds: c.duration_seconds || c.duration || 0, view_count: Number(c.view_count) || 0, username: c.streamer_username || c.channel_username || c.username || null, display_name: c.streamer_display_name || c.channel_display_name || c.display_name || null, created_at: c.created_at, fresh: fresh.length >= 4 }));++ } catch { clips = []; }++ let recaps = [];++ try { recaps = require('../recap/recap').listRecentRecaps(6, exclude); } catch { recaps = []; }++ let star = null;++ try {++ const pick = require('./star-job').loadPick();++ const su = pick && pick.username ? db.getUserByUsername(pick.username) : null;++ if (su && su.id !== exclude) star = { username: su.username, display_name: su.display_name || su.username, avatar_url: su.avatar_url || null, profile_color: su.profile_color || null, headline: pick.headline || null, reason: pick.reason || null, live: live.some(l => l.user_id === su.id) };++ } catch { star = null; }++ let similar = [];++ try {++ const cat = me ? (db.getChannelByUserId(me.id) || {}) : {};++ const category = cat.ai_category || cat.category || null;++ similar = (db.all(`SELECT u.id, u.username, u.display_name, u.avatar_url, u.profile_color, MAX(s.started_at) AS last_live_at, COUNT(s.id) AS sessions,++ (SELECT COUNT(*) FROM follows f WHERE f.streamer_id = u.id) AS followers,++ (SELECT COALESCE(ch.ai_category, ch.category) FROM channels ch WHERE ch.user_id = u.id) AS category++ FROM streams s JOIN users u ON u.id = s.user_id++ WHERE s.started_at >= datetime('now', '-30 days') AND COALESCE(u.is_banned, 0) = 0 AND u.id != COALESCE(?, -1)++ GROUP BY u.id ORDER BY (category = ?) DESC, followers DESC, last_live_at DESC LIMIT 8`, [exclude, category]) || [])++ .map(r => ({ ...r, live: live.some(l => l.user_id === r.id), same_category: !!(category && r.category === category) }));++ } catch { similar = []; }++ const data = { channel: me ? { username: me.username, display_name: me.display_name || me.username } : null, live, clips, recaps, star, similar };++ _discoverCache.set(uname, { at: Date.now(), data });++ res.set('Cache-Control', 'public, max-age=30');++ res.json(data);++ } catch (err) {++ console.error('[Home] discover error:', err.message);++ res.status(500).json({ error: 'Failed to load discover' });++ }++});++// Admin: pick a new Star of OpenVibe right now (the job otherwise rotates daily).router.post('/star/rotate', requireAuth, async (req, res) => {if (!req.user || req.user.role !== 'admin') return res.status(403).json({ error: 'Admin only' });@@@ -1271,7 +1271,7 @@ async function start() {// 8. Start stale stream heartbeat cleanup (every 60 seconds)let heartbeatCleanupRunning = false;const liveVodThumbGeneratedAt = new Map(); // streamId → last Media frame-grab time-- const maintenanceInterval = setInterval(() => {++ const maintenanceInterval = setInterval(async () => {if (heartbeatCleanupRunning) return;heartbeatCleanupRunning = true;try {@@@ -1373,9 +1373,13 @@ async function start() {if (liveThumbs.shouldRefreshLiveThumbnail(wsStream.id, 120000)) {const lastGen = liveVodThumbGeneratedAt.get(wsStream.id) || 0;if (Date.now() - lastGen < 120000) continue;++ liveVodThumbGeneratedAt.set(wsStream.id, Date.now());++ // First choice: a frame straight from the SFU (works for OBS/WHIP and hidden++ // tabs, and needs no recording). The Media in-progress-VOD frame is the backup.++ const grabbed = await liveThumbs.generateWebrtcThumbnail(wsStream.id, { minAgeMs: 120000 }).catch(() => null);++ if (grabbed) continue;const rec = recorder.activeRecordings.get(wsStream.id);if (!rec || !rec.vodId) continue;-- liveVodThumbGeneratedAt.set(wsStream.id, Date.now());mediaClient.generateThumbnail('vod', rec.vodId).then((out) => {const url = mediaClient.publicUrl(out?.url);@@@ -112,6 +112,58 @@ function generateLiveStreamThumbnail(streamId, streamKey, opts = {}) {});}++/**++ * Grab one frame from a WebRTC / WHIP stream straight out of the SFU: a short-lived++ * PlainRTP consumer feeds ffmpeg over loopback, ffmpeg writes one JPEG and exits. No++ * dependency on the broadcaster's browser tab (hidden tabs and OBS/WHIP send nothing) and++ * none on the recording (which can be refused when the disk is low).++ */++let _rtpPort = 31000;++function _nextRtpPort() { const p = _rtpPort; _rtpPort += 2; if (_rtpPort > 31900) _rtpPort = 31000; return p; }++function generateWebrtcThumbnail(streamId, opts = {}) {++ return new Promise(async (resolve) => {++ const minAgeMs = Number.isFinite(opts.minAgeMs) ? opts.minAgeMs : LIVE_THUMB_MIN_INTERVAL_MS;++ if (!shouldRefreshLiveThumbnail(streamId, minAgeMs)) return resolve(getCurrentLiveThumbnailUrl(streamId));++ const jobKey = `webrtc:${streamId}`;++ if (activeLiveThumbnailJobs.has(jobKey)) return resolve(getCurrentLiveThumbnailUrl(streamId));++ let sfu; try { sfu = require('../streaming/webrtc-sfu'); } catch { return resolve(null); }++ const roomId = `stream-${streamId}`;++ const producer = (() => { try { return sfu.findProducerByKind(roomId, 'video'); } catch { return null; } })();++ if (!producer) return resolve(null);++ activeLiveThumbnailJobs.add(jobKey);++ const port = _nextRtpPort();++ const filename = `stream-${streamId}-${Date.now()}.jpg`;++ const outPath = path.join(THUMB_DIR, filename);++ const sdpPath = path.join(require('os').tmpdir(), `openvibe-thumb-${streamId}-${port}.sdp`);++ let consumer = null;++ const finish = (ok) => {++ activeLiveThumbnailJobs.delete(jobKey);++ try { if (consumer) sfu.closePlainConsumer(roomId, consumer.transportId); } catch { /* */ }++ try { fs.unlinkSync(sdpPath); } catch { /* */ }++ if (ok && fs.existsSync(outPath) && fs.statSync(outPath).size > 2000) return resolve(_replaceThumb(streamId, filename));++ try { if (fs.existsSync(outPath)) fs.unlinkSync(outPath); } catch { /* */ }++ resolve(null);++ };++ try {++ consumer = await sfu.createPlainConsumer(roomId, producer.id, '127.0.0.1', port, port + 1);++ } catch (e) { console.warn(`[Thumbnails] webrtc grab: consumer failed for stream ${streamId}:`, e.message); return finish(false); }++ const pt = consumer.payloadType, codec = (consumer.mimeType || 'video/VP8').split('/')[1];++ const fmtp = consumer.codecParameters ? Object.entries(consumer.codecParameters).map(([k, v]) => `${k}=${v}`).join(';') : '';++ const sdp = ['v=0', 'o=- 0 0 IN IP4 127.0.0.1', 's=OpenVibe.Live thumbnail', 'c=IN IP4 127.0.0.1', 't=0 0',++ `m=video ${port} RTP/AVP ${pt}`, `a=rtpmap:${pt} ${codec}/${consumer.clockRate}`,++ consumer.ssrc ? `a=ssrc:${consumer.ssrc} cname:thumb` : '', fmtp ? `a=fmtp:${pt} ${fmtp}` : '', 'a=recvonly', ''].filter(l => l !== '').join('\n') + '\n';++ try { fs.writeFileSync(sdpPath, sdp, 'utf8'); } catch { return finish(false); }++ const ff = spawn('ffmpeg', [++ '-hide_banner', '-loglevel', 'error', '-y', '-protocol_whitelist', 'file,rtp,udp',++ '-analyzeduration', '4000000', '-probesize', '4000000', '-reorder_queue_size', '512',++ '-i', sdpPath, '-frames:v', '1', '-vf', `scale=${THUMB_WIDTH}:-1`, '-q:v', String(THUMB_QUALITY), outPath,++ ], { stdio: 'ignore' });++ const killTimer = setTimeout(() => { try { ff.kill('SIGKILL'); } catch { /* */ } }, 14000);++ ff.on('close', (code) => { clearTimeout(killTimer); finish(code === 0); });++ ff.on('error', () => { clearTimeout(killTimer); finish(false); });++ });++}++/** Grab one frame from a JSMPEG stream by tapping the relay WebSocket. */function generateJSMPEGThumbnail(streamId, videoPort) {return new Promise((resolve) => {@@@ -196,6 +248,7 @@ function extractFrameToFile(source, seekSeconds, outAbsPath) {}module.exports = {++ generateWebrtcThumbnail,THUMB_DIR,getStreamThumbnailState,shouldRefreshLiveThumbnail,@@@ -355,6 +355,64 @@ router.get('/:id/memories', optionalAuth, async (req, res) => {}});++/**++ * GET /api/vods/:id/context — everything around a VOD that isn't the file: the stream it came++ * from, its activity numbers (chat, viewers, mic moments, follows, clips), the clips cut from++ * it, and the after-show report. Shared by the VOD page, the clip page ("from this stream")++ * and cards. Cached 2 min.++ */++const _ctxCache = new Map();++router.get('/:id/context', optionalAuth, async (req, res) => {++ try {++ if (!/^\d+$/.test(req.params.id)) return res.status(404).json({ error: 'VOD not found' });++ const id = parseInt(req.params.id, 10);++ const hit = _ctxCache.get(id);++ if (hit && Date.now() - hit.at < 120000) { res.set('Cache-Control', 'public, max-age=60'); return res.json(hit.data); }++ let vod = null;++ try { vod = await media.getVod(id); } catch { vod = null; }++ if (!vod) return res.status(404).json({ error: 'VOD not found' });++ if (vod.visibility === 'private' && !(req.user && (req.user.id === vod.user_id || req.user.role === 'admin'))) return res.status(404).json({ error: 'VOD not found' });++ const stream = vod.stream_id ? db.getStreamById(vod.stream_id) : null;++ const streamer = db.getUserById(vod.user_id || (stream && stream.user_id));++ const safe = (fn, d) => { try { const v = fn(); return v == null ? d : v; } catch { return d; } };++ let stats = null;++ if (stream) {++ const start = String(stream.started_at || ''), end = String(stream.ended_at || new Date().toISOString().replace('T', ' ').slice(0, 19));++ const chat = safe(() => db.get('SELECT COUNT(*) AS n, COUNT(DISTINCT COALESCE(user_id, anon_id, username)) AS c FROM chat_messages WHERE stream_id = ? AND COALESCE(is_deleted, 0) = 0', [stream.id]), { n: 0, c: 0 });++ const avg = safe(() => db.get('SELECT ROUND(AVG(viewer_count), 1) AS a, COUNT(*) AS k FROM viewer_snapshots WHERE stream_id = ?', [stream.id]), { a: null, k: 0 });++ stats = {++ chat_messages: Number(chat.n) || 0, chatters: Number(chat.c) || 0,++ peak_viewers: Number(stream.peak_viewers) || 0, avg_viewers: avg.k ? Number(avg.a) : null,++ sound_commands: safe(() => db.get("SELECT COUNT(*) AS n FROM chat_messages WHERE stream_id = ? AND message_type = 'soundboard'", [stream.id]).n, 0),++ mic_moments: safe(() => db.get('SELECT COUNT(*) AS n FROM arena_mic_moments WHERE stream_id = ?', [stream.id]).n, 0),++ follows_gained: safe(() => db.get('SELECT COUNT(*) AS n FROM follows WHERE streamer_id = ? AND created_at BETWEEN ? AND ?', [stream.user_id, start, end]).n, 0),++ tips: safe(() => db.get("SELECT COUNT(*) AS n, COALESCE(SUM(amount), 0) AS t FROM transactions WHERE type = 'donation' AND (stream_id = ? OR (to_user_id = ? AND created_at BETWEEN ? AND ?))", [stream.id, stream.user_id, start, end]), { n: 0, t: 0 }),++ top_chatters: safe(() => db.all('SELECT c.username, u.display_name, u.avatar_url, u.profile_color, COUNT(*) AS n FROM chat_messages c LEFT JOIN users u ON u.id = c.user_id WHERE c.stream_id = ? AND COALESCE(c.is_deleted, 0) = 0 AND c.username IS NOT NULL AND (c.user_id IS NULL OR c.user_id != ?) GROUP BY COALESCE(c.user_id, c.username) ORDER BY n DESC LIMIT 3', [stream.id, stream.user_id]), []),++ };++ }++ let clips = [];++ try {++ const co = await media.listClips({ vod_id: id, order: 'views', limit: 12 });++ clips = (co?.clips || (Array.isArray(co) ? co : [])).filter(c => c.status === 'ready' || !c.status).map(c => ({ id: c.id, title: c.title, thumbnail_url: c.thumbnail_url ? media.publicUrl(c.thumbnail_url) : null, duration_seconds: c.duration_seconds || c.duration || 0, start_time: c.start_time, view_count: Number(c.view_count) || 0, by: c.display_name || c.username || null, created_at: c.created_at }));++ } catch { clips = []; }++ let recap = null;++ try { const r = stream ? require('../recap/recap').getRecap(stream.id) : null; if (r) recap = { stream_id: stream.id, grade: r.write.grade, headline: r.write.headline, moment: r.write.moment || null }; } catch { recap = null; }++ const data = {++ vod: { id: vod.id, title: vod.title, thumbnail_url: vod.thumbnail_url ? media.publicUrl(vod.thumbnail_url) : null, duration_seconds: vod.duration_seconds || vod.duration || 0, view_count: Number(vod.view_count) || 0, created_at: vod.created_at, visibility: vod.visibility || 'public', is_recording: !!vod.is_recording },++ stream: stream ? { id: stream.id, title: stream.title, started_at: stream.started_at, ended_at: stream.ended_at, category: stream.ai_category || stream.category || null, protocol: stream.protocol, is_live: !!stream.is_live } : null,++ streamer: streamer ? { id: streamer.id, username: streamer.username, display_name: streamer.display_name || streamer.username, avatar_url: streamer.avatar_url || null, profile_color: streamer.profile_color || null } : null,++ stats, clips, recap,++ };++ _ctxCache.set(id, { at: Date.now(), data });++ if (_ctxCache.size > 500) { const k = _ctxCache.keys().next().value; _ctxCache.delete(k); }++ res.set('Cache-Control', 'public, max-age=60');++ res.json(data);++ } catch (err) {++ console.error('[VODs] context error:', err.message);++ res.status(500).json({ error: 'Failed to load context' });++ }++});++router.get('/:id', optionalAuth, async (req, res) => {try {if (!/^\d+$/.test(req.params.id)) return res.status(404).json({ error: 'VOD not found' });@@@ -184,6 +184,15 @@ function listRecaps(userId, limit = 6) {}).filter(Boolean);}++/** Latest recaps site-wide (for discovery surfaces). */++function listRecentRecaps(limit = 6, excludeUserId = null) {++ ensureTable();++ const rows = db.all(`SELECT r.stream_id, r.json, r.created_at, u.username, u.display_name, u.avatar_url, u.profile_color++ FROM stream_recaps r JOIN users u ON u.id = r.user_id WHERE (? IS NULL OR r.user_id != ?) AND COALESCE(u.is_banned, 0) = 0++ ORDER BY r.created_at DESC LIMIT ?`, [excludeUserId, excludeUserId, limit]) || [];++ return rows.map(r => { try { const j = JSON.parse(r.json); return { stream_id: r.stream_id, username: r.username, display_name: r.display_name || r.username, avatar_url: r.avatar_url, profile_color: r.profile_color, title: j.stream.title, headline: j.write.headline, grade: j.write.grade, duration_seconds: j.stream.duration_seconds, peak_viewers: j.stream.peak_viewers, chat_messages: j.chat.messages, ended_at: j.stream.ended_at, thumbnail_url: j.vod ? j.vod.thumbnail_url : null }; } catch { return null; } }).filter(Boolean);++}++/** Streams that ended recently, ran long enough, and have no recap yet. */function pending() {ensureTable();@@@ -229,4 +238,4 @@ function start() {console.log('[Recap] after-show reports started (every 2 min, streams ≥ 8 min)');}--module.exports = { buildRecap, getRecap, ensureRecap, listRecaps, pending, announce, start, tick, gather, templateWriteup, MIN_DURATION_SEC, ensureTable };++module.exports = { buildRecap, getRecap, ensureRecap, listRecaps, listRecentRecaps, pending, announce, start, tick, gather, templateWriteup, MIN_DURATION_SEC, ensureTable };
new file mode 100644
new file mode 100644
@@ -0,0 +1,102 @@
@@ -0,0 +1,102 @@
+/* ── Context cards: clip ↔ VOD ↔ stream ↔ channel (public/js/context-cards.js) ── */
+.cc-host { margin-top: 14px; }
+.cc-card { position: relative; border-radius: 16px; padding: 14px 16px; background: var(--bg-secondary); border: 1px solid var(--border); }
+.cc-kicker { font-size: 0.7rem; font-weight: 800; letter-spacing: 0.12em; text-transform: uppercase; color: var(--text-muted); margin-bottom: 10px; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
+.cc-kicker i { color: var(--accent); }
+.cc-live { font-size: 0.66rem; color: #fff; background: #e0245e; border-radius: 999px; padding: 2px 8px; letter-spacing: 0.06em; }
+.cc-main { display: grid; grid-template-columns: minmax(200px, 300px) 1fr; gap: 16px; align-items: start; }
+.cc-thumb { position: relative; display: block; aspect-ratio: 16 / 9; border-radius: 12px; overflow: hidden; background: var(--bg-tertiary); border: 1px solid var(--border); }
+.cc-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; transition: transform 0.35s ease; }
+.cc-thumb:hover img { transform: scale(1.04); }
+.cc-thumb-dur { position: absolute; right: 8px; top: 8px; font-size: 0.68rem; font-weight: 800; padding: 2px 7px; border-radius: 6px; background: rgba(0,0,0,0.72); color: #fff; }
+.cc-thumb-play { position: absolute; inset: 0; display: grid; place-items: center; color: #fff; font-size: 1.4rem; opacity: 0; transition: opacity 0.2s; background: rgba(0,0,0,0.25); }
+.cc-thumb:hover .cc-thumb-play { opacity: 1; }
+.cc-thumb-play i { width: 46px; height: 46px; border-radius: 50%; display: grid; place-items: center; background: var(--accent); box-shadow: 0 8px 24px rgba(0,0,0,0.5); padding-left: 3px; }
+.cc-timeline { position: absolute; left: 0; right: 0; bottom: 0; height: 6px; background: rgba(255,255,255,0.18); }
+.cc-timeline i { position: absolute; top: -2px; bottom: -2px; min-width: 6px; border-radius: 3px; background: var(--accent); box-shadow: 0 0 10px var(--accent), 0 0 0 2px rgba(0,0,0,0.5); animation: ccMark 1.8s ease-in-out infinite; }
+@keyframes ccMark { 0%, 100% { filter: brightness(1); } 50% { filter: brightness(1.5); } }
+.cc-body { min-width: 0; display: grid; gap: 8px; align-content: start; }
+.cc-title-row { display: flex; align-items: center; gap: 10px; }
+.cc-title { font-size: 1.08rem; font-weight: 800; color: var(--text-primary); text-decoration: none; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
+.cc-title:hover { color: var(--accent-light, var(--accent)); }
+.cc-grade { --g: #a78bfa; flex: 0 0 auto; width: 28px; height: 28px; border-radius: 8px; display: inline-grid; place-items: center; font-weight: 900; font-size: 0.9rem; color: var(--g); border: 1.5px solid color-mix(in srgb, var(--g) 60%, transparent); background: color-mix(in srgb, var(--g) 12%, transparent); text-decoration: none; }
+.cc-sub { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; font-size: 0.82rem; color: var(--text-muted); }
+.cc-name { font-weight: 700; color: var(--text-primary); text-decoration: none; }
+.cc-avatar { display: inline-grid; place-items: center; border-radius: 50%; overflow: hidden; background: var(--accent); color: #fff; font-weight: 800; font-size: 0.7rem; flex: 0 0 auto; vertical-align: middle; }
+.cc-avatar img { width: 100%; height: 100%; object-fit: cover; }
+.cc-when { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
+.cc-badge { display: inline-flex; align-items: center; gap: 6px; padding: 5px 11px; border-radius: 999px; font-weight: 800; font-size: 0.86rem; font-variant-numeric: tabular-nums; color: #fff; background: linear-gradient(135deg, var(--accent), color-mix(in srgb, var(--accent) 60%, #f472b6)); box-shadow: 0 6px 18px color-mix(in srgb, var(--accent) 35%, transparent); }
+.cc-when-text { font-size: 0.8rem; color: var(--text-secondary); }
+.cc-stats { display: flex; flex-wrap: wrap; gap: 6px; }
+.cc-stat { display: inline-flex; align-items: baseline; gap: 5px; padding: 5px 10px; border-radius: 10px; background: var(--bg-tertiary); border: 1px solid var(--border); font-size: 0.8rem; }
+.cc-stat i { color: var(--accent); font-size: 0.72rem; align-self: center; }
+.cc-stat b { font-weight: 800; color: var(--text-primary); }
+.cc-stat small { color: var(--text-muted); font-size: 0.72rem; }
+.cc-recap { display: inline-flex; align-items: center; gap: 8px; font-size: 0.84rem; color: var(--text-secondary); text-decoration: none; padding: 6px 10px; border-radius: 10px; background: color-mix(in srgb, #fbbf24 8%, var(--bg-tertiary)); border: 1px solid color-mix(in srgb, #fbbf24 30%, var(--border)); }
+.cc-recap b { color: var(--text-primary); }
+.cc-recap:hover { border-color: #fbbf24; }
+.cc-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 2px; }
+.cc-more { margin-top: 14px; padding-top: 12px; border-top: 1px dashed var(--border); }
+.cc-more-head { font-size: 0.82rem; font-weight: 800; margin-bottom: 8px; }
+.cc-more-head i { color: var(--accent); margin-right: 4px; }
+.cc-clips { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 8px; }
+.cc-clip { position: relative; display: block; aspect-ratio: 16 / 10; border-radius: 10px; overflow: hidden; background: var(--bg-tertiary); border: 1px solid var(--border); text-decoration: none; color: #fff; transition: transform 0.15s, border-color 0.15s; }
+.cc-clip:hover { transform: translateY(-2px); border-color: var(--accent); }
+.cc-clip img, .cc-clip-ph { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; display: grid; place-items: center; color: var(--text-muted); font-size: 1.4rem; }
+.cc-clip-dur { position: absolute; right: 6px; top: 6px; font-size: 0.64rem; font-weight: 800; padding: 2px 6px; border-radius: 5px; background: rgba(0,0,0,0.72); }
+.cc-clip-at { position: absolute; left: 6px; top: 6px; font-size: 0.64rem; font-weight: 700; padding: 2px 6px; border-radius: 5px; background: rgba(0,0,0,0.6); }
+.cc-clip-title { position: absolute; left: 0; right: 0; bottom: 14px; padding: 14px 7px 2px; font-size: 0.76rem; font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; background: linear-gradient(transparent, rgba(0,0,0,0.85)); }
+.cc-clip-meta { position: absolute; left: 7px; right: 7px; bottom: 4px; font-size: 0.64rem; color: rgba(255,255,255,0.8); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.cc-strip .cc-stats { margin-bottom: 8px; }
+.cc-strip-foot { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; font-size: 0.82rem; color: var(--text-secondary); }
+.cc-top a { color: var(--text-primary); font-weight: 700; text-decoration: none; }
+.cc-top small { color: var(--text-muted); }
+@media (max-width: 720px) { .cc-main { grid-template-columns: 1fr; } .cc-title { white-space: normal; } }
+
+/* ── Offline channel: discover board ── */
+.dsc { width: 100%; max-width: 1040px; margin: 22px auto 6px; padding: 0 clamp(8px, 2vw, 18px); box-sizing: border-box; text-align: left; }
+.dsc-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 6px 14px; margin-bottom: 14px; }
+.dsc-title { font-size: 1rem; font-weight: 900; color: var(--text-primary); }
+.dsc-title i { color: var(--accent); margin-right: 6px; }
+.dsc-sub { font-size: 0.8rem; color: var(--text-muted); }
+.dsc-sec { margin-bottom: 18px; }
+.dsc-sec h4 { margin: 0 0 8px; font-size: 0.8rem; font-weight: 800; letter-spacing: 0.06em; text-transform: uppercase; color: var(--text-secondary); display: flex; align-items: center; gap: 6px; }
+.dsc-sec h4 i { color: var(--accent); }
+.dsc-sec h4 .muted { text-transform: none; letter-spacing: 0; font-weight: 600; }
+.dsc-live { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 10px; }
+.dsc-live-card { display: block; border-radius: 12px; overflow: hidden; background: var(--bg-secondary); border: 1px solid color-mix(in srgb, #e0245e 40%, var(--border)); text-decoration: none; color: var(--text-primary); transition: transform 0.15s, border-color 0.15s; }
+.dsc-live-card:hover { transform: translateY(-2px); border-color: #e0245e; }
+.dsc-live-thumb { position: relative; display: block; aspect-ratio: 16 / 9; background: var(--bg-tertiary); }
+.dsc-live-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
+.dsc-live-pill { position: absolute; left: 8px; top: 8px; font-size: 0.62rem; font-weight: 900; letter-spacing: 0.08em; padding: 2px 7px; border-radius: 5px; background: #e0245e; color: #fff; }
+.dsc-star .dsc-live-pill { position: static; margin-left: 6px; }
+.dsc-live-viewers { position: absolute; right: 8px; bottom: 8px; font-size: 0.68rem; font-weight: 700; padding: 2px 7px; border-radius: 5px; background: rgba(0,0,0,0.7); color: #fff; }
+.dsc-live-body { display: flex; align-items: center; gap: 8px; padding: 8px 10px; }
+.dsc-live-body > span { display: grid; min-width: 0; }
+.dsc-live-body b { font-size: 0.88rem; } .dsc-live-body small { color: var(--text-muted); font-size: 0.74rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.dsc-clips { grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); }
+.dsc-two { display: grid; grid-template-columns: minmax(240px, 1fr) 1.4fr; gap: 12px; }
+.dsc-star { position: relative; display: grid; grid-template-columns: auto 1fr; gap: 12px; align-items: center; padding: 16px 14px 14px; border-radius: 14px; text-decoration: none; color: var(--text-primary);
+ background: linear-gradient(135deg, rgba(251,191,36,0.12), rgba(244,114,182,0.08)); border: 1px solid rgba(251,191,36,0.4); }
+.dsc-star:hover { border-color: #fbbf24; }
+.dsc-star-badge { position: absolute; top: -10px; left: 12px; font-size: 0.64rem; font-weight: 900; letter-spacing: 0.06em; padding: 2px 8px; border-radius: 999px; background: linear-gradient(135deg, #fde68a, #fbbf24); color: #1c1002; }
+.dsc-star-body { display: grid; gap: 2px; min-width: 0; }
+.dsc-star-body b { font-size: 1rem; }
+.dsc-star-head { font-weight: 700; font-size: 0.86rem; color: #fde68a; }
+.dsc-star-body small { color: var(--text-secondary); font-size: 0.76rem; line-height: 1.4; }
+.dsc-recaps { display: grid; gap: 6px; align-content: start; }
+.dsc-recaps-head { font-size: 0.74rem; font-weight: 800; letter-spacing: 0.06em; text-transform: uppercase; color: var(--text-secondary); margin-bottom: 2px; }
+.dsc-recaps-head i { color: var(--accent); margin-right: 4px; }
+.dsc-recap { display: grid; grid-template-columns: 28px 1fr; gap: 10px; align-items: center; padding: 7px 10px; border-radius: 10px; background: var(--bg-secondary); border: 1px solid var(--border); text-decoration: none; color: var(--text-primary); }
+.dsc-recap:hover { border-color: var(--accent); }
+.dsc-recap-body { display: grid; min-width: 0; }
+.dsc-recap-body b { font-size: 0.84rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.dsc-recap-body small { font-size: 0.7rem; color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.dsc-people { display: flex; flex-wrap: wrap; gap: 8px; }
+.dsc-person { display: grid; grid-template-columns: auto 1fr; grid-template-rows: auto auto; column-gap: 10px; align-items: center; padding: 8px 12px 8px 8px; border-radius: 999px; background: var(--bg-secondary); border: 1px solid var(--border); text-decoration: none; color: var(--text-primary); min-width: 200px; }
+.dsc-person:hover { border-color: var(--accent); }
+.dsc-person .cc-avatar { grid-row: 1 / 3; }
+.dsc-person b { font-size: 0.86rem; } .dsc-person small { font-size: 0.7rem; color: var(--text-muted); }
+.dsc-person em { grid-column: 2; font-style: normal; font-size: 0.64rem; color: var(--accent); font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; }
+.dsc-live-dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: #e0245e; margin-right: 3px; animation: ccMark 1.4s ease-in-out infinite; }
+@media (max-width: 760px) { .dsc-two { grid-template-columns: 1fr; } .dsc-person { min-width: 0; flex: 1 1 46%; } }
@@ -10,8 +10,30 @@
@@ -10,8 +10,30 @@
background: radial-gradient(360px circle at var(--x, 50%) var(--y, 30%), color-mix(in srgb, var(--accent) 22%, transparent), transparent 70%);
background: radial-gradient(360px circle at var(--x, 50%) var(--y, 30%), color-mix(in srgb, var(--accent) 22%, transparent), transparent 70%);
}
}
#page-home .hero-content { position: relative; z-index: 2; }
#page-home .hero-content { position: relative; z-index: 2; }
-#page-home .hero-cta .btn { transition: transform 0.18s cubic-bezier(.2,1.4,.3,1), background 0.2s, box-shadow 0.2s; will-change: transform; }
-#page-home .hero-cta .btn-primary { box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 50%, transparent); animation: hfxPulse 2.6s ease-out infinite; }
+/* ── Hero buttons: icon tile + label, brand colour per destination, one clean hover ── */
+#page-home .hero-cta { display: flex; flex-wrap: wrap; justify-content: center; gap: 10px; }
+.hb {
+ --c: var(--accent); position: relative; display: inline-flex; align-items: center; gap: 10px; padding: 7px 16px 7px 7px; border-radius: 999px;
+ font: inherit; font-weight: 700; font-size: 0.95rem; color: var(--text-primary); text-decoration: none; cursor: pointer; isolation: isolate;
+ background: color-mix(in srgb, var(--bg-card) 78%, transparent); border: 1px solid color-mix(in srgb, var(--c) 28%, var(--border));
+ box-shadow: 0 6px 18px rgba(0,0,0,0.22); transition: transform 0.22s cubic-bezier(.2,1.3,.3,1), border-color 0.22s, box-shadow 0.22s, color 0.22s;
+}
+.hb::before { content: ''; position: absolute; inset: 0; border-radius: inherit; z-index: -1; background: linear-gradient(120deg, color-mix(in srgb, var(--c) 22%, transparent), transparent 60%); opacity: 0; transition: opacity 0.25s; }
+.hb::after { content: ''; position: absolute; inset: -1px; border-radius: inherit; z-index: -2; padding: 1px; background: conic-gradient(from var(--a, 0deg), transparent 0 70%, var(--c) 85%, transparent 100%); -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); -webkit-mask-composite: xor; mask-composite: exclude; opacity: 0; transition: opacity 0.25s; }
+.hb-ico { width: 34px; height: 34px; border-radius: 50%; display: grid; place-items: center; font-size: 0.95rem; color: var(--c); background: color-mix(in srgb, var(--c) 14%, transparent); border: 1px solid color-mix(in srgb, var(--c) 35%, transparent); transition: transform 0.35s cubic-bezier(.2,1.6,.3,1), background 0.25s, color 0.25s, box-shadow 0.25s; }
+.hb:hover, .hb:focus-visible { transform: translateY(-2px); border-color: color-mix(in srgb, var(--c) 70%, transparent); box-shadow: 0 12px 30px rgba(0,0,0,0.3), 0 0 24px color-mix(in srgb, var(--c) 30%, transparent); outline: none; }
+.hb:hover::before, .hb:focus-visible::before { opacity: 1; }
+.hb:hover::after { opacity: 1; animation: hbOrbit 1.6s linear infinite; }
+.hb:hover .hb-ico { transform: rotate(-12deg) scale(1.12); background: var(--c); color: #fff; box-shadow: 0 0 18px color-mix(in srgb, var(--c) 60%, transparent); }
+.hb:active { transform: translateY(0) scale(0.98); }
+@property --a { syntax: '<angle>'; inherits: false; initial-value: 0deg; }
+@keyframes hbOrbit { to { --a: 360deg; } }
+.hb-stream { --c: var(--accent); color: #fff; background: linear-gradient(135deg, var(--accent), color-mix(in srgb, var(--accent) 65%, #22d3ee)); border-color: transparent; box-shadow: 0 10px 28px color-mix(in srgb, var(--accent) 40%, transparent); }
+.hb-stream .hb-ico { background: rgba(255,255,255,0.18); border-color: rgba(255,255,255,0.35); color: #fff; }
+.hb-stream:hover .hb-ico { background: #fff; color: var(--accent); }
+.hb-stream::before { background: linear-gradient(120deg, rgba(255,255,255,0.18), transparent 60%); }
+.hb-tools { --c: #2dd4bf; } .hb-game { --c: #4ade80; } .hb-github { --c: #e5e7eb; } .hb-discord { --c: #5865f2; } .hb-vibe { --c: #f472b6; }
+@media (max-width: 560px) { .hb { padding: 6px 13px 6px 6px; font-size: 0.88rem; } .hb-ico { width: 30px; height: 30px; font-size: 0.85rem; } }
@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); } }
/* Headline: subtle breathing glow (the sweeping sheen rendered as a block over gradient text, so it is gone) */
/* Headline: subtle breathing glow (the sweeping sheen rendered as a block over gradient text, so it is gone) */
@@ -34,12 +56,12 @@
@@ -34,12 +56,12 @@
@media (prefers-reduced-motion: reduce) {
@media (prefers-reduced-motion: reduce) {
.hfx-sparks, .hfx-spot { display: none; }
.hfx-sparks, .hfx-spot { display: none; }
- .hfx-shimmer, .hfx-glow, #page-home .hero-cta .btn-primary, #page-home .section-header h2 .live-dot { animation: none !important; }
+ .hfx-shimmer, .hfx-glow, .hb::after, #page-home .section-header h2 .live-dot { animation: none !important; }
.hfx-reveal { opacity: 1; transform: none; transition: none; }
.hfx-reveal { opacity: 1; transform: none; transition: none; }
}
}
.rs-lite .hfx-sparks, .rs-lite .hfx-spot { display: none; }
.rs-lite .hfx-sparks, .rs-lite .hfx-spot { display: none; }
-.rs-lite .hfx-shimmer, .rs-lite .hfx-glow, .rs-lite #page-home .hero-cta .btn-primary, .rs-lite #page-home .section-header h2 .live-dot { animation: none !important; text-shadow: none; }
+.rs-lite .hfx-shimmer, .rs-lite .hfx-glow, .rs-lite .hb::after, .rs-lite #page-home .section-header h2 .live-dot { animation: none !important; text-shadow: none; }
.rs-lite #page-home .stream-card { transition: none; }
.rs-lite #page-home .stream-card { transition: none; }
/* ── Mobile / lite: the home page's own heavy effects (pre-existing) ──────────────
/* ── Mobile / lite: the home page's own heavy effects (pre-existing) ──────────────
@@ -1,55 +1,82 @@
@@ -1,55 +1,82 @@
/* ═══════════════════════════════════════════════════════════════
/* ═══════════════════════════════════════════════════════════════
- HOME TOUR — "One stream. Everywhere. Every toy." (public/js/home-tour.js)
- Transform/opacity animations only, so it stays smooth on phones.
+ HOME TOUR — "One stream. Everywhere." (public/js/home-tour.js)
+ Sources → core → destinations with measured SVG wires and travelling packets.
+ Subtle glass card, brand-coloured destinations, transform-only motion.
═══════════════════════════════════════════════════════════════ */
═══════════════════════════════════════════════════════════════ */
-.home-tour-mount { max-width: 860px; margin: 22px auto 0; }
+.home-tour-mount { max-width: 940px; margin: 26px auto 0; }
.tour {
.tour {
- position: relative; border-radius: 20px; padding: 20px 22px 18px; text-align: center; color: var(--text-primary);
- background: linear-gradient(160deg, color-mix(in srgb, var(--accent) 12%, var(--bg-card)), var(--bg-card));
- border: 1px solid color-mix(in srgb, var(--accent) 35%, var(--border)); box-shadow: 0 20px 50px rgba(0,0,0,0.35);
+ position: relative; border-radius: 22px; padding: 24px 24px 22px; color: var(--text-primary); overflow: hidden;
+ background: color-mix(in srgb, var(--bg-card) 62%, transparent);
+ border: 1px solid rgba(255,255,255,0.08);
+ box-shadow: 0 24px 60px rgba(0,0,0,0.35), inset 0 1px 0 rgba(255,255,255,0.06);
+ backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px);
}
}
-.tour-head h2 { margin: 0 0 6px; font-size: clamp(1.2rem, 2.6vw, 1.6rem); font-weight: 900; }
-.tour-head h2 i { color: var(--accent-light); }
-.tour-head p { margin: 0 auto 14px; max-width: 640px; color: var(--text-secondary); font-size: 0.92rem; line-height: 1.5; }
-.tour-flow { display: grid; gap: 6px; justify-items: center; }
-.tour-src, .tour-out { display: flex; flex-wrap: wrap; justify-content: center; gap: 8px; }
-.tour-node {
- display: inline-flex; align-items: center; gap: 6px; padding: 5px 12px; border-radius: 999px; font-weight: 700; font-size: 0.8rem;
- color: var(--text-primary); background: color-mix(in srgb, var(--bg-tertiary) 80%, transparent); border: 1px solid var(--border-light);
+.tour::before { content: ''; position: absolute; inset: 0; pointer-events: none; opacity: 0.35;
+ background-image: radial-gradient(rgba(255,255,255,0.10) 0.8px, transparent 0.9px); background-size: 22px 22px;
+ -webkit-mask-image: radial-gradient(70% 70% at 50% 40%, #000 30%, transparent 100%); mask-image: radial-gradient(70% 70% at 50% 40%, #000 30%, transparent 100%); }
+.tour::after { content: ''; position: absolute; left: 10%; right: 10%; top: 0; height: 1px; background: linear-gradient(90deg, transparent, color-mix(in srgb, var(--accent) 70%, transparent), transparent); pointer-events: none; }
+.tour > * { position: relative; }
+.tour-head { text-align: center; margin-bottom: 18px; }
+.tour-kicker { display: inline-flex; align-items: center; gap: 6px; font-size: 0.68rem; font-weight: 800; letter-spacing: 0.14em; text-transform: uppercase; color: var(--accent-light, var(--accent)); margin-bottom: 6px; }
+.tour-head h2 { margin: 0 0 6px; font-size: clamp(1.35rem, 3vw, 1.9rem); font-weight: 900; letter-spacing: -0.01em; }
+.tour-head p { margin: 0 auto; max-width: 620px; color: var(--text-secondary); font-size: 0.92rem; line-height: 1.55; }
+
+/* Stage: 3 columns on wide screens, a vertical stack on phones */
+.tour-stage { --tour-vertical: 0; position: relative; display: grid; grid-template-columns: minmax(150px, 1fr) minmax(220px, 1.25fr) minmax(150px, 1fr); gap: 18px clamp(28px, 6vw, 70px); align-items: center; padding: 8px 4px; }
+.tour-wires { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; overflow: visible; }
+.tour-wire { fill: none; stroke-width: 1.6; stroke-linecap: round; stroke: color-mix(in srgb, var(--text-primary) 22%, transparent); stroke-dasharray: 5 7; animation: tourFlow 1.1s linear infinite; transition: stroke 0.2s, stroke-width 0.2s; }
+.tour-wire--out { stroke: color-mix(in srgb, var(--c, #fff) 45%, transparent); }
+@keyframes tourFlow { to { stroke-dashoffset: -12; } }
+.tour-pk { fill: var(--c, #fde68a); filter: drop-shadow(0 0 4px var(--c, #fde68a)); }
+.tour-pk--in { --c: #fde68a; }
+.tour-wires[class*="hot-"] .tour-wire--out { stroke: color-mix(in srgb, var(--c, #fff) 18%, transparent); }
+.tour-wires.hot-twitch .tour-wire--twitch, .tour-wires.hot-youtube .tour-wire--youtube, .tour-wires.hot-kick .tour-wire--kick, .tour-wires.hot-rs .tour-wire--rs, .tour-wires.hot-rtmp .tour-wire--rtmp { stroke: var(--c); stroke-width: 2.6; stroke-dasharray: none; filter: drop-shadow(0 0 6px var(--c)); }
+
+.tour-col { display: grid; gap: 8px; align-content: center; position: relative; z-index: 1; }
+.tour-col-label { font-size: 0.64rem; font-weight: 800; letter-spacing: 0.14em; text-transform: uppercase; color: var(--text-muted); margin-bottom: 2px; }
+.tour-col--src .tour-col-label { text-align: right; }
+.tour-col--src .tour-node { justify-self: end; flex-direction: row-reverse; text-align: right; }
+.tour-node { --c: var(--text-primary); display: inline-flex; align-items: center; gap: 9px; padding: 6px 12px 6px 6px; border-radius: 999px; max-width: 100%;
+ background: color-mix(in srgb, var(--bg-tertiary) 80%, transparent); border: 1px solid color-mix(in srgb, var(--c) 30%, var(--border)); transition: transform 0.2s, border-color 0.2s, box-shadow 0.2s; }
+.tour-node--src { --c: #fde68a; }
+.tour-node--dst { justify-self: start; padding-right: 14px; cursor: default; }
+.tour-node:hover { transform: translateY(-1px); border-color: color-mix(in srgb, var(--c) 75%, transparent); box-shadow: 0 8px 22px rgba(0,0,0,0.3), 0 0 18px color-mix(in srgb, var(--c) 25%, transparent); }
+.tour-node-ico { width: 30px; height: 30px; border-radius: 50%; display: grid; place-items: center; flex: none; font-size: 0.86rem; color: var(--c); background: color-mix(in srgb, var(--c) 14%, transparent); border: 1px solid color-mix(in srgb, var(--c) 35%, transparent); }
+.tour-node-text { display: grid; line-height: 1.15; min-width: 0; }
+.tour-node-text b { font-size: 0.84rem; font-weight: 800; white-space: nowrap; }
+.tour-node-text small { font-size: 0.66rem; color: var(--text-muted); font-weight: 600; }
+.tour-node-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--c); box-shadow: 0 0 8px var(--c); margin-left: 2px; animation: tourDot 1.8s ease-in-out infinite; }
+@keyframes tourDot { 0%, 100% { opacity: 0.5; transform: scale(0.85); } 50% { opacity: 1; transform: scale(1.15); } }
+
+.tour-core-wrap { display: grid; place-items: center; position: relative; z-index: 1; }
+.tour-core { position: relative; display: grid; justify-items: center; gap: 4px; padding: 18px 20px 16px; border-radius: 20px; text-align: center; width: 100%; max-width: 300px;
+ background: radial-gradient(90% 90% at 50% 0%, color-mix(in srgb, var(--accent) 35%, transparent), transparent 70%), color-mix(in srgb, var(--bg-card) 85%, transparent);
+ border: 1px solid color-mix(in srgb, var(--accent) 60%, transparent); box-shadow: 0 0 0 1px rgba(255,255,255,0.04), 0 18px 40px rgba(0,0,0,0.35), 0 0 40px color-mix(in srgb, var(--accent) 25%, transparent); }
+.tour-core b { font-size: 1.02rem; font-weight: 900; }
+.tour-core small { font-size: 0.68rem; color: var(--text-secondary); font-weight: 600; line-height: 1.4; }
+.tour-core-logo { width: 44px; height: 44px; border-radius: 50%; display: grid; place-items: center; font-size: 1.15rem; color: #fff; background: linear-gradient(135deg, var(--accent), color-mix(in srgb, var(--accent) 60%, #22d3ee)); box-shadow: 0 8px 20px color-mix(in srgb, var(--accent) 45%, transparent); margin-bottom: 4px; }
+.tour-core-ring { position: absolute; left: 50%; top: 50%; width: 120%; height: 120%; border-radius: 26px; border: 1px solid color-mix(in srgb, var(--accent) 55%, transparent); transform: translate(-50%, -50%); animation: tourRing 3.2s ease-out infinite; pointer-events: none; }
+.tour-core-ring--2 { animation-delay: 1.6s; }
+@keyframes tourRing { 0% { transform: translate(-50%, -50%) scale(0.82); opacity: 0.7; } 100% { transform: translate(-50%, -50%) scale(1.12); opacity: 0; } }
+
+.tour-features { display: flex; flex-wrap: wrap; justify-content: center; gap: 6px; margin: 20px 0 0; }
+.tour-feat { display: inline-flex; align-items: center; gap: 6px; padding: 5px 11px; border-radius: 999px; font-size: 0.76rem; font-weight: 700; color: var(--text-secondary);
+ background: color-mix(in srgb, var(--bg-tertiary) 70%, transparent); border: 1px solid rgba(255,255,255,0.08); transition: color 0.2s, border-color 0.2s, transform 0.2s;
+ animation: tourPop 0.5s cubic-bezier(.2,1.5,.3,1) both; animation-delay: calc(0.15s + var(--i, 0) * 55ms); }
+.tour-feat i { color: var(--accent-light, var(--accent)); }
+.tour-feat:hover { color: var(--text-primary); border-color: color-mix(in srgb, var(--accent) 50%, transparent); transform: translateY(-1px); }
+@keyframes tourPop { from { opacity: 0; transform: translateY(8px) scale(0.9); } to { opacity: 1; transform: none; } }
+.tour-actions { display: flex; flex-wrap: wrap; justify-content: center; gap: 10px; margin-top: 18px; }
+
+@media (max-width: 760px) {
+ .tour { padding: 18px 14px 16px; border-radius: 18px; backdrop-filter: none; -webkit-backdrop-filter: none; background: color-mix(in srgb, var(--bg-card) 88%, transparent); }
+ .tour-stage { --tour-vertical: 1; grid-template-columns: 1fr; gap: 22px; }
+ .tour-col { display: flex; flex-wrap: wrap; justify-content: center; gap: 8px; }
+ .tour-col-label { display: none; }
+ .tour-col--src .tour-node { flex-direction: row; text-align: left; }
+ .tour-node-text small { display: none; }
+ .tour-core { max-width: 340px; }
}
}
-.tour-twitch { border-color: #9146ff; background: rgba(145,70,255,0.22); color: #fff; }
-.tour-yt { border-color: #ff0000; background: rgba(255,0,0,0.18); color: #fff; }
-.tour-kick { border-color: #53fc18; background: rgba(83,252,24,0.14); color: #d9ffcc; }
-.tour-rs { border-color: #7dd3fc; background: rgba(125,211,252,0.14); color: #fff; }
-.tour-hub {
- display: grid; justify-items: center; gap: 2px; padding: 10px 18px; border-radius: 14px;
- background: linear-gradient(135deg, color-mix(in srgb, var(--accent) 45%, transparent), color-mix(in srgb, var(--accent-dark) 25%, transparent));
- border: 1px solid color-mix(in srgb, var(--accent-light) 75%, transparent); color: #fff;
-}
-.tour-hub b { font-size: 1rem; }
-.tour-hub small { color: #e9d5ff; font-size: 0.74rem; font-weight: 600; }
-.tour-wire { position: relative; width: 4px; height: 26px; border-radius: 2px; background: color-mix(in srgb, var(--text-primary) 18%, transparent); }
-.tour-fan { width: min(70%, 420px); height: 26px; background: none; }
-.tour-fan::before { content: ''; position: absolute; left: 0; right: 0; top: 50%; height: 3px; background: repeating-linear-gradient(90deg, color-mix(in srgb, var(--text-primary) 45%, transparent) 0 8px, transparent 8px 16px); animation: tourDash 0.6s linear infinite; }
-.tour-fan::after { content: ''; position: absolute; left: 50%; top: 0; width: 4px; height: 50%; background: color-mix(in srgb, var(--text-primary) 18%, transparent); transform: translateX(-50%); }
-@keyframes tourDash { to { background-position: 20px 0; } }
-.tour-packet { position: absolute; left: 50%; top: -6px; width: 10px; height: 10px; margin-left: -5px; border-radius: 50%; background: #fde68a; animation: tourDown 1.2s linear infinite; }
-.tour-packet:nth-child(2) { animation-delay: 0.6s; }
-.tour-fan .tour-packet { top: 50%; margin-top: -5px; animation: tourFan 1.4s ease-in-out infinite; }
-.tour-fan .tour-packet:nth-child(2) { animation-name: tourFanL; animation-delay: 0.45s; }
-.tour-fan .tour-packet:nth-child(3) { animation-name: tourFanR; animation-delay: 0.9s; }
-@keyframes tourDown { 0% { transform: translateY(0); opacity: 0; } 15% { opacity: 1; } 100% { transform: translateY(26px); opacity: 0; } }
-@keyframes tourFan { 0% { transform: translate(0, -14px) scale(0.6); opacity: 0; } 30% { opacity: 1; } 100% { transform: translate(0, 12px) scale(1); opacity: 0; } }
-@keyframes tourFanL { 0% { transform: translate(0, -14px) scale(0.6); opacity: 0; } 30% { opacity: 1; } 100% { transform: translate(-160px, 12px) scale(1); opacity: 0; } }
-@keyframes tourFanR { 0% { transform: translate(0, -14px) scale(0.6); opacity: 0; } 30% { opacity: 1; } 100% { transform: translate(160px, 12px) scale(1); opacity: 0; } }
-.tour-features { display: flex; flex-wrap: wrap; justify-content: center; gap: 6px; margin: 14px 0 0; }
-.tour-feat {
- display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px; border-radius: 999px; font-size: 0.78rem; font-weight: 700; color: var(--text-primary);
- background: rgba(34,197,94,0.12); border: 1px solid rgba(34,197,94,0.45);
- animation: tourPop 0.5s cubic-bezier(.2,1.5,.3,1) both; animation-delay: calc(0.2s + var(--i, 0) * 70ms);
-}
-.tour-feat i { color: #86efac; }
-@keyframes tourPop { from { opacity: 0; transform: translateY(8px) scale(0.85); } to { opacity: 1; transform: none; } }
-.tour-actions { display: flex; flex-wrap: wrap; justify-content: center; gap: 10px; margin-top: 16px; }
-@media (max-width: 720px) { .tour { padding: 16px 12px 14px; } .tour-fan { width: 90%; } .tour-node { font-size: 0.74rem; padding: 4px 9px; } }
-@media (prefers-reduced-motion: reduce) { .tour-fan::before, .tour-packet, .tour-feat { animation: none; } .tour-feat { opacity: 1; transform: none; } }
+@media (prefers-reduced-motion: reduce) { .tour-wire, .tour-node-dot, .tour-core-ring, .tour-feat { animation: none; } .tour-feat { opacity: 1; transform: none; } }
+.rs-lite .tour::before, .rs-lite .tour-core-ring { display: none; }
+.rs-lite .tour-wire { animation: none; }
@@ -67,11 +67,12 @@
@@ -67,11 +67,12 @@
<link rel="stylesheet" href="/css/broadcast.css?v=11">
<link rel="stylesheet" href="/css/broadcast.css?v=11">
<link rel="stylesheet" href="/css/cosmetics.css?v=2">
<link rel="stylesheet" href="/css/cosmetics.css?v=2">
<link rel="stylesheet" href="/css/messenger.css?v=1">
<link rel="stylesheet" href="/css/messenger.css?v=1">
- <link rel="stylesheet" href="/css/home-fx.css?v=4">
- <link rel="stylesheet" href="/css/home-tour.css?v=1">
+ <link rel="stylesheet" href="/css/home-fx.css?v=5">
+ <link rel="stylesheet" href="/css/home-tour.css?v=2">
<link rel="stylesheet" href="/css/broadcast-guide.css?v=1">
<link rel="stylesheet" href="/css/broadcast-guide.css?v=1">
<link rel="stylesheet" href="/css/i18n-star.css?v=5">
<link rel="stylesheet" href="/css/i18n-star.css?v=5">
<link rel="stylesheet" href="/css/recap.css?v=1">
<link rel="stylesheet" href="/css/recap.css?v=1">
+ <link rel="stylesheet" href="/css/context-cards.css?v=1">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<!-- JSON-LD Structured Data -->
<!-- JSON-LD Structured Data -->
@@ -232,24 +233,12 @@
@@ -232,24 +233,12 @@
</div>
</div>
</div>
</div>
<div class="hero-cta">
<div class="hero-cta">
- <button class="btn btn-primary btn-lg" onclick="currentUser ? navigate('/broadcast') : showModal('register')">
- <i class="fa-solid fa-broadcast-tower"></i> Stream
- </button>
- <a href="https://openvibe.network" class="btn btn-outline btn-lg">
- <i class="fa-solid fa-screwdriver-wrench"></i> Tools
- </a>
- <a href="https://openvibe.games/game" class="btn btn-outline btn-lg">
- <i class="fa-solid fa-gamepad"></i> Game
- </a>
- <a href="https://github.com/OpenVibers/OpenVibe.Live" target="_blank" class="btn btn-outline btn-lg">
- <i class="fa-brands fa-github"></i> GitHub
- </a>
- <a href="https://discord.gg/M6MuRUaeJj" target="_blank" class="btn btn-outline btn-lg" rel="noopener noreferrer">
- <i class="fa-brands fa-discord"></i> Discord
- </a>
- <a href="/documentation" onclick="return handleLinkClick(event, '/documentation')" class="btn btn-outline btn-lg">
- <i class="fa-solid fa-wand-magic-sparkles"></i> Vibe Coders
- </a>
+ <button class="hb hb-stream" onclick="currentUser ? navigate('/broadcast') : showModal('register')"><span class="hb-ico"><i class="fa-solid fa-broadcast-tower"></i></span><span class="hb-label">Stream</span></button>
+ <a href="https://openvibe.network" class="hb hb-tools"><span class="hb-ico"><i class="fa-solid fa-screwdriver-wrench"></i></span><span class="hb-label">Tools</span></a>
+ <a href="https://openvibe.games/game" class="hb hb-game"><span class="hb-ico"><i class="fa-solid fa-gamepad"></i></span><span class="hb-label">Game</span></a>
+ <a href="https://github.com/OpenVibers/OpenVibe.Live" target="_blank" class="hb hb-github" rel="noopener"><span class="hb-ico"><i class="fa-brands fa-github"></i></span><span class="hb-label">GitHub</span></a>
+ <a href="https://discord.gg/M6MuRUaeJj" target="_blank" class="hb hb-discord" rel="noopener noreferrer"><span class="hb-ico"><i class="fa-brands fa-discord"></i></span><span class="hb-label">Discord</span></a>
+ <a href="/documentation" onclick="return handleLinkClick(event, '/documentation')" class="hb hb-vibe"><span class="hb-ico"><i class="fa-solid fa-wand-magic-sparkles"></i></span><span class="hb-label">Vibe Coders</span></a>
</div>
</div>
<!-- One stream, everywhere — restream pipeline + feature tour (public/js/home-tour.js) -->
<!-- One stream, everywhere — restream pipeline + feature tour (public/js/home-tour.js) -->
<div class="home-tour-mount" id="home-tour-mount"></div>
<div class="home-tour-mount" id="home-tour-mount"></div>
@@ -5053,7 +5042,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
@@ -5053,7 +5042,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
<!-- Scripts -->
<!-- Scripts -->
<script src="/js/theme-engine.js?v=4"></script>
<script src="/js/theme-engine.js?v=4"></script>
<script src="/live-notify.js?v=3" defer></script>
<script src="/live-notify.js?v=3" defer></script>
-<script src="/js/app.js?v=137" defer></script>
+<script src="/js/app.js?v=138" defer></script>
<script src="/js/stream-player.js?v=21" defer></script>
<script src="/js/stream-player.js?v=21" defer></script>
<script src="/js/stream-pip.js?v=1" defer></script>
<script src="/js/stream-pip.js?v=1" defer></script>
<script src="/js/chat-shared.js?v=3" defer></script>
<script src="/js/chat-shared.js?v=3" defer></script>
@@ -5127,12 +5116,13 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
@@ -5127,12 +5116,13 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
<script src="/js/ai-viewers.js?v=2" defer></script>
<script src="/js/ai-viewers.js?v=2" defer></script>
<script src="/js/arena.js?v=18" defer></script>
<script src="/js/arena.js?v=18" defer></script>
<script src="/js/recap.js?v=1" defer></script>
<script src="/js/recap.js?v=1" defer></script>
+<script src="/js/context-cards.js?v=1" defer></script>
<script src="/js/powerchat.js?v=10" defer></script>
<script src="/js/powerchat.js?v=10" defer></script>
<script src="/js/pastes.js?v=15" defer></script>
<script src="/js/pastes.js?v=15" defer></script>
<script src="/js/draggable-fab.js?v=2" defer></script>
<script src="/js/draggable-fab.js?v=2" defer></script>
<script src="/js/messenger.js?v=2" defer></script>
<script src="/js/messenger.js?v=2" defer></script>
-<script src="/js/home-fx.js?v=2" defer></script>
-<script src="/js/home-tour.js?v=2" defer></script>
+<script src="/js/home-fx.js?v=3" defer></script>
+<script src="/js/home-tour.js?v=3" defer></script>
<script src="/js/broadcast-guide.js?v=1" defer></script>
<script src="/js/broadcast-guide.js?v=1" defer></script>
<!-- ── OpenVibe shared libs (notifications, account sync, themes) ── -->
<!-- ── OpenVibe shared libs (notifications, account sync, themes) ── -->
@@ -5938,6 +5938,14 @@ async function _fillOfflineExplore(username) {
@@ -5938,6 +5938,14 @@ async function _fillOfflineExplore(username) {
if (!built.length && (data.vod || data.clip)) {
if (!built.length && (data.vod || data.clip)) {
built.push({ key: 'all', label: 'Top content', vod: data.vod || null, clip: data.clip || null });
built.push({ key: 'all', label: 'Top content', vod: data.vod || null, clip: data.clip || null });
}
}
+ // The discover board (live now, hot clips, fresh reports, star of the day, streamers like
+ // this one) fills the rest of the offline screen — with or without this channel's own content.
+ const mountDiscover = () => {
+ if (host.closest('.ch-offline-overlay')) return; // over a custom image: keep it light
+ let d = document.getElementById('ch-discover');
+ if (!d) { d = document.createElement('div'); d.id = 'ch-discover'; host.insertAdjacentElement('afterend', d); }
+ if (typeof renderOfflineDiscover === 'function') renderOfflineDiscover(username, d);
+ };
if (!built.length) {
if (!built.length) {
host.innerHTML = '';
host.innerHTML = '';
// Nothing to show → don't leave an empty overlay (just an X) floating over the image.
// Nothing to show → don't leave an empty overlay (just an X) floating over the image.
@@ -5945,8 +5953,10 @@ async function _fillOfflineExplore(username) {
@@ -5945,8 +5953,10 @@ async function _fillOfflineExplore(username) {
const re = document.getElementById('ch-offline-reopen');
const re = document.getElementById('ch-offline-reopen');
if (ov) ov.style.display = 'none';
if (ov) ov.style.display = 'none';
if (re) re.classList.remove('show');
if (re) re.classList.remove('show');
+ mountDiscover();
return;
return;
}
}
+ mountDiscover();
_offlineRanges = built;
_offlineRanges = built;
_offlineIdx = 0;
_offlineIdx = 0;
@@ -6774,6 +6784,8 @@ async function loadVodPlayer(vodId, seekTo) {
@@ -6774,6 +6784,8 @@ async function loadVodPlayer(vodId, seekTo) {
} else {
} else {
vpStream.style.display = 'none';
vpStream.style.display = 'none';
}
}
+ // Activity strip: chat lines, chatters, viewers, mic moments, follows, report.
+ if (typeof renderVodContextStrip === 'function') renderVodContextStrip(v.id);
}
}
const filename = v.file_path.split('/').pop();
const filename = v.file_path.split('/').pop();
@@ -7470,6 +7482,8 @@ async function loadClipPlayer(clipId) {
@@ -7470,6 +7482,8 @@ async function loadClipPlayer(clipId) {
} else {
} else {
clpSource.style.display = 'none';
clpSource.style.display = 'none';
}
}
+ // Upgrade the plain line into the rich "from this stream" card when the VOD context loads.
+ if (cl.vod_id && typeof renderClipSourceCard === 'function') renderClipSourceCard(cl);
}
}
const video = document.getElementById('clp-video');
const video = document.getElementById('clp-video');
@@ -7489,7 +7503,9 @@ async function loadClipPlayer(clipId) {
@@ -7489,7 +7503,9 @@ async function loadClipPlayer(clipId) {
note.style.cssText = 'display:flex;flex-direction:column;align-items:center;justify-content:center;height:300px;gap:12px;color:var(--text-muted,#999)';
note.style.cssText = 'display:flex;flex-direction:column;align-items:center;justify-content:center;height:300px;gap:12px;color:var(--text-muted,#999)';
note.innerHTML = failed ? `
note.innerHTML = failed ? `
<i class="fa-solid fa-triangle-exclamation" style="font-size:2.5rem;color:var(--danger,#e74c3c)"></i>
<i class="fa-solid fa-triangle-exclamation" style="font-size:2.5rem;color:var(--danger,#e74c3c)"></i>
- <p style="font-size:1.05rem;font-weight:600">This clip failed to render</p>
+ <p style="font-size:1.05rem;font-weight:600">${cl.will_retry ? 'This clip is being re-cut' : 'This clip failed to render'}</p>
+ ${cl.will_retry ? `<p class="muted" style="font-size:0.82rem;margin:-4px 0 8px"><i class="fa-solid fa-rotate fa-spin"></i> The server retries automatically${cl.cut_next_at ? ' · next try ' + esc(timeAgo(cl.cut_next_at)) : ''}${cl.cut_attempts ? ' · attempt ' + cl.cut_attempts : ''}</p>` : ''}
+ ${cl.cut_error ? `<p class="muted" style="font-size:0.74rem;max-width:520px;margin:0 auto 8px;opacity:0.75">${esc(String(cl.cut_error).split(' | ')[0].slice(0, 160))}</p>` : ''}
<p class="muted" style="font-size:0.85rem">The server couldn't cut it from the recording.</p>
<p class="muted" style="font-size:0.85rem">The server couldn't cut it from the recording.</p>
<button class="btn btn-sm btn-outline" onclick="recutClip(${cl.id})"><i class="fa-solid fa-rotate-right"></i> Try again</button>` : `
<button class="btn btn-sm btn-outline" onclick="recutClip(${cl.id})"><i class="fa-solid fa-rotate-right"></i> Try again</button>` : `
<i class="fa-solid fa-scissors fa-bounce" style="font-size:2.5rem;color:var(--accent)"></i>
<i class="fa-solid fa-scissors fa-bounce" style="font-size:2.5rem;color:var(--accent)"></i>
new file mode 100644
new file mode 100644
@@ -0,0 +1,169 @@
@@ -0,0 +1,169 @@
+/**
+ * context-cards.js — the connective tissue between clips, VODs, streams and channels.
+ *
+ * renderClipSourceCard(clip) clip page: the stream this clip came from as a rich card —
+ * VOD thumbnail with the clip's window marked on a timeline,
+ * timestamp badge, the stream's activity numbers, the
+ * after-show report, and the other clips cut from it.
+ * renderVodContextStrip(vodId) VOD page: activity strip (chat lines, chatters, peak/avg
+ * viewers, mic moments, sound commands, follows, tips,
+ * loudest chatters) + after-show report link.
+ * renderOfflineDiscover(username) offline channel screen: a discover board — live now,
+ * hot clips, fresh reports, the star of the day, streamers
+ * like this one — so an empty channel still sends people
+ * somewhere good.
+ *
+ * All three read the same two endpoints: GET /api/vods/:id/context and GET /api/home/discover.
+ */
+(function () {
+ 'use strict';
+ const n = (v) => Number(v || 0).toLocaleString();
+ const dur = (sec) => { sec = Math.max(0, Math.round(sec || 0)); const h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60), s = sec % 60; return h ? `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}` : `${m}:${String(s).padStart(2, '0')}`; };
+ const durWords = (sec) => { sec = Math.max(0, Math.round(sec || 0)); const h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60); return h ? `${h}h ${m}m` : `${m}m`; };
+ const go = (href) => `href="${esc(href)}" onclick="return handleLinkClick(event, '${esc(href)}')"`;
+ const ago = (ts) => { try { return timeAgo(ts); } catch { return ''; } };
+ const GRADE = { S: '#fbbf24', A: '#a78bfa', B: '#38bdf8', C: '#94a3b8' };
+ const av = (u, size) => `<span class="cc-avatar" style="width:${size}px;height:${size}px;${u && u.profile_color ? `background:${esc(u.profile_color)}` : ''}">${u && u.avatar_url ? `<img src="${esc(u.avatar_url)}" alt="" loading="lazy">` : esc(String((u && (u.display_name || u.username)) || '?').charAt(0).toUpperCase())}</span>`;
+ const statPills = (st) => {
+ if (!st) return '';
+ const items = [
+ ['fa-eye', n(st.peak_viewers), 'peak'],
+ st.avg_viewers != null ? ['fa-chart-line', n(st.avg_viewers), 'avg'] : null,
+ ['fa-comments', n(st.chat_messages), 'chat lines'],
+ ['fa-users', n(st.chatters), 'chatters'],
+ st.mic_moments ? ['fa-microphone-lines', n(st.mic_moments), 'mic moments'] : null,
+ st.sound_commands ? ['fa-volume-high', n(st.sound_commands), 'sounds'] : null,
+ st.follows_gained ? ['fa-heart', `+${n(st.follows_gained)}`, 'follows'] : null,
+ st.tips && st.tips.n ? ['fa-coins', n(st.tips.t), `in ${st.tips.n} tip${st.tips.n === 1 ? '' : 's'}`] : null,
+ ].filter(Boolean);
+ return `<div class="cc-stats">${items.map(([i, v, l]) => `<span class="cc-stat"><i class="fa-solid ${i}"></i><b>${v}</b><small>${esc(l)}</small></span>`).join('')}</div>`;
+ };
+ const clipTile = (c, opts = {}) => `<a class="cc-clip" ${go(`/clip/${c.id}`)} title="${esc(c.title || 'Clip')}">
+ ${c.thumbnail_url ? `<img src="${esc(c.thumbnail_url)}" alt="" loading="lazy">` : '<span class="cc-clip-ph"><i class="fa-solid fa-scissors"></i></span>'}
+ <span class="cc-clip-dur">${dur(c.duration_seconds)}</span>
+ ${opts.at && c.start_time != null ? `<span class="cc-clip-at"><i class="fa-regular fa-clock"></i> ${dur(c.start_time)}</span>` : ''}
+ <span class="cc-clip-title">${esc(c.title || 'Clip')}</span>
+ <span class="cc-clip-meta">${c.view_count ? `<i class="fa-solid fa-eye"></i> ${n(c.view_count)} · ` : ''}${c.display_name || c.by ? esc(c.display_name || c.by) : ''}${c.created_at ? ` · ${esc(ago(c.created_at))}` : ''}</span>
+ </a>`;
+
+ async function fetchContext(vodId) {
+ if (!vodId) return null;
+ try { return await api(`/vods/${encodeURIComponent(vodId)}/context`); } catch { return null; }
+ }
+
+ // ── Clip page: "From this stream" card ─────────────────────
+ window.renderClipSourceCard = async function (clip) {
+ const host = document.getElementById('clp-stream-source');
+ if (!host || !clip) return;
+ const ctx = await fetchContext(clip.vod_id);
+ if (!ctx || !ctx.vod) return; // keep the plain text fallback
+ const total = Math.max(1, ctx.vod.duration_seconds || 0);
+ const start = Math.max(0, Number(clip.start_time) || 0), end = Math.min(total, Math.max(start + 1, Number(clip.end_time) || start + (clip.duration_seconds || 0)));
+ const left = Math.min(100, start / total * 100), width = Math.max(0.8, (end - start) / total * 100);
+ const canWatch = clip.vod_available !== false && ctx.vod.visibility !== 'private';
+ const vodHref = `/vod/${ctx.vod.id}?t=${Math.floor(start)}`;
+ const s = ctx.stream || {}, st = ctx.stats;
+ const others = (ctx.clips || []).filter(c => c.id !== clip.id).slice(0, 6);
+ const rc = ctx.recap;
+ host.classList.add('cc-host');
+ host.innerHTML = `
+ <div class="cc-card">
+ <div class="cc-kicker"><i class="fa-solid fa-tower-broadcast"></i> From this stream</div>
+ <div class="cc-main">
+ <a class="cc-thumb" ${canWatch ? go(vodHref) : 'href="#" onclick="return false"'} title="${canWatch ? 'Watch this moment in the full VOD' : 'VOD unavailable'}">
+ ${ctx.vod.thumbnail_url ? `<img src="${esc(ctx.vod.thumbnail_url)}" alt="" loading="lazy">` : '<span class="cc-clip-ph"><i class="fa-solid fa-video"></i></span>'}
+ <span class="cc-thumb-dur">${dur(total)}</span>
+ <span class="cc-thumb-play"><i class="fa-solid fa-play"></i></span>
+ <span class="cc-timeline" aria-hidden="true"><i style="left:${left.toFixed(2)}%;width:${width.toFixed(2)}%"></i></span>
+ </a>
+ <div class="cc-body">
+ <div class="cc-title-row">
+ <a class="cc-title" ${canWatch ? go(`/vod/${ctx.vod.id}`) : 'href="#" onclick="return false"'}>${esc(s.title || ctx.vod.title || 'Stream')}</a>
+ ${rc ? `<a class="cc-grade" ${go(`/recap/${rc.stream_id}`)} title="${esc(rc.headline || 'After-show report')}" style="--g:${GRADE[rc.grade] || GRADE.B}">${esc(rc.grade)}</a>` : ''}
+ </div>
+ <div class="cc-sub">
+ ${ctx.streamer ? `${av(ctx.streamer, 20)} <a class="cc-name" ${go(`/@${ctx.streamer.username}`)}>${esc(ctx.streamer.display_name)}</a>` : ''}
+ ${s.started_at ? `<span>· ${esc(new Date(String(s.started_at).replace(' ', 'T') + 'Z').toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }))}</span>` : ''}
+ ${s.category ? `<span>· ${esc(s.category)}</span>` : ''}
+ ${ctx.vod.view_count ? `<span>· <i class="fa-solid fa-eye"></i> ${n(ctx.vod.view_count)} VOD views</span>` : ''}
+ </div>
+ <div class="cc-when">
+ <span class="cc-badge"><i class="fa-regular fa-clock"></i> ${dur(start)} → ${dur(end)}</span>
+ <span class="cc-when-text">${Math.round(start / total * 100)}% into a ${durWords(total)} stream</span>
+ </div>
+ ${statPills(st)}
+ ${rc && rc.headline ? `<a class="cc-recap" ${go(`/recap/${rc.stream_id}`)}><i class="fa-solid fa-clipboard-list"></i> <b>After-show report:</b> ${esc(rc.headline)}</a>` : ''}
+ <div class="cc-actions">
+ ${canWatch ? `<a class="btn btn-primary btn-sm" ${go(vodHref)}><i class="fa-solid fa-forward"></i> Watch this moment in the VOD</a>` : '<span class="muted" style="font-size:0.8rem"><i class="fa-solid fa-eye-slash"></i> The full VOD is no longer available</span>'}
+ ${ctx.streamer ? `<a class="btn btn-outline btn-sm" ${go(`/@${ctx.streamer.username}`)}><i class="fa-solid fa-user"></i> Channel</a>` : ''}
+ </div>
+ </div>
+ </div>
+ ${others.length ? `<div class="cc-more"><div class="cc-more-head"><i class="fa-solid fa-scissors"></i> More clips from this stream <span class="muted">(${others.length})</span></div><div class="cc-clips">${others.map(c => clipTile(c, { at: true })).join('')}</div></div>` : ''}
+ </div>`;
+ host.style.display = '';
+ };
+
+ // ── VOD page: activity strip ────────────────────────────────
+ window.renderVodContextStrip = async function (vodId) {
+ const anchor = document.getElementById('vp-stream-source');
+ if (!anchor) return;
+ let host = document.getElementById('vp-context');
+ if (!host) { host = document.createElement('div'); host.id = 'vp-context'; host.className = 'cc-host'; anchor.insertAdjacentElement('afterend', host); }
+ host.innerHTML = '';
+ const ctx = await fetchContext(vodId);
+ if (!ctx || !ctx.stats) { host.style.display = 'none'; return; }
+ const st = ctx.stats, rc = ctx.recap;
+ const top = (st.top_chatters || []).slice(0, 3);
+ host.innerHTML = `
+ <div class="cc-card cc-strip">
+ <div class="cc-kicker"><i class="fa-solid fa-bolt"></i> The room during this stream ${ctx.stream && ctx.stream.is_live ? '<span class="cc-live">LIVE · numbers so far</span>' : ''}</div>
+ ${statPills(st)}
+ <div class="cc-strip-foot">
+ ${top.length ? `<span class="cc-top"><i class="fa-solid fa-ranking-star"></i> Loudest: ${top.map((t, i) => `${['🥇', '🥈', '🥉'][i]} <a ${go(`/@${t.username}`)}>${esc(t.display_name || t.username)}</a> <small>${n(t.n)}</small>`).join(' ')}</span>` : ''}
+ ${rc ? `<a class="cc-recap" ${go(`/recap/${rc.stream_id}`)}><span class="cc-grade" style="--g:${GRADE[rc.grade] || GRADE.B}">${esc(rc.grade)}</span> <b>After-show report:</b> ${esc(rc.headline || '')}</a>` : ''}
+ </div>
+ </div>`;
+ host.style.display = '';
+ };
+
+ // ── Offline channel: discover board ─────────────────────────
+ window.renderOfflineDiscover = async function (username, mountEl) {
+ const host = mountEl || document.getElementById('ch-discover');
+ if (!host) return;
+ let d = null;
+ try { d = await api(`/home/discover${username ? `?channel=${encodeURIComponent(username)}` : ''}`); } catch { d = null; }
+ if (!d) { host.innerHTML = ''; return; }
+ const name = d.channel ? d.channel.display_name : 'this channel';
+ const sections = [];
+ if ((d.live || []).length) {
+ sections.push(`<section class="dsc-sec"><h4><i class="fa-solid fa-circle live-dot"></i> Live right now</h4><div class="dsc-live">${d.live.map(s => `
+ <a class="dsc-live-card" ${go(`/@${s.username}`)}>
+ <span class="dsc-live-thumb">${s.thumbnail_url ? `<img src="${esc(s.thumbnail_url)}" alt="" loading="lazy">` : '<span class="cc-clip-ph"><i class="fa-solid fa-tower-broadcast"></i></span>'}<span class="dsc-live-pill">LIVE</span><span class="dsc-live-viewers"><i class="fa-solid fa-eye"></i> ${n(s.viewer_count)}</span></span>
+ <span class="dsc-live-body">${av(s, 26)}<span><b>${esc(s.display_name || s.username)}</b><small>${esc(s.title || '')}</small></span></span>
+ </a>`).join('')}</div></section>`);
+ }
+ if ((d.clips || []).length) {
+ sections.push(`<section class="dsc-sec"><h4><i class="fa-solid fa-fire"></i> ${d.clips[0].fresh ? 'Hot clips this week' : 'Most-watched clips'} <span class="muted">across OpenVibe</span></h4><div class="cc-clips dsc-clips">${d.clips.map(c => clipTile(c)).join('')}</div></section>`);
+ }
+ if (d.star || (d.recaps || []).length) {
+ const star = d.star ? `<a class="dsc-star" ${go(`/@${d.star.username}`)}>
+ <span class="dsc-star-badge">⭐ Star of the day</span>
+ ${av(d.star, 56)}
+ <span class="dsc-star-body"><b>${esc(d.star.display_name)}${d.star.live ? ' <span class="dsc-live-pill">LIVE</span>' : ''}</b>${d.star.headline ? `<span class="dsc-star-head">${esc(d.star.headline)}</span>` : ''}${d.star.reason ? `<small>${esc(d.star.reason)}</small>` : ''}</span>
+ </a>` : '';
+ const recaps = (d.recaps || []).length ? `<div class="dsc-recaps"><div class="dsc-recaps-head"><i class="fa-solid fa-clipboard-list"></i> Fresh after-show reports</div>${d.recaps.slice(0, 5).map(r => `
+ <a class="dsc-recap" ${go(`/recap/${r.stream_id}`)}>
+ <span class="cc-grade" style="--g:${GRADE[r.grade] || GRADE.B}">${esc(r.grade)}</span>
+ <span class="dsc-recap-body"><b>${esc(r.headline || r.title)}</b><small>${esc(r.display_name)} · ${esc(ago(r.ended_at))} · ${durWords(r.duration_seconds)} · peak ${n(r.peak_viewers)}${r.chat_messages ? ` · ${n(r.chat_messages)} chat lines` : ''}</small></span>
+ </a>`).join('')}</div>` : '';
+ sections.push(`<section class="dsc-sec dsc-two">${star}${recaps}</section>`);
+ }
+ if ((d.similar || []).length) {
+ sections.push(`<section class="dsc-sec"><h4><i class="fa-solid fa-people-group"></i> Streamers to check out</h4><div class="dsc-people">${d.similar.map(p => `
+ <a class="dsc-person" ${go(`/@${p.username}`)}>${av(p, 44)}<b>${esc(p.display_name || p.username)}</b><small>${p.live ? '<span class="dsc-live-dot"></span> live now' : (p.last_live_at ? `live ${esc(ago(p.last_live_at))}` : '')}${p.followers ? ` · ${n(p.followers)} follower${p.followers === 1 ? '' : 's'}` : ''}</small>${p.same_category && p.category ? `<em>${esc(p.category)}</em>` : ''}</a>`).join('')}</div></section>`);
+ }
+ if (!sections.length) { host.innerHTML = ''; return; }
+ host.innerHTML = `<div class="dsc"><div class="dsc-head"><span class="dsc-title"><i class="fa-solid fa-compass"></i> While ${esc(name)} is away</span><span class="dsc-sub">the rest of OpenVibe is right here — follow ${esc(name)} to get pinged when they're back</span></div>${sections.join('')}</div>`;
+ };
+})();
@@ -4,8 +4,7 @@
@@ -4,8 +4,7 @@
* Purely decorative and fully additive: nothing here is required for the page to work.
* Purely decorative and fully additive: nothing here is required for the page to work.
* - hero sparks: a lightweight canvas of drifting embers / orbs behind the hero
* - hero sparks: a lightweight canvas of drifting embers / orbs behind the hero
* - cursor spotlight: a soft radial glow that follows the pointer over the hero
* - cursor spotlight: a soft radial glow that follows the pointer over the hero
- * - magnetic buttons: the hero CTA buttons lean toward the cursor
- * - scroll reveal: section headers, cards and grids slide/fade in as they enter the viewport
+ * - scroll reveal: section headers, cards and grids slide/fade in as they enter the viewport
* - count-up: hero stat numbers tick up from 0 when first painted
* - count-up: hero stat numbers tick up from 0 when first painted
* - card tilt: stream cards tilt slightly toward the pointer
* - card tilt: stream cards tilt slightly toward the pointer
* Everything is disabled under prefers-reduced-motion, paused when the tab is hidden, and
* Everything is disabled under prefers-reduced-motion, paused when the tab is hidden, and
@@ -77,16 +76,9 @@
@@ -77,16 +76,9 @@
cancelAnimationFrame(raf);
cancelAnimationFrame(raf);
raf = requestAnimationFrame(() => {
raf = requestAnimationFrame(() => {
spot.style.setProperty('--x', `${x}px`); spot.style.setProperty('--y', `${y}px`); spot.style.opacity = '1';
spot.style.setProperty('--x', `${x}px`); spot.style.setProperty('--y', `${y}px`); spot.style.opacity = '1';
- hero.querySelectorAll('.hero-cta .btn').forEach(btn => {
- const b = btn.getBoundingClientRect();
- const dx = e.clientX - (b.left + b.width / 2), dy = e.clientY - (b.top + b.height / 2);
- const dist = Math.hypot(dx, dy);
- if (dist < 140) { const k = (1 - dist / 140) * 10; btn.style.transform = `translate(${(dx / dist) * k}px, ${(dy / dist) * k}px)`; }
- else btn.style.transform = '';
- });
});
});
});
});
- hero.addEventListener('pointerleave', () => { spot.style.opacity = '0'; hero.querySelectorAll('.hero-cta .btn').forEach(btn => { btn.style.transform = ''; }); });
+ hero.addEventListener('pointerleave', () => { spot.style.opacity = '0'; });
}
}
// ── Scroll reveal ──────────────────────────────────────────
// ── Scroll reveal ──────────────────────────────────────────
@@ -1,54 +1,121 @@
@@ -1,54 +1,121 @@
/**
/**
- * home-tour.js — "One stream. Everywhere. Every toy." — the animated feature tour on the home page.
+ * home-tour.js — "One stream. Everywhere." — the restream pipeline on the home page.
*
*
- * Static content, no API: the restream pipeline (your cam or robot → OpenVibe.Live → Twitch,
- * YouTube, Kick, RobotStreamer, any RTMP) with packets flowing along it, and the feature chips
- * that only exist here. Mounted into #home-tour-mount under the hero CTA. Transform-only
- * animations, so it stays smooth in mobile lite mode.
+ * Three columns: what you send (cam / robot / OBS) → the OpenVibe.Live core → where it goes
+ * (Twitch, YouTube, Kick, RobotStreamer, any RTMP). The wires are an SVG drawn between the real
+ * DOM nodes after layout (so it is exact at every width, and re-flows to a vertical stack on
+ * phones), and packets travel along them with SVG <animateMotion> — brand-coloured per
+ * destination, staggered, transform-only. Hovering a destination lights up its wire.
+ * Static content, no API. Mounted into #home-tour-mount under the hero buttons.
*/
*/
(function () {
(function () {
'use strict';
'use strict';
+ const SRC = [
+ { id: 'cam', icon: 'fa-solid fa-video', label: 'Your cam', sub: 'browser · phone' },
+ { 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' },
+ ];
+ const DST = [
+ { id: 'twitch', icon: 'fa-brands fa-twitch', label: 'Twitch', color: '#a970ff' },
+ { id: 'youtube', icon: 'fa-brands fa-youtube', label: 'YouTube', color: '#ff4b4b' },
+ { id: 'kick', icon: 'fa-solid fa-bolt', label: 'Kick', color: '#53fc18' },
+ { id: 'rs', icon: 'fa-solid fa-robot', label: 'RobotStreamer', color: '#7dd3fc' },
+ { id: 'rtmp', icon: 'fa-solid fa-tower-broadcast', label: 'Any RTMP', color: '#e5e7eb' },
+ ];
+ const FEATURES = [
+ ['fa-gauge-high', 'Sub-second WebRTC'], ['fa-face-grin-squint-tears', '7TV / BTTV / FFZ emotes'], ['fa-volume-high', 'Sound commands'],
+ ['fa-gamepad', 'Robot & hardware controls'], ['fa-satellite-dish', 'Free restream, all at once'], ['fa-scissors', 'VODs, clips, AI moments'],
+ ['fa-comments', 'One chat from every platform'], ['fa-language', 'Auto-translated chat'], ['fa-microphone-lines', 'The Arena: mic-judged beefs'],
+ ['fa-clipboard-list', 'After-show reports'], ['fa-brands fa-github', '100% open source'],
+ ];
+ const go = (href) => `href="${href}" onclick="return handleLinkClick(event, '${href}')"`;
+
function mount() {
function mount() {
const el = document.getElementById('home-tour-mount');
const el = document.getElementById('home-tour-mount');
if (!el || el.dataset.mounted) return;
if (!el || el.dataset.mounted) return;
el.dataset.mounted = '1';
el.dataset.mounted = '1';
- const go = (href) => `href="${href}" onclick="return handleLinkClick(event, '${href}')"`;
el.innerHTML = `
el.innerHTML = `
- <div class="tour">
+ <section class="tour" aria-label="Stream from OpenVibe.Live and restream everywhere">
<div class="tour-head">
<div class="tour-head">
- <h2><i class="fa-solid fa-satellite-dish"></i> One stream. Everywhere. Every toy.</h2>
- <p>Go live here once and mirror it to every platform at the same time — while your chat, emotes, sound commands, robot controls, VODs and clips all live in one place.</p>
+ <div class="tour-kicker"><i class="fa-solid fa-satellite-dish"></i> Restream</div>
+ <h2>One stream. Everywhere.</h2>
+ <p>Go live here once and mirror it to every platform at the same time — your chat, emotes, sound commands, robot controls, VODs and clips stay in one place.</p>
</div>
</div>
- <div class="tour-flow" aria-label="Stream from OpenVibe.Live and restream everywhere">
- <div class="tour-src"><span class="tour-node"><i class="fa-solid fa-video"></i> your cam</span><span class="tour-node"><i class="fa-solid fa-robot"></i> your robot</span><span class="tour-node"><i class="fa-solid fa-desktop"></i> OBS / RTMP</span></div>
- <div class="tour-wire" aria-hidden="true"><span class="tour-packet"></span><span class="tour-packet"></span></div>
- <div class="tour-hub"><b><i class="fa-solid fa-circle-nodes"></i> OpenVibe.Live</b><small>WebRTC <1s latency · emotes · sound commands · robot controls · VODs · clips · AI moments</small></div>
- <div class="tour-wire tour-fan" aria-hidden="true"><span class="tour-packet"></span><span class="tour-packet"></span><span class="tour-packet"></span></div>
- <div class="tour-out">
- <span class="tour-node tour-twitch"><i class="fa-brands fa-twitch"></i> Twitch</span>
- <span class="tour-node tour-yt"><i class="fa-brands fa-youtube"></i> YouTube</span>
- <span class="tour-node tour-kick"><i class="fa-solid fa-bolt"></i> Kick</span>
- <span class="tour-node tour-rs"><i class="fa-solid fa-robot"></i> RobotStreamer</span>
- <span class="tour-node"><i class="fa-solid fa-tower-broadcast"></i> any RTMP</span>
+ <div class="tour-stage" id="tour-stage">
+ <svg class="tour-wires" id="tour-wires" aria-hidden="true"></svg>
+ <div class="tour-col tour-col--src">
+ <div class="tour-col-label">You send</div>
+ ${SRC.map(s => `<div class="tour-node tour-node--src" data-node="${s.id}"><span class="tour-node-ico"><i class="${s.icon}"></i></span><span class="tour-node-text"><b>${s.label}</b><small>${s.sub}</small></span></div>`).join('')}
+ </div>
+ <div class="tour-core-wrap">
+ <div class="tour-core" data-node="core">
+ <span class="tour-core-ring"></span><span class="tour-core-ring tour-core-ring--2"></span>
+ <span class="tour-core-logo"><i class="fa-solid fa-circle-nodes"></i></span>
+ <b>OpenVibe.Live</b>
+ <small><1s latency · chat · emotes · sounds · controls · VOD · clips</small>
+ </div>
+ </div>
+ <div class="tour-col tour-col--dst">
+ <div class="tour-col-label">It lands on</div>
+ ${DST.map(d => `<div class="tour-node tour-node--dst" data-node="${d.id}" style="--c:${d.color}"><span class="tour-node-ico"><i class="${d.icon}"></i></span><span class="tour-node-text"><b>${d.label}</b></span><span class="tour-node-dot"></span></div>`).join('')}
</div>
</div>
</div>
</div>
- <div class="tour-features">
- <span class="tour-feat" style="--i:0"><i class="fa-solid fa-gauge-high"></i> Sub-second WebRTC latency</span>
- <span class="tour-feat" style="--i:1"><i class="fa-solid fa-face-grin-squint-tears"></i> 7TV / BTTV / FFZ + custom emotes</span>
- <span class="tour-feat" style="--i:2"><i class="fa-solid fa-volume-high"></i> Sound commands & soundboard</span>
- <span class="tour-feat" style="--i:3"><i class="fa-solid fa-gamepad"></i> Robot & hardware controls</span>
- <span class="tour-feat" style="--i:4"><i class="fa-solid fa-satellite-dish"></i> Free restream, all at once</span>
- <span class="tour-feat" style="--i:5"><i class="fa-solid fa-scissors"></i> VODs, clips, AI moments</span>
- <span class="tour-feat" style="--i:6"><i class="fa-solid fa-comments"></i> One chat from every platform</span>
- <span class="tour-feat" style="--i:7"><i class="fa-solid fa-language"></i> Auto-translated chat</span>
- <span class="tour-feat" style="--i:8"><i class="fa-solid fa-microphone-lines"></i> The Arena: mic-judged beefs</span>
- <span class="tour-feat" style="--i:9"><i class="fa-brands fa-github"></i> 100% open source</span>
- </div>
+ <div class="tour-features">${FEATURES.map(([i, t], k) => `<span class="tour-feat" style="--i:${k}"><i class="${i.startsWith('fa-brands') ? i : 'fa-solid ' + i}"></i> ${t}</span>`).join('')}</div>
<div class="tour-actions">
<div class="tour-actions">
<a class="btn btn-primary btn-lg" ${go('/broadcast')}><i class="fa-solid fa-tower-broadcast"></i> Go live</a>
<a class="btn btn-primary btn-lg" ${go('/broadcast')}><i class="fa-solid fa-tower-broadcast"></i> Go live</a>
<a class="btn btn-outline btn-lg" href="/broadcast?setup=restream" onclick="event.preventDefault(); startRestreamGuide();"><i class="fa-solid fa-satellite-dish"></i> Set up restreams (guided)</a>
<a class="btn btn-outline btn-lg" href="/broadcast?setup=restream" onclick="event.preventDefault(); startRestreamGuide();"><i class="fa-solid fa-satellite-dish"></i> Set up restreams (guided)</a>
</div>
</div>
- </div>`;
+ </section>`;
+ wire();
+ let raf = 0;
+ const redraw = () => { cancelAnimationFrame(raf); raf = requestAnimationFrame(wire); };
+ if ('ResizeObserver' in window) new ResizeObserver(redraw).observe(el.querySelector('#tour-stage'));
+ window.addEventListener('resize', redraw);
+ document.fonts && document.fonts.ready && document.fonts.ready.then(redraw);
+ el.querySelectorAll('.tour-node--dst').forEach(nd => {
+ nd.addEventListener('pointerenter', () => el.querySelector('#tour-wires').classList.add(`hot-${nd.dataset.node}`));
+ nd.addEventListener('pointerleave', () => el.querySelector('#tour-wires').classList.remove(`hot-${nd.dataset.node}`));
+ });
+ }
+
+ /** Draw the wires between the real nodes and start the packets. */
+ function wire() {
+ const stage = document.getElementById('tour-stage'), svg = document.getElementById('tour-wires');
+ if (!stage || !svg) return;
+ const R = stage.getBoundingClientRect();
+ svg.setAttribute('viewBox', `0 0 ${R.width} ${R.height}`);
+ svg.setAttribute('width', R.width); svg.setAttribute('height', R.height);
+ const vertical = getComputedStyle(stage).getPropertyValue('--tour-vertical').trim() === '1';
+ const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches || document.documentElement.classList.contains('rs-lite');
+ const core = stage.querySelector('[data-node="core"]');
+ if (!core) return;
+ const c = rel(core.getBoundingClientRect(), R);
+ const anchorOf = (node, side) => {
+ const b = rel(node.getBoundingClientRect(), R);
+ if (vertical) return { x: b.x + b.w / 2, y: side === 'out' ? b.y + b.h : b.y };
+ return { x: side === 'out' ? b.x + b.w : b.x, y: b.y + b.h / 2 };
+ };
+ const coreIn = vertical ? { x: c.x + c.w / 2, y: c.y } : { x: c.x, y: c.y + c.h / 2 };
+ const coreOut = vertical ? { x: c.x + c.w / 2, y: c.y + c.h } : { x: c.x + c.w, y: c.y + c.h / 2 };
+ const curve = (a, b) => vertical
+ ? `M${a.x},${a.y} C${a.x},${(a.y + b.y) / 2} ${b.x},${(a.y + b.y) / 2} ${b.x},${b.y}`
+ : `M${a.x},${a.y} C${(a.x + b.x) / 2},${a.y} ${(a.x + b.x) / 2},${b.y} ${b.x},${b.y}`;
+ let defs = '', paths = '', packets = '';
+ const srcNodes = [...stage.querySelectorAll('.tour-node--src')], dstNodes = [...stage.querySelectorAll('.tour-node--dst')];
+ srcNodes.forEach((nd, i) => {
+ const d = curve(anchorOf(nd, 'out'), coreIn);
+ paths += `<path id="tw-in-${i}" class="tour-wire tour-wire--in" d="${d}"/>`;
+ if (!reduce) packets += `<circle class="tour-pk tour-pk--in" r="3.5"><animateMotion dur="${(1.6 + i * 0.2).toFixed(2)}s" begin="${(i * 0.55).toFixed(2)}s" repeatCount="indefinite"><mpath href="#tw-in-${i}"/></animateMotion></circle>`;
+ });
+ dstNodes.forEach((nd, i) => {
+ const d = curve(coreOut, anchorOf(nd, 'in'));
+ const col = nd.style.getPropertyValue('--c') || '#fff';
+ paths += `<path id="tw-out-${i}" class="tour-wire tour-wire--out tour-wire--${nd.dataset.node}" style="--c:${col}" d="${d}"/>`;
+ if (!reduce) for (let k = 0; k < 2; k++) packets += `<circle class="tour-pk" style="--c:${col}" r="3.5"><animateMotion dur="${(1.7 + i * 0.12).toFixed(2)}s" begin="${(i * 0.3 + k * 0.9).toFixed(2)}s" repeatCount="indefinite"><mpath href="#tw-out-${i}"/></animateMotion></circle>`;
+ });
+ svg.innerHTML = defs + paths + packets;
}
}
+ function rel(b, R) { return { x: b.left - R.left, y: b.top - R.top, w: b.width, h: b.height }; }
+
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', mount); else mount();
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', mount); else mount();
})();
})();
@@ -344,6 +344,59 @@ router.get('/digest', optionalAuth, (req, res) => {
@@ -344,6 +344,59 @@ router.get('/digest', optionalAuth, (req, res) => {
}
}
});
});
+// ── Discover: what to watch instead when a channel is offline (or anywhere) ────────────
+// ?channel=<username> excludes that streamer from the picks and finds "streamers like them".
+// Live now, this week's hottest clips (site-wide), the latest after-show reports, the star of
+// the day, and streamers in the same category who were live recently. Cached ~60s per channel.
+const _discoverCache = new Map();
+router.get('/discover', optionalAuth, async (req, res) => {
+ try {
+ const uname = String(req.query.channel || '').trim().toLowerCase();
+ const hit = _discoverCache.get(uname);
+ if (hit && Date.now() - hit.at < 60000) { res.set('Cache-Control', 'public, max-age=30'); return res.json(hit.data); }
+ const me = uname ? db.getUserByUsername(uname) : null;
+ const exclude = me ? me.id : null;
+ const media = require('../media-client');
+ const strip = (s) => { if (!s) return s; const { stream_key, managed_stream_key, ...rest } = s; return rest; };
+ const live = (db.getLiveStreams() || []).filter(s => s.user_id !== exclude).slice(0, 6).map(strip);
+ let clips = [];
+ try {
+ const co = await media.listClips({ order: 'views', limit: 40 });
+ const all = (co?.clips || (Array.isArray(co) ? co : [])).filter(c => (c.status === 'ready' || !c.status) && (c.visibility == null || c.visibility === 'public') && (c.channel_user_id || c.user_id) !== exclude);
+ const weekAgo = Date.now() - 7 * 86400000;
+ const fresh = all.filter(c => Date.parse(String(c.created_at || '').replace(' ', 'T') + 'Z') >= weekAgo);
+ clips = (fresh.length >= 4 ? fresh : all).slice(0, 8).map(c => ({ id: c.id, title: c.title, thumbnail_url: c.thumbnail_url ? media.publicUrl(c.thumbnail_url) : null, duration_seconds: c.duration_seconds || c.duration || 0, view_count: Number(c.view_count) || 0, username: c.streamer_username || c.channel_username || c.username || null, display_name: c.streamer_display_name || c.channel_display_name || c.display_name || null, created_at: c.created_at, fresh: fresh.length >= 4 }));
+ } catch { clips = []; }
+ let recaps = [];
+ try { recaps = require('../recap/recap').listRecentRecaps(6, exclude); } catch { recaps = []; }
+ let star = null;
+ try {
+ const pick = require('./star-job').loadPick();
+ const su = pick && pick.username ? db.getUserByUsername(pick.username) : null;
+ if (su && su.id !== exclude) star = { username: su.username, display_name: su.display_name || su.username, avatar_url: su.avatar_url || null, profile_color: su.profile_color || null, headline: pick.headline || null, reason: pick.reason || null, live: live.some(l => l.user_id === su.id) };
+ } catch { star = null; }
+ let similar = [];
+ try {
+ const cat = me ? (db.getChannelByUserId(me.id) || {}) : {};
+ const category = cat.ai_category || cat.category || null;
+ similar = (db.all(`SELECT u.id, u.username, u.display_name, u.avatar_url, u.profile_color, MAX(s.started_at) AS last_live_at, COUNT(s.id) AS sessions,
+ (SELECT COUNT(*) FROM follows f WHERE f.streamer_id = u.id) AS followers,
+ (SELECT COALESCE(ch.ai_category, ch.category) FROM channels ch WHERE ch.user_id = u.id) AS category
+ FROM streams s JOIN users u ON u.id = s.user_id
+ WHERE s.started_at >= datetime('now', '-30 days') AND COALESCE(u.is_banned, 0) = 0 AND u.id != COALESCE(?, -1)
+ GROUP BY u.id ORDER BY (category = ?) DESC, followers DESC, last_live_at DESC LIMIT 8`, [exclude, category]) || [])
+ .map(r => ({ ...r, live: live.some(l => l.user_id === r.id), same_category: !!(category && r.category === category) }));
+ } catch { similar = []; }
+ const data = { channel: me ? { username: me.username, display_name: me.display_name || me.username } : null, live, clips, recaps, star, similar };
+ _discoverCache.set(uname, { at: Date.now(), data });
+ res.set('Cache-Control', 'public, max-age=30');
+ res.json(data);
+ } catch (err) {
+ console.error('[Home] discover error:', err.message);
+ res.status(500).json({ error: 'Failed to load discover' });
+ }
+});
+
// Admin: pick a new Star of OpenVibe right now (the job otherwise rotates daily).
// Admin: pick a new Star of OpenVibe right now (the job otherwise rotates daily).
router.post('/star/rotate', requireAuth, async (req, res) => {
router.post('/star/rotate', requireAuth, async (req, res) => {
if (!req.user || req.user.role !== 'admin') return res.status(403).json({ error: 'Admin only' });
if (!req.user || req.user.role !== 'admin') return res.status(403).json({ error: 'Admin only' });
@@ -1271,7 +1271,7 @@ async function start() {
@@ -1271,7 +1271,7 @@ async function start() {
// 8. Start stale stream heartbeat cleanup (every 60 seconds)
// 8. Start stale stream heartbeat cleanup (every 60 seconds)
let heartbeatCleanupRunning = false;
let heartbeatCleanupRunning = false;
const liveVodThumbGeneratedAt = new Map(); // streamId → last Media frame-grab time
const liveVodThumbGeneratedAt = new Map(); // streamId → last Media frame-grab time
- const maintenanceInterval = setInterval(() => {
+ const maintenanceInterval = setInterval(async () => {
if (heartbeatCleanupRunning) return;
if (heartbeatCleanupRunning) return;
heartbeatCleanupRunning = true;
heartbeatCleanupRunning = true;
try {
try {
@@ -1373,9 +1373,13 @@ async function start() {
@@ -1373,9 +1373,13 @@ async function start() {
if (liveThumbs.shouldRefreshLiveThumbnail(wsStream.id, 120000)) {
if (liveThumbs.shouldRefreshLiveThumbnail(wsStream.id, 120000)) {
const lastGen = liveVodThumbGeneratedAt.get(wsStream.id) || 0;
const lastGen = liveVodThumbGeneratedAt.get(wsStream.id) || 0;
if (Date.now() - lastGen < 120000) continue;
if (Date.now() - lastGen < 120000) continue;
+ liveVodThumbGeneratedAt.set(wsStream.id, Date.now());
+ // First choice: a frame straight from the SFU (works for OBS/WHIP and hidden
+ // tabs, and needs no recording). The Media in-progress-VOD frame is the backup.
+ const grabbed = await liveThumbs.generateWebrtcThumbnail(wsStream.id, { minAgeMs: 120000 }).catch(() => null);
+ if (grabbed) continue;
const rec = recorder.activeRecordings.get(wsStream.id);
const rec = recorder.activeRecordings.get(wsStream.id);
if (!rec || !rec.vodId) continue;
if (!rec || !rec.vodId) continue;
- liveVodThumbGeneratedAt.set(wsStream.id, Date.now());
mediaClient.generateThumbnail('vod', rec.vodId)
mediaClient.generateThumbnail('vod', rec.vodId)
.then((out) => {
.then((out) => {
const url = mediaClient.publicUrl(out?.url);
const url = mediaClient.publicUrl(out?.url);
@@ -112,6 +112,58 @@ function generateLiveStreamThumbnail(streamId, streamKey, opts = {}) {
@@ -112,6 +112,58 @@ function generateLiveStreamThumbnail(streamId, streamKey, opts = {}) {
});
});
}
}
+/**
+ * Grab one frame from a WebRTC / WHIP stream straight out of the SFU: a short-lived
+ * PlainRTP consumer feeds ffmpeg over loopback, ffmpeg writes one JPEG and exits. No
+ * dependency on the broadcaster's browser tab (hidden tabs and OBS/WHIP send nothing) and
+ * none on the recording (which can be refused when the disk is low).
+ */
+let _rtpPort = 31000;
+function _nextRtpPort() { const p = _rtpPort; _rtpPort += 2; if (_rtpPort > 31900) _rtpPort = 31000; return p; }
+function generateWebrtcThumbnail(streamId, opts = {}) {
+ return new Promise(async (resolve) => {
+ const minAgeMs = Number.isFinite(opts.minAgeMs) ? opts.minAgeMs : LIVE_THUMB_MIN_INTERVAL_MS;
+ if (!shouldRefreshLiveThumbnail(streamId, minAgeMs)) return resolve(getCurrentLiveThumbnailUrl(streamId));
+ const jobKey = `webrtc:${streamId}`;
+ if (activeLiveThumbnailJobs.has(jobKey)) return resolve(getCurrentLiveThumbnailUrl(streamId));
+ let sfu; try { sfu = require('../streaming/webrtc-sfu'); } catch { return resolve(null); }
+ const roomId = `stream-${streamId}`;
+ const producer = (() => { try { return sfu.findProducerByKind(roomId, 'video'); } catch { return null; } })();
+ if (!producer) return resolve(null);
+ activeLiveThumbnailJobs.add(jobKey);
+ const port = _nextRtpPort();
+ const filename = `stream-${streamId}-${Date.now()}.jpg`;
+ const outPath = path.join(THUMB_DIR, filename);
+ const sdpPath = path.join(require('os').tmpdir(), `openvibe-thumb-${streamId}-${port}.sdp`);
+ let consumer = null;
+ const finish = (ok) => {
+ activeLiveThumbnailJobs.delete(jobKey);
+ try { if (consumer) sfu.closePlainConsumer(roomId, consumer.transportId); } catch { /* */ }
+ try { fs.unlinkSync(sdpPath); } catch { /* */ }
+ if (ok && fs.existsSync(outPath) && fs.statSync(outPath).size > 2000) return resolve(_replaceThumb(streamId, filename));
+ try { if (fs.existsSync(outPath)) fs.unlinkSync(outPath); } catch { /* */ }
+ resolve(null);
+ };
+ try {
+ consumer = await sfu.createPlainConsumer(roomId, producer.id, '127.0.0.1', port, port + 1);
+ } catch (e) { console.warn(`[Thumbnails] webrtc grab: consumer failed for stream ${streamId}:`, e.message); return finish(false); }
+ const pt = consumer.payloadType, codec = (consumer.mimeType || 'video/VP8').split('/')[1];
+ const fmtp = consumer.codecParameters ? Object.entries(consumer.codecParameters).map(([k, v]) => `${k}=${v}`).join(';') : '';
+ const sdp = ['v=0', 'o=- 0 0 IN IP4 127.0.0.1', 's=OpenVibe.Live thumbnail', 'c=IN IP4 127.0.0.1', 't=0 0',
+ `m=video ${port} RTP/AVP ${pt}`, `a=rtpmap:${pt} ${codec}/${consumer.clockRate}`,
+ consumer.ssrc ? `a=ssrc:${consumer.ssrc} cname:thumb` : '', fmtp ? `a=fmtp:${pt} ${fmtp}` : '', 'a=recvonly', ''].filter(l => l !== '').join('\n') + '\n';
+ try { fs.writeFileSync(sdpPath, sdp, 'utf8'); } catch { return finish(false); }
+ const ff = spawn('ffmpeg', [
+ '-hide_banner', '-loglevel', 'error', '-y', '-protocol_whitelist', 'file,rtp,udp',
+ '-analyzeduration', '4000000', '-probesize', '4000000', '-reorder_queue_size', '512',
+ '-i', sdpPath, '-frames:v', '1', '-vf', `scale=${THUMB_WIDTH}:-1`, '-q:v', String(THUMB_QUALITY), outPath,
+ ], { stdio: 'ignore' });
+ const killTimer = setTimeout(() => { try { ff.kill('SIGKILL'); } catch { /* */ } }, 14000);
+ ff.on('close', (code) => { clearTimeout(killTimer); finish(code === 0); });
+ ff.on('error', () => { clearTimeout(killTimer); finish(false); });
+ });
+}
+
/** Grab one frame from a JSMPEG stream by tapping the relay WebSocket. */
/** Grab one frame from a JSMPEG stream by tapping the relay WebSocket. */
function generateJSMPEGThumbnail(streamId, videoPort) {
function generateJSMPEGThumbnail(streamId, videoPort) {
return new Promise((resolve) => {
return new Promise((resolve) => {
@@ -196,6 +248,7 @@ function extractFrameToFile(source, seekSeconds, outAbsPath) {
@@ -196,6 +248,7 @@ function extractFrameToFile(source, seekSeconds, outAbsPath) {
}
}
module.exports = {
module.exports = {
+ generateWebrtcThumbnail,
THUMB_DIR,
THUMB_DIR,
getStreamThumbnailState,
getStreamThumbnailState,
shouldRefreshLiveThumbnail,
shouldRefreshLiveThumbnail,
@@ -355,6 +355,64 @@ router.get('/:id/memories', optionalAuth, async (req, res) => {
@@ -355,6 +355,64 @@ router.get('/:id/memories', optionalAuth, async (req, res) => {
}
}
});
});
+/**
+ * GET /api/vods/:id/context — everything around a VOD that isn't the file: the stream it came
+ * from, its activity numbers (chat, viewers, mic moments, follows, clips), the clips cut from
+ * it, and the after-show report. Shared by the VOD page, the clip page ("from this stream")
+ * and cards. Cached 2 min.
+ */
+const _ctxCache = new Map();
+router.get('/:id/context', optionalAuth, async (req, res) => {
+ try {
+ if (!/^\d+$/.test(req.params.id)) return res.status(404).json({ error: 'VOD not found' });
+ const id = parseInt(req.params.id, 10);
+ const hit = _ctxCache.get(id);
+ if (hit && Date.now() - hit.at < 120000) { res.set('Cache-Control', 'public, max-age=60'); return res.json(hit.data); }
+ let vod = null;
+ try { vod = await media.getVod(id); } catch { vod = null; }
+ if (!vod) return res.status(404).json({ error: 'VOD not found' });
+ if (vod.visibility === 'private' && !(req.user && (req.user.id === vod.user_id || req.user.role === 'admin'))) return res.status(404).json({ error: 'VOD not found' });
+ const stream = vod.stream_id ? db.getStreamById(vod.stream_id) : null;
+ const streamer = db.getUserById(vod.user_id || (stream && stream.user_id));
+ const safe = (fn, d) => { try { const v = fn(); return v == null ? d : v; } catch { return d; } };
+ let stats = null;
+ if (stream) {
+ const start = String(stream.started_at || ''), end = String(stream.ended_at || new Date().toISOString().replace('T', ' ').slice(0, 19));
+ const chat = safe(() => db.get('SELECT COUNT(*) AS n, COUNT(DISTINCT COALESCE(user_id, anon_id, username)) AS c FROM chat_messages WHERE stream_id = ? AND COALESCE(is_deleted, 0) = 0', [stream.id]), { n: 0, c: 0 });
+ const avg = safe(() => db.get('SELECT ROUND(AVG(viewer_count), 1) AS a, COUNT(*) AS k FROM viewer_snapshots WHERE stream_id = ?', [stream.id]), { a: null, k: 0 });
+ stats = {
+ chat_messages: Number(chat.n) || 0, chatters: Number(chat.c) || 0,
+ peak_viewers: Number(stream.peak_viewers) || 0, avg_viewers: avg.k ? Number(avg.a) : null,
+ sound_commands: safe(() => db.get("SELECT COUNT(*) AS n FROM chat_messages WHERE stream_id = ? AND message_type = 'soundboard'", [stream.id]).n, 0),
+ mic_moments: safe(() => db.get('SELECT COUNT(*) AS n FROM arena_mic_moments WHERE stream_id = ?', [stream.id]).n, 0),
+ follows_gained: safe(() => db.get('SELECT COUNT(*) AS n FROM follows WHERE streamer_id = ? AND created_at BETWEEN ? AND ?', [stream.user_id, start, end]).n, 0),
+ tips: safe(() => db.get("SELECT COUNT(*) AS n, COALESCE(SUM(amount), 0) AS t FROM transactions WHERE type = 'donation' AND (stream_id = ? OR (to_user_id = ? AND created_at BETWEEN ? AND ?))", [stream.id, stream.user_id, start, end]), { n: 0, t: 0 }),
+ top_chatters: safe(() => db.all('SELECT c.username, u.display_name, u.avatar_url, u.profile_color, COUNT(*) AS n FROM chat_messages c LEFT JOIN users u ON u.id = c.user_id WHERE c.stream_id = ? AND COALESCE(c.is_deleted, 0) = 0 AND c.username IS NOT NULL AND (c.user_id IS NULL OR c.user_id != ?) GROUP BY COALESCE(c.user_id, c.username) ORDER BY n DESC LIMIT 3', [stream.id, stream.user_id]), []),
+ };
+ }
+ let clips = [];
+ try {
+ const co = await media.listClips({ vod_id: id, order: 'views', limit: 12 });
+ clips = (co?.clips || (Array.isArray(co) ? co : [])).filter(c => c.status === 'ready' || !c.status).map(c => ({ id: c.id, title: c.title, thumbnail_url: c.thumbnail_url ? media.publicUrl(c.thumbnail_url) : null, duration_seconds: c.duration_seconds || c.duration || 0, start_time: c.start_time, view_count: Number(c.view_count) || 0, by: c.display_name || c.username || null, created_at: c.created_at }));
+ } catch { clips = []; }
+ let recap = null;
+ try { const r = stream ? require('../recap/recap').getRecap(stream.id) : null; if (r) recap = { stream_id: stream.id, grade: r.write.grade, headline: r.write.headline, moment: r.write.moment || null }; } catch { recap = null; }
+ const data = {
+ vod: { id: vod.id, title: vod.title, thumbnail_url: vod.thumbnail_url ? media.publicUrl(vod.thumbnail_url) : null, duration_seconds: vod.duration_seconds || vod.duration || 0, view_count: Number(vod.view_count) || 0, created_at: vod.created_at, visibility: vod.visibility || 'public', is_recording: !!vod.is_recording },
+ stream: stream ? { id: stream.id, title: stream.title, started_at: stream.started_at, ended_at: stream.ended_at, category: stream.ai_category || stream.category || null, protocol: stream.protocol, is_live: !!stream.is_live } : null,
+ streamer: streamer ? { id: streamer.id, username: streamer.username, display_name: streamer.display_name || streamer.username, avatar_url: streamer.avatar_url || null, profile_color: streamer.profile_color || null } : null,
+ stats, clips, recap,
+ };
+ _ctxCache.set(id, { at: Date.now(), data });
+ if (_ctxCache.size > 500) { const k = _ctxCache.keys().next().value; _ctxCache.delete(k); }
+ res.set('Cache-Control', 'public, max-age=60');
+ res.json(data);
+ } catch (err) {
+ console.error('[VODs] context error:', err.message);
+ res.status(500).json({ error: 'Failed to load context' });
+ }
+});
+
router.get('/:id', optionalAuth, async (req, res) => {
router.get('/:id', optionalAuth, async (req, res) => {
try {
try {
if (!/^\d+$/.test(req.params.id)) return res.status(404).json({ error: 'VOD not found' });
if (!/^\d+$/.test(req.params.id)) return res.status(404).json({ error: 'VOD not found' });
@@ -184,6 +184,15 @@ function listRecaps(userId, limit = 6) {
@@ -184,6 +184,15 @@ function listRecaps(userId, limit = 6) {
}).filter(Boolean);
}).filter(Boolean);
}
}
+/** Latest recaps site-wide (for discovery surfaces). */
+function listRecentRecaps(limit = 6, excludeUserId = null) {
+ ensureTable();
+ const rows = db.all(`SELECT r.stream_id, r.json, r.created_at, u.username, u.display_name, u.avatar_url, u.profile_color
+ FROM stream_recaps r JOIN users u ON u.id = r.user_id WHERE (? IS NULL OR r.user_id != ?) AND COALESCE(u.is_banned, 0) = 0
+ ORDER BY r.created_at DESC LIMIT ?`, [excludeUserId, excludeUserId, limit]) || [];
+ return rows.map(r => { try { const j = JSON.parse(r.json); return { stream_id: r.stream_id, username: r.username, display_name: r.display_name || r.username, avatar_url: r.avatar_url, profile_color: r.profile_color, title: j.stream.title, headline: j.write.headline, grade: j.write.grade, duration_seconds: j.stream.duration_seconds, peak_viewers: j.stream.peak_viewers, chat_messages: j.chat.messages, ended_at: j.stream.ended_at, thumbnail_url: j.vod ? j.vod.thumbnail_url : null }; } catch { return null; } }).filter(Boolean);
+}
+
/** Streams that ended recently, ran long enough, and have no recap yet. */
/** Streams that ended recently, ran long enough, and have no recap yet. */
function pending() {
function pending() {
ensureTable();
ensureTable();
@@ -229,4 +238,4 @@ function start() {
@@ -229,4 +238,4 @@ function start() {
console.log('[Recap] after-show reports started (every 2 min, streams ≥ 8 min)');
console.log('[Recap] after-show reports started (every 2 min, streams ≥ 8 min)');
}
}
-module.exports = { buildRecap, getRecap, ensureRecap, listRecaps, pending, announce, start, tick, gather, templateWriteup, MIN_DURATION_SEC, ensureTable };
+module.exports = { buildRecap, getRecap, ensureRecap, listRecaps, listRecentRecaps, pending, announce, start, tick, gather, templateWriteup, MIN_DURATION_SEC, ensureTable };