openvibers/openvibe.live Commits
The home page, compact: the featured stream on its picture, one strip of numbers, every live stream listed (#804)
Changes
@@@ -16,47 +16,66 @@.hf-media.is-switching video, .hf-media.is-switching canvas, .hf-media.is-switching img.hf-frame { opacity: 0; }.hf-media video, .hf-media canvas { transition: opacity var(--ov-dur-3, 0.4s); }.hf-poster { position: absolute; inset: 0; background-size: cover; background-position: center; filter: blur(14px) saturate(1.2); transform: scale(1.1); opacity: 0.55; }++/* What rides on the picture: the top row (live, watching, frames, then sound and hide at the end) and the caption. */++.hf-top { position: absolute; top: 10px; left: 10px; right: 10px; z-index: 2; display: flex; align-items: center; gap: 6px; pointer-events: none; }++.hf-top-end { margin-left: auto; display: inline-flex; gap: 6px; pointer-events: auto; }.hf-live {-- position: absolute; top: 12px; left: 12px; z-index: 2; display: inline-flex; align-items: center; gap: 6px;++ display: inline-flex; align-items: center; gap: 6px;padding: 4px 10px; border-radius: 999px; font-size: 0.7rem; font-weight: 800; letter-spacing: 0.08em; color: #fff;background: #ef4444; box-shadow: 0 4px 14px rgba(239, 68, 68, 0.45);}.hf-live i { font-size: 0.5rem; animation: hfBlink 1.6s var(--ov-ease-in-out, ease-in-out) infinite; }@keyframes hfBlink { 50% { opacity: 0.35; } }--.hf-viewers { position: absolute; top: 12px; right: 12px; z-index: 2; display: inline-flex; align-items: center; gap: 5px; padding: 4px 9px; border-radius: 999px; font-size: 0.72rem; color: #fff; background: rgba(0, 0, 0, 0.55); backdrop-filter: blur(6px); }--.hf-sound {-- position: absolute; bottom: 12px; left: 12px; z-index: 2; display: inline-flex; align-items: center; gap: 7px;-- padding: 7px 12px; border-radius: 999px; border: 1px solid rgba(255, 255, 255, 0.25); cursor: pointer;-- background: rgba(0, 0, 0, 0.6); color: #fff; font: inherit; font-size: 0.78rem; font-weight: 600; backdrop-filter: blur(6px);-- transition: background var(--ov-dur-2, 0.15s), transform var(--ov-dur-2, 0.15s);++.hf-viewers { display: inline-flex; align-items: center; gap: 5px; padding: 4px 9px; border-radius: 999px; font-size: 0.72rem; color: #fff; background: rgba(0, 0, 0, 0.55); backdrop-filter: blur(6px); }++.hf-sound, .hf-hide {++ display: grid; place-items: center; width: 34px; height: 34px; padding: 0; border-radius: 50%; cursor: pointer;++ border: 1px solid rgba(255, 255, 255, 0.22); background: rgba(0, 0, 0, 0.55); color: #fff; font: inherit; font-size: 0.82rem;++ -webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px);++ transition: background var(--ov-dur-2, 0.15s), transform var(--ov-dur-2, 0.15s) var(--ov-ease-spring, ease);}--.hf-sound:hover { background: rgba(0, 0, 0, 0.8); transform: translateY(-1px); }--.hf-preview-note { position: absolute; bottom: 12px; right: 12px; z-index: 2; padding: 4px 9px; border-radius: 999px; font-size: 0.68rem; color: #ddd; background: rgba(0, 0, 0, 0.5); }++.hf-sound:hover, .hf-hide:hover { background: rgba(0, 0, 0, 0.8); transform: translateY(-1px); }++.hf-sound[aria-pressed="true"] { background: var(--accent); border-color: transparent; }++.hf-preview-note { padding: 4px 9px; border-radius: 999px; font-size: 0.68rem; color: #e5e7eb; background: rgba(0, 0, 0, 0.5); -webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px); white-space: nowrap; }.hf-media-link { position: absolute; inset: 0; z-index: 1; }++.hf-cap {++ position: absolute; left: 0; right: 0; bottom: 0; z-index: 2; pointer-events: none; display: grid; gap: 6px;++ padding: 44px 14px 12px; background: linear-gradient(to top, rgba(0, 0, 0, 0.82), rgba(0, 0, 0, 0.45) 55%, transparent);++ color: #fff;++}--.hf-panel { display: flex; flex-direction: column; gap: 10px; padding: 18px 18px 16px; min-width: 0; }--.hf-kicker { display: flex; align-items: center; justify-content: space-between; gap: 8px; font-size: 0.66rem; font-weight: 800; letter-spacing: 0.1em; text-transform: uppercase; color: var(--text-muted); }--.hf-kicker b { color: var(--danger-text, #ef4444); display: inline-flex; align-items: center; gap: 6px; }--.hf-hide { background: none; border: 0; padding: 4px 6px; color: var(--text-muted); cursor: pointer; border-radius: 8px; font-size: 0.8rem; line-height: 1; }--.hf-hide:hover { color: var(--text-primary); background: color-mix(in srgb, var(--text-primary) 8%, transparent); }--.hf-title { margin: 0; font-size: clamp(1.05rem, 1.8vw, 1.35rem); font-weight: 800; line-height: 1.2; letter-spacing: -0.01em; color: var(--text-primary); overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }--.hf-who { display: flex; align-items: center; gap: 8px; font-size: 0.86rem; color: var(--text-secondary); text-decoration: none; min-width: 0; }--.hf-who:hover { color: var(--text-primary); }--.hf-who b { color: var(--text-primary); }--.hf-who .muted { margin-left: auto; font-size: 0.74rem; white-space: nowrap; }++.hf-panel { display: flex; flex-direction: column; gap: 10px; padding: 16px 16px 14px; min-width: 0; }++.hf-title { margin: 0; font-size: clamp(1.05rem, 1.8vw, 1.4rem); font-weight: 800; line-height: 1.2; letter-spacing: -0.01em; color: #fff; text-shadow: 0 1px 8px rgba(0, 0, 0, 0.6); overflow-wrap: anywhere; }++.hf-who { display: flex; align-items: center; flex-wrap: wrap; gap: 6px 8px; font-size: 0.84rem; color: rgba(255, 255, 255, 0.85); min-width: 0; }++.hf-who .stream-card-avatar { width: 24px; height: 24px; font-size: 0.7rem; box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.35); }++.hf-who b { color: #fff; overflow-wrap: anywhere; }++.hf-up { margin-left: auto; font-size: 0.74rem; white-space: nowrap; color: rgba(255, 255, 255, 0.8); }++/* A wide screen has the panel beside the picture: the title and who head it there, and the picture carries only its++ chips. A phone stacks them: the title and who ride on the picture (.hf-cap) and the panel starts with Right now. */++.hf-head { display: grid; gap: 8px; }++.hf-head .hf-title { color: var(--text-primary); text-shadow: none; }++.hf-head .hf-who { color: var(--text-secondary); text-decoration: none; }++.hf-head .hf-who:hover b { color: var(--accent); }++.hf-head .hf-who b { color: var(--text-primary); }++.hf-head .hf-tag { background: color-mix(in srgb, var(--accent) 14%, transparent); color: var(--accent-light); }++.hf-head .hf-up { color: var(--text-muted); }++@media (min-width: 861px) { .hf-cap { display: none; } }++@media (max-width: 860px) { .hf-head { display: none; } }.hf-tag .cat-inferred { opacity: .78; }--.hf-tag { font-size: 0.68rem; padding: 2px 8px; border-radius: 999px; background: color-mix(in srgb, var(--accent) 14%, transparent); color: var(--accent-light); }++.hf-tag { font-size: 0.68rem; padding: 2px 8px; border-radius: 999px; background: rgba(255, 255, 255, 0.16); color: #fff; -webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px); }.hf-now {position: relative; padding: 10px 12px 10px 14px; border-radius: 12px; font-size: 0.84rem; line-height: 1.5; color: var(--text-secondary);background: color-mix(in srgb, #22d3ee 7%, transparent); border: 1px solid color-mix(in srgb, #22d3ee 22%, transparent);transition: opacity var(--ov-dur-3, 0.3s);}.hf-now::before { content: ''; position: absolute; left: 0; top: 10px; bottom: 10px; width: 3px; border-radius: 3px; background: #22d3ee; }--.hf-now-head { display: flex; align-items: center; gap: 6px; margin-bottom: 3px; font-size: 0.66rem; font-weight: 800; letter-spacing: 0.08em; text-transform: uppercase; color: #22d3ee; }--.hf-now-head span { margin-left: auto; font-weight: 600; letter-spacing: 0; text-transform: none; color: var(--text-muted); }++/* "Right now · 23m ago" runs into the note's first line instead of taking a line of its own. */++.hf-now-head { display: inline; margin-right: 6px; font-size: 0.66rem; font-weight: 800; letter-spacing: 0.08em; text-transform: uppercase; color: #22d3ee; white-space: nowrap; }++.hf-now-head i { margin-right: 3px; }++.hf-now-head span { margin-left: 4px; font-weight: 600; letter-spacing: 0; text-transform: none; color: var(--text-muted); }++.hf-now-head span:not(:empty)::before { content: '· '; }.hf-now.is-updating { opacity: 0.4; }.hf-overview { font-size: 0.8rem; color: var(--text-muted); line-height: 1.45; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; }--.hf-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: auto; padding-top: 4px; }++.hf-actions { display: flex; align-items: center; gap: 8px; margin-top: auto; padding-top: 2px; }.hf-watch {display: inline-flex; align-items: center; gap: 8px; padding: 10px 16px; border-radius: 12px; text-decoration: none;color: #fff; font-weight: 700; font-size: 0.9rem;@@@ -72,7 +91,7 @@}.hf-next:hover { color: var(--text-primary); border-color: color-mix(in srgb, var(--accent) 50%, var(--border)); }.hf-next small { color: var(--text-muted); font-weight: 500; }--.hf-chat { display: flex; align-items: center; gap: 6px; font-size: 0.74rem; color: var(--text-muted); }++.hf-chat { display: flex; align-items: center; gap: 6px; margin-top: -2px; font-size: 0.74rem; color: var(--text-muted); }.hf-chat i { color: var(--accent); }/* The Live Now header gets a small way back in once the featured stream is hidden. */@@@ -85,10 +104,15 @@@media (max-width: 860px) {.hf { grid-template-columns: 1fr; }-- .hf-panel { padding: 14px 14px 14px; }++ .hf-panel { gap: 9px; padding: 12px 12px 12px; }.hf-overview { display: none; }++ .hf-watch { flex: 1; justify-content: center; }++}++@media (max-width: 420px) {++ .hf-cap { padding: 36px 12px 10px; gap: 5px; }++ .hf-title { font-size: 1.05rem; }}/* Touch pointers: the unmute pill answers a finger at 44 px (2026-11-05 phone sweep: 35.6 px tall). It sits on thevideo overlay, so it takes the height itself rather than a hit area over the frame's own link. */--@media (pointer: coarse) { .hf-sound { min-height: 44px; } }++@media (pointer: coarse) { .hf-sound, .hf-hide { width: 44px; height: 44px; } }@@@ -89,7 +89,7 @@/* Recently Online: every card has the same places in the same order (who and when; this week in numbers, each withits icon; when they are usually on; two lines about the channel; two replays), so cards are the same heightwithout a hole in the short ones. */--.recent-streamer-card { display: flex; flex-direction: column; height: 100%; }++.recent-streamer-card { display: flex; flex-direction: column; height: 100%; container: rscard / inline-size; }/* The head: the picture (it opens the person's menu), the name over when they are usually on, and when they were on. */.recent-streamer-head { display: flex; align-items: center; gap: 12px; flex-wrap: nowrap; padding: 14px 14px 10px; }.recent-streamer-face { position: relative; display: grid; place-items: center; width: 52px; height: 52px; flex: none; border-radius: 50%; overflow: hidden; cursor: pointer; background: color-mix(in srgb, var(--accent) 58%, var(--bg-tertiary)); color: var(--text-primary); font-size: 1.2rem; font-weight: 800; box-shadow: 0 0 0 2px var(--bg-card), 0 0 0 4px color-mix(in srgb, var(--accent) 45%, transparent); transition: transform var(--ov-dur-2, .15s) var(--ov-ease-spring, ease), box-shadow var(--ov-dur-2, .15s); }@@@ -98,11 +98,12 @@.recent-streamer-face:hover, .recent-streamer-face:focus-visible { transform: scale(1.06); outline: none; box-shadow: 0 0 0 2px var(--bg-card), 0 0 0 4px var(--accent); }.recent-streamer-face:active { transform: scale(.96); }.recent-streamer-who { display: grid; gap: 4px; min-width: 0; flex: 1; }--.recent-streamer-top { display: flex; align-items: center; gap: 8px; min-width: 0; }--.recent-streamer-usual { font-size: .78rem; line-height: 1.3; color: var(--text-secondary); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }--.recent-streamer-who .streamer-group-name { flex: 0 1 auto; min-width: 0; font-size: 1.02rem; font-weight: 750; color: var(--text-primary); text-decoration: none; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }++/* The name on its own line, whole (it wraps, never "@Matic…"); under it the live chip or last-seen and the usual time. */++.recent-streamer-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 8px; min-width: 0; }++.recent-streamer-usual { font-size: .76rem; line-height: 1.3; color: var(--text-secondary); }++.recent-streamer-who .streamer-group-name { display: block; min-width: 0; font-size: 1.02rem; font-weight: 750; line-height: 1.2; color: var(--text-primary); text-decoration: none; white-space: normal; overflow: visible; overflow-wrap: anywhere; }.recent-streamer-who .streamer-group-name:hover { color: var(--accent); }--.recent-streamer-head .streamer-group-last-online { flex: none; margin-left: auto; padding: 3px 9px; border-radius: 999px; font-size: .74rem; font-weight: 650; color: var(--text-secondary); background: color-mix(in srgb, var(--text-primary) 6%, transparent); white-space: nowrap; }++.recent-streamer-head .streamer-group-last-online { flex: none; margin-left: 0; padding: 2px 8px; border-radius: 999px; font-size: .7rem; font-weight: 650; line-height: 1.5; color: var(--text-secondary); background: color-mix(in srgb, var(--text-primary) 6%, transparent); white-space: nowrap; }.recent-streamer-head .streamer-group-last-online.is-live { color: #fff; background: var(--live, #ef4444); font-weight: 800; }/* This week: a caption (with when they are usually on), then even tiles, a number over what it counts. */.recent-streamer-cap { margin: 0 14px 6px; font-size: .72rem; font-weight: 650; color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }@@@ -110,7 +111,19 @@.recent-streamer-stat { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: baseline; gap: 1px 5px; padding: 8px 8px 7px; border-radius: 12px; background: color-mix(in srgb, var(--text-primary) 4.5%, transparent); }.recent-streamer-stat i { font-size: .78rem; color: var(--accent); }.recent-streamer-stat b { font-size: .98rem; font-weight: 800; font-variant-numeric: tabular-nums; color: var(--text-primary); white-space: nowrap; }--.recent-streamer-stat small { grid-column: 1 / -1; font-size: .7rem; line-height: 1.25; color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }++.recent-streamer-stat small { grid-column: 1 / -1; font-size: .7rem; line-height: 1.25; color: var(--text-muted); overflow-wrap: anywhere; }++/* A narrow card (a phone's rail): the number holds its tile, the icon goes first (2026-11-11: "15.3 h" ran out of it). */++@container rscard (max-width: 280px) {++ .recent-streamer-head { gap: 10px; padding: 12px 12px 8px; }++ .recent-streamer-face { width: 44px; height: 44px; font-size: 1rem; }++ .recent-streamer-cap { margin: 0 12px 5px; }++ .recent-streamer-stats { gap: 5px; padding: 0 12px 10px; }++ .recent-streamer-stat { grid-template-columns: minmax(0, 1fr); padding: 7px 7px 6px; border-radius: 10px; }++ .recent-streamer-stat i { display: none; }++ .recent-streamer-stat b { font-size: .92rem; }++ .recent-streamer-about { margin: 0 12px 10px; }++ .recent-streamer-history { gap: 6px; padding: 0 12px 12px; }++}.recent-streamer-about { position: relative; margin: 0 14px 12px; }.recent-streamer-summary { margin: 0; min-height: 4.35em; color: var(--text-secondary); font-size: .82rem; line-height: 1.45; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }/* Where the three lines cut the text, it fades out instead of stopping in the middle of a word; More is under it. */@@@ -132,7 +145,7 @@.recent-stream-row.is-live .recent-stream-thumb { box-shadow: 0 0 0 2px var(--live, #ef4444); }.recent-stream-tag { position: absolute; left: 6px; bottom: 6px; padding: 2px 7px; border-radius: 6px; font-size: .68rem; font-weight: 700; color: #fff; background: rgba(0, 0, 0, .66); -webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px); }.recent-stream-row.is-live .recent-stream-tag { background: var(--live, #ef4444); }--.recent-stream-title { font-size: .8rem; font-weight: 600; color: var(--text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }++.recent-stream-title { font-size: .8rem; font-weight: 600; line-height: 1.3; color: var(--text-primary); overflow: hidden; overflow-wrap: anywhere; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }.recent-stream-row:hover .recent-stream-title { color: var(--accent); }.streamer-group-identity {display: flex; align-items: center; gap: 8px;@@@ -260,14 +273,20 @@#page-home .digest-head { margin-bottom: 10px; }#page-home .digest-title { margin: 0; font-size: 1rem; line-height: 1.35; letter-spacing: 0; text-transform: none; }#page-home .digest-title span { color: var(--text-muted); font-size: .8rem; font-weight: 500; white-space: nowrap; }--#page-home .digest-tiles { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; }--#page-home .digest-tile { min-width: 0; min-height: 70px; padding: 9px 11px; border: 1px solid var(--border); border-radius: 10px; background: var(--bg-tertiary); display: flex; flex-direction: column; justify-content: flex-start; gap: 4px; }--#page-home .digest-tile-label { color: var(--text-secondary); font-size: .75rem; white-space: nowrap; }--#page-home .digest-tile-label i { color: var(--accent); margin-right: 3px; }--#page-home .digest-tile-value { color: var(--text-primary); font-weight: 800; font-size: 1.25rem; font-variant-numeric: tabular-nums; line-height: 1.1; }--#page-home .digest-tile-value small { font-weight: 600; font-size: .75rem; color: var(--text-muted); }--#page-home .digest-new-faces { display: flex; margin-top: 4px; }--#page-home .digest-new-faces a { margin-right: -5px; border-radius: 50%; }++/* 2026-11-11 (owner: "look at all that wasted space for those metrics"): the four counts are one strip, a number over++ what it counts, on one row at every width; the change sits beside the number, green up and red down. */++#page-home .digest-tiles { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); border: 1px solid var(--border); border-radius: 12px; background: var(--bg-tertiary); overflow: hidden; }++#page-home .digest-tile { min-width: 0; padding: 8px 10px 7px; display: flex; flex-direction: column; gap: 2px; }++#page-home .digest-tile + .digest-tile { border-left: 1px solid var(--border); }++#page-home .digest-tile-label { color: var(--text-secondary); font-size: .72rem; line-height: 1.25; overflow-wrap: anywhere; }++#page-home .digest-tile-label i { color: var(--accent); margin-right: 2px; font-size: .68rem; }++#page-home .digest-tile-value { display: flex; align-items: baseline; flex-wrap: wrap; gap: 0 5px; color: var(--text-primary); font-weight: 800; font-size: 1.12rem; font-variant-numeric: tabular-nums; line-height: 1.15; }++#page-home .digest-delta { font-weight: 650; font-size: .7rem; color: var(--text-muted); }++#page-home .digest-delta.is-up { color: var(--success, #22c55e); }++#page-home .digest-delta.is-down { color: color-mix(in srgb, var(--danger, #ef4444) 80%, var(--text-muted)); }++#page-home .digest-new-faces { display: flex; margin-top: 3px; }++#page-home .digest-new-faces a { margin-right: -6px; border-radius: 50%; }++#page-home .digest-new-faces .digest-avatar { width: 20px; height: 20px; font-size: .6rem; }#page-home .digest-avatar { width: 28px; height: 28px; flex: none; border-radius: 50%; object-fit: cover; background: var(--bg-tertiary); border: 1px solid var(--border); }#page-home .digest-avatar--initial { display: inline-grid; place-items: center; font-size: .75rem; font-weight: 800; color: var(--text-primary); }#page-home .digest-section { margin-top: 11px; padding-top: 9px; border-top: 1px solid var(--border); }@@@ -283,13 +302,19 @@#page-home .digest-topic-text strong, #page-home .digest-topic-text small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }#page-home .digest-topic-text strong { font-size: .79rem; font-weight: 600; }#page-home .digest-topic-text small { margin-top: 2px; color: var(--text-muted); font-size: .75rem; }--#page-home .digest-person { display: flex; align-items: center; gap: 8px; min-height: 47px; }--#page-home .digest-person-main { min-width: 0; flex: 1; }--#page-home .digest-person-main a { color: var(--text-primary); font-size: .8rem; font-weight: 700; text-decoration: none; }--#page-home .digest-person-main small { display: -webkit-box; overflow: hidden; -webkit-line-clamp: 2; -webkit-box-orient: vertical; color: var(--text-muted); font-size: .75rem; line-height: 1.35; }--#page-home .digest-person-actions { display: flex; align-items: center; gap: 4px; }--#page-home .digest-person-actions a { display: inline-flex; align-items: center; justify-content: center; min-height: 36px; padding: 0 7px; border: 1px solid var(--border); border-radius: 7px; color: var(--text-secondary); font-size: .75rem; font-weight: 700; white-space: nowrap; text-decoration: none; }--#page-home .digest-person-actions i { margin-right: 3px; }++/* A person: face, name (whole, wrapping before it is ever cut) and one line, then two round icons. */++#page-home .digest-person { display: flex; align-items: center; gap: 10px; min-height: 46px; padding: 3px 0; }++#page-home .digest-person.is-live .digest-avatar { box-shadow: 0 0 0 2px var(--bg-secondary), 0 0 0 3.5px var(--live, #ef4444); }++#page-home .digest-person-main { min-width: 0; flex: 1; display: grid; gap: 1px; }++#page-home .digest-person-main a { color: var(--text-primary); font-size: .84rem; font-weight: 750; line-height: 1.25; text-decoration: none; overflow-wrap: anywhere; }++#page-home .digest-person-main small { color: var(--text-muted); font-size: .74rem; line-height: 1.35; }++#page-home .digest-person.is-live .digest-person-main small { color: color-mix(in srgb, var(--live, #ef4444) 70%, var(--text-secondary)); font-weight: 600; }++#page-home .digest-person-actions { flex: none; display: flex; align-items: center; gap: 6px; }++#page-home .digest-person-actions a { display: grid; place-items: center; width: 36px; height: 36px; border: 1px solid var(--border); border-radius: 50%; color: var(--text-secondary); font-size: .8rem; text-decoration: none; transition: color var(--ov-dur-2, .15s), border-color var(--ov-dur-2, .15s), transform var(--ov-dur-2, .15s) var(--ov-ease-spring, ease); }++#page-home .digest-person-actions a:hover { border-color: var(--accent); transform: translateY(-1px); }++#page-home .digest-person-actions a.is-go { color: #fff; border-color: transparent; background: var(--accent); }++#page-home .digest-person.is-live .digest-person-actions a.is-go { background: var(--live, #ef4444); }++#page-home .digest-person-actions a.is-go i { margin-left: 2px; }#page-home .digest-across { display: flex; align-items: center; gap: 6px 12px; margin-top: 10px; padding-top: 9px; border-top: 1px solid var(--border); }#page-home .digest-across strong { flex: none; font-size: .71rem; }#page-home .digest-across a { min-width: 0; display: flex; align-items: center; color: var(--text-secondary); text-decoration: none; font-size: .75rem; }@@@ -305,8 +330,8 @@#page-home .digest-section { min-width: 0; }}@media (max-width: 700px) {-- #page-home .digest-tiles { grid-template-columns: repeat(2, minmax(0, 1fr)); }-- #page-home .digest-tile { min-height: 62px; }++ #page-home .digest-tile { padding: 7px 7px 6px; }++ #page-home .digest-tile-label i { display: none; }#page-home .digest-across { align-items: flex-start; flex-wrap: wrap; }#page-home .digest-across strong { width: 100%; }#page-home .digest-across a { max-width: 48%; }@@@ -317,7 +342,8 @@#page-home .digest-topic-text strong { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; white-space: normal; }}@media (pointer: coarse) {-- #page-home .digest-topic-row, #page-home .digest-person, #page-home .digest-person-actions a, #page-home .digest-across a { min-height: 44px; }++ #page-home .digest-topic-row, #page-home .digest-person, #page-home .digest-across a { min-height: 44px; }++ #page-home .digest-person-actions a { width: 44px; height: 44px; }#page-home .digest-new-faces a { min-width: 32px; min-height: 32px; }}.streamer-group-goal { display: flex; align-items: center; gap: 8px; margin: 6px 5px 2px; font-size: 0.76rem; }@@@ -1349,11 +1375,10 @@ html[data-ov-motion=off] .star-petals{display: none}.home-rail-tail { display: none; }@keyframes homeRailPulse { from { opacity: 1; } to { opacity: .55; } }--/* 2026-10-31, the phone home, shorter again: the featured stream's card leaves the Live Now grid on a phone (the-- featured block above already shows it), and when it was the only stream there the grid's header goes too; the-- intro at the foot starts closed (app-home.js: initHomeTabs { closable }). */++/* 2026-10-31, the phone home, shorter again: when the featured stream is the only one live, the Live Now grid and++ its header leave the phone home (the featured block above already shows it). With two or more live, Live Now lists++ every one, the featured one included (owner, 2026-11-11). The intro at the foot starts closed (initHomeTabs). */@media (max-width: 640px) {-- #stream-grid-live .stream-card.is-featured { display: none; }@supports selector(:has(*)) {#stream-grid-live:not(:has(.stream-card:not(.is-featured))):has(.stream-card) { display: none; }.section-header:has(+ #stream-grid-live:not(:has(.stream-card:not(.is-featured))):has(.stream-card)) { display: none; }@@@ -1568,10 +1568,12 @@ function renderRecentlyOnline(containerId, streamers, append) {name.href = channelHref;const when = el('span', 'streamer-group-last-online' + (s.live ? ' is-live' : ''), s.live ? 'Live now' : timeAgo(s.last_online_at));if (!s.live) when.title = 'Last live ' + timeAgo(s.last_online_at);-- const top = el('span', 'recent-streamer-top');-- top.append(name, when);-- who.append(top);-- if (facts.usual_time) who.append(el('span', 'recent-streamer-usual', facts.usual_time));++ // The name has its line to itself and wraps before it is ever cut; "Live now" or when they were last live goes on++ // the line under it with their usual time (owner, 2026-11-11: "@Matic…" beside a Live now chip).++ const meta = el('span', 'recent-streamer-meta');++ meta.append(when);++ if (facts.usual_time) meta.append(el('span', 'recent-streamer-usual', facts.usual_time));++ who.append(name, meta);head.append(face, who);card.append(head);// This week, as even tiles: a number and what it counts. One with nothing to say is not drawn; the rest share@@@ -1586,7 +1588,9 @@ function renderRecentlyOnline(containerId, streamers, append) {stats.append(n);};stat('fa-tower-broadcast', facts.streams_this_week || '', Number(facts.streams_this_week) === 1 ? 'stream' : 'streams', 'Streams this week');-- stat('fa-clock', facts.hours_this_week ? facts.hours_this_week + ' h' : '', 'live', 'Hours live this week');++ // Whole hours from ten up ("15 h", not "15.3 h"): a number that fits its tile at a phone's width (2026-11-11).++ const hrs = Number(facts.hours_this_week) || 0;++ stat('fa-clock', hrs ? (hrs >= 10 ? Math.round(hrs) : hrs) + ' h' : '', 'live', `Hours live this week: ${hrs}`);stat('fa-fire', facts.days_in_a_row >= 3 ? facts.days_in_a_row + ' d' : '', 'in a row', 'Days in a row');const week = stats.childElementCount > 0;if (!week && facts.last_stream_seconds) stat('fa-clock', formatDuration(facts.last_stream_seconds), 'last stream', 'Length of the last stream');@@@ -130,31 +130,54 @@const viewers = Number(s.total_viewer_count || s.viewer_count || 0);const up = (typeof formatUptime === 'function' && s.started_at) ? formatUptime(s.started_at) : '';const canPlay = !constrained() && (s.protocol === 'rtmp' || s.protocol === 'jsmpeg');++ // 2026-11-11 (owner: "you don't need the featured live text on that area … have it floating on top of it"): the++ // stream's title, who and how long ride on the picture, so the card is the picture and what it says right now.++ const inferred = s.ai_category && s.ai_category === s.category;++ // The session overview only when it says something Right now does not (a young stream's overview is that note).++ const ovText = String(s.ai_overview_short || s.ai_overview || '').trim();++ const same = (a, b) => !!a && !!b && a.slice(0, 48) === b.slice(0, 48);++ const overview = ovText && !same(ovText, now) ? ovText : '';st.el.innerHTML = `<div class="hf" data-stream-id="${escText(s.id)}"><div class="hf-media">${s.thumbnail_url ? `<div class="hf-poster" style="background-image:url('${escText(s.thumbnail_url)}')"></div>` : ''}-- <span class="hf-live"><i class="fa-solid fa-circle"></i> LIVE</span>-- <span class="hf-viewers"><i class="fa-solid fa-eye"></i> ${viewers.toLocaleString()}</span>-- <a class="hf-media-link" href="${escText(chan)}" onclick="return handleLinkClick(event, '${escText(chan)}')" aria-label="Watch ${escText(s.display_name || s.username)}"></a>-- ${canPlay ? `<button type="button" class="hf-sound" aria-pressed="${st.unmuted}"><i class="fa-solid ${st.unmuted ? 'fa-volume-high' : 'fa-volume-xmark'}"></i> ${st.unmuted ? 'Sound on' : 'Unmute'}</button>` : `<span class="hf-preview-note">${constrained() ? 'Live frames · saving data' : 'Live frames · tap to watch'}</span>`}++ <a class="hf-media-link" href="${escText(chan)}" onclick="return handleLinkClick(event, '${escText(chan)}')" aria-label="Watch ${escText(s.title || 'the stream')} with ${escText(s.display_name || s.username)}"></a>++ <div class="hf-top">++ <span class="hf-live" title="Featured live stream"><i class="fa-solid fa-circle"></i> LIVE</span>++ <span class="hf-viewers" title="Watching now"><i class="fa-solid fa-eye"></i> ${viewers.toLocaleString()}</span>++ ${canPlay ? '' : `<span class="hf-preview-note" title="${constrained() ? 'Saving data: a new frame every few seconds' : 'A new frame every few seconds; open the stream to watch it live'}">${constrained() ? 'Frames · saving data' : 'Live frames'}</span>`}++ <span class="hf-top-end">++ ${canPlay ? `<button type="button" class="hf-sound" aria-pressed="${st.unmuted}" aria-label="${st.unmuted ? 'Mute' : 'Unmute'}" title="${st.unmuted ? 'Mute' : 'Unmute'}"><i class="fa-solid ${st.unmuted ? 'fa-volume-high' : 'fa-volume-xmark'}"></i></button>` : ''}++ <button type="button" class="hf-hide" title="Hide the featured stream (bring it back from the Live Now header)" aria-label="Hide the featured stream"><i class="fa-solid fa-xmark"></i></button>++ </span>++ </div>++ <div class="hf-cap" aria-hidden="true">++ <h3 class="hf-title">${escText(s.title || 'Untitled Stream')}</h3>++ <span class="hf-who">++ ${typeof _avatarSpan === 'function' ? _avatarSpan(s.avatar_url, s.username, s.profile_color) : ''}++ <b>${escText('@' + (s.username || s.display_name || ''))}</b>++ ${s.category ? `<span class="hf-tag"${inferred ? ' title="Category inferred by OpenVibe's AI from the stream"' : ''}>${escText(s.category)}${inferred ? '<span class="cat-inferred"> · inferred</span>' : ''}</span>` : ''}++ ${up ? `<span class="hf-up"><i class="fa-solid fa-clock"></i> ${escText(up)}</span>` : ''}++ </span>++ </div></div><div class="hf-panel">-- <div class="hf-kicker"><b><i class="fa-solid fa-satellite-dish"></i> Featured live</b><button type="button" class="hf-hide" title="Hide the featured stream (you can bring it back from the Live Now header)" aria-label="Hide the featured stream"><i class="fa-solid fa-xmark"></i></button></div>-- <h3 class="hf-title">${escText(s.title || 'Untitled Stream')}</h3>-- <a class="hf-who" href="${escText(chan)}" onclick="return handleLinkClick(event, '${escText(chan)}')">-- ${typeof _avatarSpan === 'function' ? _avatarSpan(s.avatar_url, s.username, s.profile_color) : ''}-- <b>${escText('@' + (s.username || s.display_name || ''))}</b>-- ${s.category ? `<span class="hf-tag"${s.ai_category && s.ai_category === s.category ? ' title="Category inferred by OpenVibe's AI from the stream"' : ''}>${escText(s.category)}${s.ai_category && s.ai_category === s.category ? '<span class="cat-inferred"> · inferred</span>' : ''}</span>` : ''}-- ${up ? `<span class="muted"><i class="fa-solid fa-clock"></i> ${escText(up)}</span>` : ''}-- </a>-- ${now ? `<div class="hf-now"><div class="hf-now-head"><i class="fa-solid fa-wand-magic-sparkles"></i> Right now <span>${m.captured_at ? escText(typeof timeAgo === 'function' ? timeAgo(m.captured_at) : '') : ''}</span></div>${escText(now)}</div>` : ''}-- ${s.ai_overview_short || s.ai_overview ? `<div class="hf-overview">${escText(s.ai_overview_short || s.ai_overview)}</div>` : ''}++ <div class="hf-head">++ <h3 class="hf-title">${escText(s.title || 'Untitled Stream')}</h3>++ <a class="hf-who" href="${escText(chan)}" onclick="return handleLinkClick(event, '${escText(chan)}')">++ ${typeof _avatarSpan === 'function' ? _avatarSpan(s.avatar_url, s.username, s.profile_color) : ''}++ <b>${escText('@' + (s.username || s.display_name || ''))}</b>++ ${s.category ? `<span class="hf-tag">${escText(s.category)}</span>` : ''}++ ${up ? `<span class="hf-up"><i class="fa-solid fa-clock"></i> ${escText(up)}</span>` : ''}++ </a>++ </div>++ ${now ? `<div class="hf-now"><span class="hf-now-head"><i class="fa-solid fa-wand-magic-sparkles"></i> Right now <span>${m.captured_at ? escText(typeof timeAgo === 'function' ? timeAgo(m.captured_at) : '') : ''}</span></span>${escText(now)}</div>` : ''}++ ${overview ? `<div class="hf-overview">${escText(overview)}</div>` : ''}<div class="hf-actions"><a class="hf-watch" href="${escText(chan)}" onclick="return handleLinkClick(event, '${escText(chan)}')"><i class="fa-solid fa-play"></i> Watch & chat</a>-- ${d.count > 1 ? `<button type="button" class="hf-next"><i class="fa-solid fa-forward"></i> Next <small>${d.count} live</small></button>` : ''}-- <span class="hf-chat"><i class="fa-solid fa-comments"></i> Chat is open to everyone — no account needed</span>++ ${d.count > 1 ? `<button type="button" class="hf-next" title="The next live stream"><i class="fa-solid fa-forward"></i> Next <small>${d.count} live</small></button>` : ''}</div>++ <span class="hf-chat"><i class="fa-solid fa-comments"></i> Anyone can chat, no account needed</span></div></div>`;$('.hf-hide').addEventListener('click', () => setOff(true));@@@ -163,7 +186,8 @@st.unmuted = !st.unmuted;if (st.player) st.player.setMuted(!st.unmuted);sound.setAttribute('aria-pressed', String(st.unmuted));-- sound.innerHTML = `<i class="fa-solid ${st.unmuted ? 'fa-volume-high' : 'fa-volume-xmark'}"></i> ${st.unmuted ? 'Sound on' : 'Unmute'}`;++ sound.setAttribute('aria-label', st.unmuted ? 'Mute' : 'Unmute'); sound.title = st.unmuted ? 'Mute' : 'Unmute';++ sound.innerHTML = `<i class="fa-solid ${st.unmuted ? 'fa-volume-high' : 'fa-volume-xmark'}"></i>`;});const next = $('.hf-next');if (next) next.addEventListener('click', () => { st.skip = s.id; load(true); });@@@ -218,8 +242,7 @@const s = d.stream;const v = $('.hf-viewers');if (v) v.innerHTML = `<i class="fa-solid fa-eye"></i> ${Number(s.total_viewer_count || s.viewer_count || 0).toLocaleString()}`;-- const t = $('.hf-title');-- if (t && t.textContent !== (s.title || 'Untitled Stream')) t.textContent = s.title || 'Untitled Stream';++ for (const t of st.el.querySelectorAll('.hf-title')) if (t.textContent !== (s.title || 'Untitled Stream')) t.textContent = s.title || 'Untitled Stream';const now = $('.hf-now');const m = d.moment;if (m && m.description) {@@@ -267,9 +290,9 @@}/**-- * The featured stream's card in the Live Now grid carries .is-featured, so a phone shows that stream once, in-- * the featured block (2026-10-31: with one stream live the phone home drew it twice, 1,000 px apart). Wider-- * screens keep the card (features/home.css).++ * The featured stream's card in the Live Now grid carries .is-featured. Live Now lists every live stream (owner,++ * 2026-11-11: a streamer live on two slots showed one); only when the featured stream is the one stream live does a++ * phone leave the grid out, so it is not drawn twice 1,000 px apart (2026-10-31; features/home.css).*/function markGrid() {const id = st.current && !st.el?.hidden ? String(st.current.id) : '';@@@ -1304,21 +1304,6 @@ async function setPipsForManagedStream(managedStreamId, pips) {return list;}--/**-- * Slots live right now as a PIP over another live stream of their own streamer (a screen share's camera,-- * public/js/broadcast-cam-pip.js): the live list leaves them out, since they show inside that stream. A co-host's stream-- * shown as a PIP is not among them: it is someone else's channel and keeps its own place.-- */--async function getLiveOwnPipSourceSlots() {-- try {-- const rows = await all(`SELECT DISTINCT p.source_msid FROM stream_pips p-- JOIN managed_streams src ON src.id = p.source_msid-- JOIN streams host ON host.managed_stream_id = p.managed_stream_id AND host.is_live = 1-- WHERE host.user_id = src.user_id`);-- return new Set(rows.map((r) => Number(r.source_msid)));-- } catch { return new Set(); }--}--/** A person's slots, as PIP sources: id, title, slug, and whether each is live now. */async function getPipSourceSlots(userId) {try {@@@ -3788,7 +3773,7 @@ module.exports = {updateManagedStream, deleteManagedStream,setLiveStreamsContentLabels, suggestStreamContentLabels,getManagedStreamBroadcastSettings, updateManagedStreamBroadcastSettings,-- getPipOverlayForManagedStream, getPipCandidateSlots, getPipsForManagedStream, setPipsForManagedStream, getPipSourceSlots, getLiveOwnPipSourceSlots, MAX_PIPS,++ getPipOverlayForManagedStream, getPipCandidateSlots, getPipsForManagedStream, setPipsForManagedStream, getPipSourceSlots, MAX_PIPS,countManagedStreamsByUser, getManagedStreamLimit,isValidManagedStreamSlug, isManagedStreamSlugTaken,ensureStreamerRoleOnFeed,@@@ -36,8 +36,13 @@ const avatar = (user) => user.avatar_url? `<img class="digest-avatar" src="${esc(user.avatar_url)}" alt="" loading="lazy">`: `<span class="digest-avatar digest-avatar--initial">${esc((user.display_name || user.username || '?').slice(0, 1).toUpperCase())}</span>`;const name = (user) => esc(user.display_name || user.username);--const delta = (value) => value > 0 ? `+${value}` : String(value);--const tile = (icon, label, value, change, extra = '') => `<div class="digest-tile"><span class="digest-tile-label"><i class="fa-solid ${icon}" aria-hidden="true"></i> ${label}</span><span class="digest-tile-value">${Number(value || 0).toLocaleString()}${change == null ? '' : ` <small title="Today compared with yesterday">${delta(change)}</small>`}</span>${extra}</div>`;++const delta = (value) => value > 0 ? `+${value}` : value < 0 ? `\u2212${-value}` : '\u00b10';++// One cell of the strip (owner, 2026-11-11: "look at all that wasted space for those metrics"): the number with its++// change beside it, what it counts under it, four cells on one row at every width.++const tile = (icon, label, value, change, extra = '') => {++ const c = change == null ? '' : ` <small class="digest-delta${change > 0 ? ' is-up' : change < 0 ? ' is-down' : ''}" title="Today compared with yesterday">${delta(change)}</small>`;++ return `<div class="digest-tile"><span class="digest-tile-value">${Number(value || 0).toLocaleString()}${c}</span><span class="digest-tile-label"><i class="fa-solid ${icon}" aria-hidden="true"></i> ${label}</span>${extra}</div>`;++};const validProfile = (username) => /^[a-z0-9_-]{1,64}$/i.test(String(username || ''));const profile = (u) => validProfile(u.username) ? `/@${encodeURIComponent(u.username)}` : '/';const text = (s, max = 145) => esc(String(s || '').slice(0, max));@@@ -57,10 +62,21 @@ function render(d) {const link = `/rundown/e/${Number(t.stream_id)}#rd-take-${Number(t.id)}`;return `<a class="digest-topic-row" href="${link}"><span class="digest-topic-chip digest-topic-chip--${esc(t.kind || 'other')}">${text(t.topic, 40)}</span><span class="digest-topic-text">${avatar(t)}<span><strong>${text(t.text)}</strong><small>in ${name(t)}'s stream · ${esc(age(t.said_at))}</small></span></span></a>`;}).join('');-- const people = [...d.liveNow.map((u) => ({ ...u, live: true })), ...d.streamed].slice(0, 3).map((u) => {-- const watch = u.watch_url || (u.live ? profile(u) : null);-- const details = u.live ? `Live now · ${Number(u.viewer_count || 0)} watching` : `streamed ${Number(u.sessions) === 1 ? 'once' : `${Number(u.sessions)} times`} · ${Math.max(1, Math.round(Number(u.hours || 0) * 60))} min · peak ${Number(u.peak_viewers || 0)} watching`;-- return `<div class="digest-person">${avatar(u)}<span class="digest-person-main"><a href="${profile(u)}">${name(u)}</a><small>${esc(details)}</small></span><span class="digest-person-actions">${watch ? `<a href="${esc(watch)}">Watch</a>` : ''}<a href="/rundown/${encodeURIComponent(u.username)}"><i class="fa-solid fa-newspaper" aria-hidden="true"></i> Rundown</a></span></div>`;++ // One row a person: someone live on two slots is one row that says so, not the same face twice (2026-11-11).++ const live = [];++ for (const u of d.liveNow) {++ const had = live.find((x) => x.username === u.username);++ if (had) { had.streams += 1; had.viewer_count = Number(had.viewer_count || 0) + Number(u.viewer_count || 0); } else live.push({ ...u, live: true, streams: 1 });++ }++ const people = [...live, ...d.streamed].slice(0, 3).map((u) => {++ const go = u.watch_url || (u.live ? profile(u) : null);++ const mins = Math.max(1, Math.round(Number(u.hours || 0) * 60));++ const details = u.live++ ? `Live now${u.streams > 1 ? ` on ${u.streams} streams` : ''} · ${Number(u.viewer_count || 0)} watching`++ : `Streamed ${Number(u.sessions) === 1 ? 'once' : `${Number(u.sessions)} times`} · ${mins >= 90 ? `${Math.round(mins / 6) / 10} h` : `${mins} min`} · peak ${Number(u.peak_viewers || 0)}`;++ // The two ways in are icons with their names on them, so the person's name and line keep the row's width.++ const who = name(u);++ return `<div class="digest-person${u.live ? ' is-live' : ''}">${avatar(u)}<span class="digest-person-main"><a href="${profile(u)}">${who}</a><small>${esc(details)}</small></span><span class="digest-person-actions">${go ? `<a class="is-go" href="${esc(go)}" title="Watch" aria-label="Watch ${who}"><i class="fa-solid fa-play" aria-hidden="true"></i></a>` : ''}<a href="/rundown/${encodeURIComponent(u.username)}" title="Rundown" aria-label="${who}'s Rundown"><i class="fa-solid fa-newspaper" aria-hidden="true"></i></a></span></div>`;}).join('');const across = d.across.length ? `<div class="digest-across"><strong>Across OpenVibe</strong>${d.across.map((x) => `<a href="${esc(x.url)}"><i class="fa-solid ${esc(x.icon)}" aria-hidden="true"></i><span>${text(x.title, 62)} <small>· ${esc(x.site)}</small></span></a>`).join('')}</div>` : '';return `<div class="digest-head"><h2 class="digest-title">Since you were here <span>· ${esc(since)}</span></h2></div><div class="digest-tiles">${tiles}</div>${topics ? `<section class="digest-section"><h3>What they talked about</h3>${topics}</section>` : ''}${people ? `<section class="digest-section"><h3>Who streamed</h3>${people}</section>` : ''}${across}`;@@@ -274,7 +274,9 @@ const FEATURED_ROTATE_MS = 4 * 60 * 1000;router.get('/featured', async (req, res) => {res.set('Cache-Control', 'public, max-age=10');try {-- const live = (await db.getLiveStreams() || []).filter((s) => !s.is_nsfw);++ // The same streams Live Now lists (streaming/routes.js GET /): nothing labelled 18+ on the home page.++ const labels = require('../labels');++ const live = (await db.getLiveStreams() || []).filter((s) => !s.is_nsfw && !labels.labelsOf(s).labels.includes('18+'));require('../media-proxy/live-preview').attach(live);require('../streaming/live-thumb-cadence').markListed(live.map((x) => x.id));if (!live.length) return res.json({ stream: null, count: 0 });@@@ -1198,12 +1198,12 @@ router.get('/', optionalAuth, async (req, res) => {// the poll period so a card can never look stale for a whole cycle.res.set('Cache-Control', 'public, max-age=5');const restreamManager = require('./restream-manager');-- // A streamer's camera that is the PIP of their own live stream shows inside it, not as a stream of its own.-- const insidePip = await db.getLiveOwnPipSourceSlots();++ // Every live slot is a stream of its own and is listed, a slot that is also the PIP of its streamer's other++ // stream included (owner, 2026-11-11: "he's live twice … those are 2 different streams"; it was left out++ // since 2026-10-26, so a robot camera shown as a PIP vanished from Live Now while it was the featured stream).// The home page is the owner's one clean page (L1): nothing labelled 18+ is listed there, whatever a// viewer's prefs say. Every other surface shows it with its chips; this is the only place a label hides.-- let streams = (await db.getLiveStreams()).filter((s) => !(s.managed_stream_id && insidePip.has(Number(s.managed_stream_id)))-- && !contentLabels.labelsOf(s).labels.includes('18+'));++ let streams = (await db.getLiveStreams()).filter((s) => !contentLabels.labelsOf(s).labels.includes('18+'));require('../media-proxy/live-preview').attach(streams);require('./live-thumb-cadence').markListed(streams.map((x) => x.id));// Filters narrow the list in place, so its order (viewer_count DESC, started_at DESC) is untouched.@@@ -248,7 +248,8 @@ console.log('OK I: geometry persists, hide is recoverable, audio defaults to mutconsole.log('OK O: the viewer draws every live PIP, polls, and plays WebRTC and RTMP sources');}--// ── P: the live list: a streamer's camera that is the PIP of their own live stream shows inside it, not as a tile ──++// ── P: the live list lists every live slot: a streamer's own slot that is also the PIP of their other live stream is a++// stream of its own and keeps its tile (owner, 2026-11-11: "he's live twice … those are 2 different streams") ──{// Slot 10 (owner, live as 502) lists 11 (owner's webcam, live as 500) and 12 (someone else's, live as 501).const app = express();@@@ -256,10 +257,10 @@ console.log('OK I: geometry persists, hide is recoverable, audio defaults to mutconst server = await new Promise((r) => { const s = http.createServer(app).listen(0, '127.0.0.1', () => r(s)); });try {const ids = async () => (await (await fetch(`http://127.0.0.1:${server.address().port}/api/streams`)).json()).streams.map((x) => Number(x.id)).sort();-- assert.deepStrictEqual(await ids(), [501, 502], "the owner's webcam (500) is inside the screen share; the co-host (501) keeps a tile");++ assert.deepStrictEqual(await ids(), [500, 501, 502], "the owner's webcam (500), the co-host (501) and the screen share (502) each have a tile");await db.run('UPDATE streams SET is_live = 0 WHERE id = 502');-- assert.deepStrictEqual(await ids(), [500, 501], 'once the screen share ends, the webcam is a stream like any other');-- console.log("OK P: the live list leaves out a streamer's own camera as long as it is the PIP of their live stream");++ assert.deepStrictEqual(await ids(), [500, 501], 'once the screen share ends, its tile goes and the others stay');++ console.log('OK P: the live list lists every live slot, PIP sources included');} finally { server.close(); }}
@@ -16,47 +16,66 @@
@@ -16,47 +16,66 @@
.hf-media.is-switching video, .hf-media.is-switching canvas, .hf-media.is-switching img.hf-frame { opacity: 0; }
.hf-media.is-switching video, .hf-media.is-switching canvas, .hf-media.is-switching img.hf-frame { opacity: 0; }
.hf-media video, .hf-media canvas { transition: opacity var(--ov-dur-3, 0.4s); }
.hf-media video, .hf-media canvas { transition: opacity var(--ov-dur-3, 0.4s); }
.hf-poster { position: absolute; inset: 0; background-size: cover; background-position: center; filter: blur(14px) saturate(1.2); transform: scale(1.1); opacity: 0.55; }
.hf-poster { position: absolute; inset: 0; background-size: cover; background-position: center; filter: blur(14px) saturate(1.2); transform: scale(1.1); opacity: 0.55; }
+/* What rides on the picture: the top row (live, watching, frames, then sound and hide at the end) and the caption. */
+.hf-top { position: absolute; top: 10px; left: 10px; right: 10px; z-index: 2; display: flex; align-items: center; gap: 6px; pointer-events: none; }
+.hf-top-end { margin-left: auto; display: inline-flex; gap: 6px; pointer-events: auto; }
.hf-live {
.hf-live {
- position: absolute; top: 12px; left: 12px; z-index: 2; display: inline-flex; align-items: center; gap: 6px;
+ display: inline-flex; align-items: center; gap: 6px;
padding: 4px 10px; border-radius: 999px; font-size: 0.7rem; font-weight: 800; letter-spacing: 0.08em; color: #fff;
padding: 4px 10px; border-radius: 999px; font-size: 0.7rem; font-weight: 800; letter-spacing: 0.08em; color: #fff;
background: #ef4444; box-shadow: 0 4px 14px rgba(239, 68, 68, 0.45);
background: #ef4444; box-shadow: 0 4px 14px rgba(239, 68, 68, 0.45);
}
}
.hf-live i { font-size: 0.5rem; animation: hfBlink 1.6s var(--ov-ease-in-out, ease-in-out) infinite; }
.hf-live i { font-size: 0.5rem; animation: hfBlink 1.6s var(--ov-ease-in-out, ease-in-out) infinite; }
@keyframes hfBlink { 50% { opacity: 0.35; } }
@keyframes hfBlink { 50% { opacity: 0.35; } }
-.hf-viewers { position: absolute; top: 12px; right: 12px; z-index: 2; display: inline-flex; align-items: center; gap: 5px; padding: 4px 9px; border-radius: 999px; font-size: 0.72rem; color: #fff; background: rgba(0, 0, 0, 0.55); backdrop-filter: blur(6px); }
-.hf-sound {
- position: absolute; bottom: 12px; left: 12px; z-index: 2; display: inline-flex; align-items: center; gap: 7px;
- padding: 7px 12px; border-radius: 999px; border: 1px solid rgba(255, 255, 255, 0.25); cursor: pointer;
- background: rgba(0, 0, 0, 0.6); color: #fff; font: inherit; font-size: 0.78rem; font-weight: 600; backdrop-filter: blur(6px);
- transition: background var(--ov-dur-2, 0.15s), transform var(--ov-dur-2, 0.15s);
+.hf-viewers { display: inline-flex; align-items: center; gap: 5px; padding: 4px 9px; border-radius: 999px; font-size: 0.72rem; color: #fff; background: rgba(0, 0, 0, 0.55); backdrop-filter: blur(6px); }
+.hf-sound, .hf-hide {
+ display: grid; place-items: center; width: 34px; height: 34px; padding: 0; border-radius: 50%; cursor: pointer;
+ border: 1px solid rgba(255, 255, 255, 0.22); background: rgba(0, 0, 0, 0.55); color: #fff; font: inherit; font-size: 0.82rem;
+ -webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px);
+ transition: background var(--ov-dur-2, 0.15s), transform var(--ov-dur-2, 0.15s) var(--ov-ease-spring, ease);
}
}
-.hf-sound:hover { background: rgba(0, 0, 0, 0.8); transform: translateY(-1px); }
-.hf-preview-note { position: absolute; bottom: 12px; right: 12px; z-index: 2; padding: 4px 9px; border-radius: 999px; font-size: 0.68rem; color: #ddd; background: rgba(0, 0, 0, 0.5); }
+.hf-sound:hover, .hf-hide:hover { background: rgba(0, 0, 0, 0.8); transform: translateY(-1px); }
+.hf-sound[aria-pressed="true"] { background: var(--accent); border-color: transparent; }
+.hf-preview-note { padding: 4px 9px; border-radius: 999px; font-size: 0.68rem; color: #e5e7eb; background: rgba(0, 0, 0, 0.5); -webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px); white-space: nowrap; }
.hf-media-link { position: absolute; inset: 0; z-index: 1; }
.hf-media-link { position: absolute; inset: 0; z-index: 1; }
+.hf-cap {
+ position: absolute; left: 0; right: 0; bottom: 0; z-index: 2; pointer-events: none; display: grid; gap: 6px;
+ padding: 44px 14px 12px; background: linear-gradient(to top, rgba(0, 0, 0, 0.82), rgba(0, 0, 0, 0.45) 55%, transparent);
+ color: #fff;
+}
-.hf-panel { display: flex; flex-direction: column; gap: 10px; padding: 18px 18px 16px; min-width: 0; }
-.hf-kicker { display: flex; align-items: center; justify-content: space-between; gap: 8px; font-size: 0.66rem; font-weight: 800; letter-spacing: 0.1em; text-transform: uppercase; color: var(--text-muted); }
-.hf-kicker b { color: var(--danger-text, #ef4444); display: inline-flex; align-items: center; gap: 6px; }
-.hf-hide { background: none; border: 0; padding: 4px 6px; color: var(--text-muted); cursor: pointer; border-radius: 8px; font-size: 0.8rem; line-height: 1; }
-.hf-hide:hover { color: var(--text-primary); background: color-mix(in srgb, var(--text-primary) 8%, transparent); }
-.hf-title { margin: 0; font-size: clamp(1.05rem, 1.8vw, 1.35rem); font-weight: 800; line-height: 1.2; letter-spacing: -0.01em; color: var(--text-primary); overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
-.hf-who { display: flex; align-items: center; gap: 8px; font-size: 0.86rem; color: var(--text-secondary); text-decoration: none; min-width: 0; }
-.hf-who:hover { color: var(--text-primary); }
-.hf-who b { color: var(--text-primary); }
-.hf-who .muted { margin-left: auto; font-size: 0.74rem; white-space: nowrap; }
+.hf-panel { display: flex; flex-direction: column; gap: 10px; padding: 16px 16px 14px; min-width: 0; }
+.hf-title { margin: 0; font-size: clamp(1.05rem, 1.8vw, 1.4rem); font-weight: 800; line-height: 1.2; letter-spacing: -0.01em; color: #fff; text-shadow: 0 1px 8px rgba(0, 0, 0, 0.6); overflow-wrap: anywhere; }
+.hf-who { display: flex; align-items: center; flex-wrap: wrap; gap: 6px 8px; font-size: 0.84rem; color: rgba(255, 255, 255, 0.85); min-width: 0; }
+.hf-who .stream-card-avatar { width: 24px; height: 24px; font-size: 0.7rem; box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.35); }
+.hf-who b { color: #fff; overflow-wrap: anywhere; }
+.hf-up { margin-left: auto; font-size: 0.74rem; white-space: nowrap; color: rgba(255, 255, 255, 0.8); }
+/* A wide screen has the panel beside the picture: the title and who head it there, and the picture carries only its
+ chips. A phone stacks them: the title and who ride on the picture (.hf-cap) and the panel starts with Right now. */
+.hf-head { display: grid; gap: 8px; }
+.hf-head .hf-title { color: var(--text-primary); text-shadow: none; }
+.hf-head .hf-who { color: var(--text-secondary); text-decoration: none; }
+.hf-head .hf-who:hover b { color: var(--accent); }
+.hf-head .hf-who b { color: var(--text-primary); }
+.hf-head .hf-tag { background: color-mix(in srgb, var(--accent) 14%, transparent); color: var(--accent-light); }
+.hf-head .hf-up { color: var(--text-muted); }
+@media (min-width: 861px) { .hf-cap { display: none; } }
+@media (max-width: 860px) { .hf-head { display: none; } }
.hf-tag .cat-inferred { opacity: .78; }
.hf-tag .cat-inferred { opacity: .78; }
-.hf-tag { font-size: 0.68rem; padding: 2px 8px; border-radius: 999px; background: color-mix(in srgb, var(--accent) 14%, transparent); color: var(--accent-light); }
+.hf-tag { font-size: 0.68rem; padding: 2px 8px; border-radius: 999px; background: rgba(255, 255, 255, 0.16); color: #fff; -webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px); }
.hf-now {
.hf-now {
position: relative; padding: 10px 12px 10px 14px; border-radius: 12px; font-size: 0.84rem; line-height: 1.5; color: var(--text-secondary);
position: relative; padding: 10px 12px 10px 14px; border-radius: 12px; font-size: 0.84rem; line-height: 1.5; color: var(--text-secondary);
background: color-mix(in srgb, #22d3ee 7%, transparent); border: 1px solid color-mix(in srgb, #22d3ee 22%, transparent);
background: color-mix(in srgb, #22d3ee 7%, transparent); border: 1px solid color-mix(in srgb, #22d3ee 22%, transparent);
transition: opacity var(--ov-dur-3, 0.3s);
transition: opacity var(--ov-dur-3, 0.3s);
}
}
.hf-now::before { content: ''; position: absolute; left: 0; top: 10px; bottom: 10px; width: 3px; border-radius: 3px; background: #22d3ee; }
.hf-now::before { content: ''; position: absolute; left: 0; top: 10px; bottom: 10px; width: 3px; border-radius: 3px; background: #22d3ee; }
-.hf-now-head { display: flex; align-items: center; gap: 6px; margin-bottom: 3px; font-size: 0.66rem; font-weight: 800; letter-spacing: 0.08em; text-transform: uppercase; color: #22d3ee; }
-.hf-now-head span { margin-left: auto; font-weight: 600; letter-spacing: 0; text-transform: none; color: var(--text-muted); }
+/* "Right now · 23m ago" runs into the note's first line instead of taking a line of its own. */
+.hf-now-head { display: inline; margin-right: 6px; font-size: 0.66rem; font-weight: 800; letter-spacing: 0.08em; text-transform: uppercase; color: #22d3ee; white-space: nowrap; }
+.hf-now-head i { margin-right: 3px; }
+.hf-now-head span { margin-left: 4px; font-weight: 600; letter-spacing: 0; text-transform: none; color: var(--text-muted); }
+.hf-now-head span:not(:empty)::before { content: '· '; }
.hf-now.is-updating { opacity: 0.4; }
.hf-now.is-updating { opacity: 0.4; }
.hf-overview { font-size: 0.8rem; color: var(--text-muted); line-height: 1.45; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; }
.hf-overview { font-size: 0.8rem; color: var(--text-muted); line-height: 1.45; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; }
-.hf-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: auto; padding-top: 4px; }
+.hf-actions { display: flex; align-items: center; gap: 8px; margin-top: auto; padding-top: 2px; }
.hf-watch {
.hf-watch {
display: inline-flex; align-items: center; gap: 8px; padding: 10px 16px; border-radius: 12px; text-decoration: none;
display: inline-flex; align-items: center; gap: 8px; padding: 10px 16px; border-radius: 12px; text-decoration: none;
color: #fff; font-weight: 700; font-size: 0.9rem;
color: #fff; font-weight: 700; font-size: 0.9rem;
@@ -72,7 +91,7 @@
@@ -72,7 +91,7 @@
}
}
.hf-next:hover { color: var(--text-primary); border-color: color-mix(in srgb, var(--accent) 50%, var(--border)); }
.hf-next:hover { color: var(--text-primary); border-color: color-mix(in srgb, var(--accent) 50%, var(--border)); }
.hf-next small { color: var(--text-muted); font-weight: 500; }
.hf-next small { color: var(--text-muted); font-weight: 500; }
-.hf-chat { display: flex; align-items: center; gap: 6px; font-size: 0.74rem; color: var(--text-muted); }
+.hf-chat { display: flex; align-items: center; gap: 6px; margin-top: -2px; font-size: 0.74rem; color: var(--text-muted); }
.hf-chat i { color: var(--accent); }
.hf-chat i { color: var(--accent); }
/* The Live Now header gets a small way back in once the featured stream is hidden. */
/* The Live Now header gets a small way back in once the featured stream is hidden. */
@@ -85,10 +104,15 @@
@@ -85,10 +104,15 @@
@media (max-width: 860px) {
@media (max-width: 860px) {
.hf { grid-template-columns: 1fr; }
.hf { grid-template-columns: 1fr; }
- .hf-panel { padding: 14px 14px 14px; }
+ .hf-panel { gap: 9px; padding: 12px 12px 12px; }
.hf-overview { display: none; }
.hf-overview { display: none; }
+ .hf-watch { flex: 1; justify-content: center; }
+}
+@media (max-width: 420px) {
+ .hf-cap { padding: 36px 12px 10px; gap: 5px; }
+ .hf-title { font-size: 1.05rem; }
}
}
/* Touch pointers: the unmute pill answers a finger at 44 px (2026-11-05 phone sweep: 35.6 px tall). It sits on the
/* Touch pointers: the unmute pill answers a finger at 44 px (2026-11-05 phone sweep: 35.6 px tall). It sits on the
video overlay, so it takes the height itself rather than a hit area over the frame's own link. */
video overlay, so it takes the height itself rather than a hit area over the frame's own link. */
-@media (pointer: coarse) { .hf-sound { min-height: 44px; } }
+@media (pointer: coarse) { .hf-sound, .hf-hide { width: 44px; height: 44px; } }
@@ -89,7 +89,7 @@
@@ -89,7 +89,7 @@
/* Recently Online: every card has the same places in the same order (who and when; this week in numbers, each with
/* Recently Online: every card has the same places in the same order (who and when; this week in numbers, each with
its icon; when they are usually on; two lines about the channel; two replays), so cards are the same height
its icon; when they are usually on; two lines about the channel; two replays), so cards are the same height
without a hole in the short ones. */
without a hole in the short ones. */
-.recent-streamer-card { display: flex; flex-direction: column; height: 100%; }
+.recent-streamer-card { display: flex; flex-direction: column; height: 100%; container: rscard / inline-size; }
/* The head: the picture (it opens the person's menu), the name over when they are usually on, and when they were on. */
/* The head: the picture (it opens the person's menu), the name over when they are usually on, and when they were on. */
.recent-streamer-head { display: flex; align-items: center; gap: 12px; flex-wrap: nowrap; padding: 14px 14px 10px; }
.recent-streamer-head { display: flex; align-items: center; gap: 12px; flex-wrap: nowrap; padding: 14px 14px 10px; }
.recent-streamer-face { position: relative; display: grid; place-items: center; width: 52px; height: 52px; flex: none; border-radius: 50%; overflow: hidden; cursor: pointer; background: color-mix(in srgb, var(--accent) 58%, var(--bg-tertiary)); color: var(--text-primary); font-size: 1.2rem; font-weight: 800; box-shadow: 0 0 0 2px var(--bg-card), 0 0 0 4px color-mix(in srgb, var(--accent) 45%, transparent); transition: transform var(--ov-dur-2, .15s) var(--ov-ease-spring, ease), box-shadow var(--ov-dur-2, .15s); }
.recent-streamer-face { position: relative; display: grid; place-items: center; width: 52px; height: 52px; flex: none; border-radius: 50%; overflow: hidden; cursor: pointer; background: color-mix(in srgb, var(--accent) 58%, var(--bg-tertiary)); color: var(--text-primary); font-size: 1.2rem; font-weight: 800; box-shadow: 0 0 0 2px var(--bg-card), 0 0 0 4px color-mix(in srgb, var(--accent) 45%, transparent); transition: transform var(--ov-dur-2, .15s) var(--ov-ease-spring, ease), box-shadow var(--ov-dur-2, .15s); }
@@ -98,11 +98,12 @@
@@ -98,11 +98,12 @@
.recent-streamer-face:hover, .recent-streamer-face:focus-visible { transform: scale(1.06); outline: none; box-shadow: 0 0 0 2px var(--bg-card), 0 0 0 4px var(--accent); }
.recent-streamer-face:hover, .recent-streamer-face:focus-visible { transform: scale(1.06); outline: none; box-shadow: 0 0 0 2px var(--bg-card), 0 0 0 4px var(--accent); }
.recent-streamer-face:active { transform: scale(.96); }
.recent-streamer-face:active { transform: scale(.96); }
.recent-streamer-who { display: grid; gap: 4px; min-width: 0; flex: 1; }
.recent-streamer-who { display: grid; gap: 4px; min-width: 0; flex: 1; }
-.recent-streamer-top { display: flex; align-items: center; gap: 8px; min-width: 0; }
-.recent-streamer-usual { font-size: .78rem; line-height: 1.3; color: var(--text-secondary); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
-.recent-streamer-who .streamer-group-name { flex: 0 1 auto; min-width: 0; font-size: 1.02rem; font-weight: 750; color: var(--text-primary); text-decoration: none; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+/* The name on its own line, whole (it wraps, never "@Matic…"); under it the live chip or last-seen and the usual time. */
+.recent-streamer-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 8px; min-width: 0; }
+.recent-streamer-usual { font-size: .76rem; line-height: 1.3; color: var(--text-secondary); }
+.recent-streamer-who .streamer-group-name { display: block; min-width: 0; font-size: 1.02rem; font-weight: 750; line-height: 1.2; color: var(--text-primary); text-decoration: none; white-space: normal; overflow: visible; overflow-wrap: anywhere; }
.recent-streamer-who .streamer-group-name:hover { color: var(--accent); }
.recent-streamer-who .streamer-group-name:hover { color: var(--accent); }
-.recent-streamer-head .streamer-group-last-online { flex: none; margin-left: auto; padding: 3px 9px; border-radius: 999px; font-size: .74rem; font-weight: 650; color: var(--text-secondary); background: color-mix(in srgb, var(--text-primary) 6%, transparent); white-space: nowrap; }
+.recent-streamer-head .streamer-group-last-online { flex: none; margin-left: 0; padding: 2px 8px; border-radius: 999px; font-size: .7rem; font-weight: 650; line-height: 1.5; color: var(--text-secondary); background: color-mix(in srgb, var(--text-primary) 6%, transparent); white-space: nowrap; }
.recent-streamer-head .streamer-group-last-online.is-live { color: #fff; background: var(--live, #ef4444); font-weight: 800; }
.recent-streamer-head .streamer-group-last-online.is-live { color: #fff; background: var(--live, #ef4444); font-weight: 800; }
/* This week: a caption (with when they are usually on), then even tiles, a number over what it counts. */
/* This week: a caption (with when they are usually on), then even tiles, a number over what it counts. */
.recent-streamer-cap { margin: 0 14px 6px; font-size: .72rem; font-weight: 650; color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.recent-streamer-cap { margin: 0 14px 6px; font-size: .72rem; font-weight: 650; color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
@@ -110,7 +111,19 @@
@@ -110,7 +111,19 @@
.recent-streamer-stat { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: baseline; gap: 1px 5px; padding: 8px 8px 7px; border-radius: 12px; background: color-mix(in srgb, var(--text-primary) 4.5%, transparent); }
.recent-streamer-stat { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: baseline; gap: 1px 5px; padding: 8px 8px 7px; border-radius: 12px; background: color-mix(in srgb, var(--text-primary) 4.5%, transparent); }
.recent-streamer-stat i { font-size: .78rem; color: var(--accent); }
.recent-streamer-stat i { font-size: .78rem; color: var(--accent); }
.recent-streamer-stat b { font-size: .98rem; font-weight: 800; font-variant-numeric: tabular-nums; color: var(--text-primary); white-space: nowrap; }
.recent-streamer-stat b { font-size: .98rem; font-weight: 800; font-variant-numeric: tabular-nums; color: var(--text-primary); white-space: nowrap; }
-.recent-streamer-stat small { grid-column: 1 / -1; font-size: .7rem; line-height: 1.25; color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.recent-streamer-stat small { grid-column: 1 / -1; font-size: .7rem; line-height: 1.25; color: var(--text-muted); overflow-wrap: anywhere; }
+/* A narrow card (a phone's rail): the number holds its tile, the icon goes first (2026-11-11: "15.3 h" ran out of it). */
+@container rscard (max-width: 280px) {
+ .recent-streamer-head { gap: 10px; padding: 12px 12px 8px; }
+ .recent-streamer-face { width: 44px; height: 44px; font-size: 1rem; }
+ .recent-streamer-cap { margin: 0 12px 5px; }
+ .recent-streamer-stats { gap: 5px; padding: 0 12px 10px; }
+ .recent-streamer-stat { grid-template-columns: minmax(0, 1fr); padding: 7px 7px 6px; border-radius: 10px; }
+ .recent-streamer-stat i { display: none; }
+ .recent-streamer-stat b { font-size: .92rem; }
+ .recent-streamer-about { margin: 0 12px 10px; }
+ .recent-streamer-history { gap: 6px; padding: 0 12px 12px; }
+}
.recent-streamer-about { position: relative; margin: 0 14px 12px; }
.recent-streamer-about { position: relative; margin: 0 14px 12px; }
.recent-streamer-summary { margin: 0; min-height: 4.35em; color: var(--text-secondary); font-size: .82rem; line-height: 1.45; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
.recent-streamer-summary { margin: 0; min-height: 4.35em; color: var(--text-secondary); font-size: .82rem; line-height: 1.45; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
/* Where the three lines cut the text, it fades out instead of stopping in the middle of a word; More is under it. */
/* Where the three lines cut the text, it fades out instead of stopping in the middle of a word; More is under it. */
@@ -132,7 +145,7 @@
@@ -132,7 +145,7 @@
.recent-stream-row.is-live .recent-stream-thumb { box-shadow: 0 0 0 2px var(--live, #ef4444); }
.recent-stream-row.is-live .recent-stream-thumb { box-shadow: 0 0 0 2px var(--live, #ef4444); }
.recent-stream-tag { position: absolute; left: 6px; bottom: 6px; padding: 2px 7px; border-radius: 6px; font-size: .68rem; font-weight: 700; color: #fff; background: rgba(0, 0, 0, .66); -webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px); }
.recent-stream-tag { position: absolute; left: 6px; bottom: 6px; padding: 2px 7px; border-radius: 6px; font-size: .68rem; font-weight: 700; color: #fff; background: rgba(0, 0, 0, .66); -webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px); }
.recent-stream-row.is-live .recent-stream-tag { background: var(--live, #ef4444); }
.recent-stream-row.is-live .recent-stream-tag { background: var(--live, #ef4444); }
-.recent-stream-title { font-size: .8rem; font-weight: 600; color: var(--text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.recent-stream-title { font-size: .8rem; font-weight: 600; line-height: 1.3; color: var(--text-primary); overflow: hidden; overflow-wrap: anywhere; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
.recent-stream-row:hover .recent-stream-title { color: var(--accent); }
.recent-stream-row:hover .recent-stream-title { color: var(--accent); }
.streamer-group-identity {
.streamer-group-identity {
display: flex; align-items: center; gap: 8px;
display: flex; align-items: center; gap: 8px;
@@ -260,14 +273,20 @@
@@ -260,14 +273,20 @@
#page-home .digest-head { margin-bottom: 10px; }
#page-home .digest-head { margin-bottom: 10px; }
#page-home .digest-title { margin: 0; font-size: 1rem; line-height: 1.35; letter-spacing: 0; text-transform: none; }
#page-home .digest-title { margin: 0; font-size: 1rem; line-height: 1.35; letter-spacing: 0; text-transform: none; }
#page-home .digest-title span { color: var(--text-muted); font-size: .8rem; font-weight: 500; white-space: nowrap; }
#page-home .digest-title span { color: var(--text-muted); font-size: .8rem; font-weight: 500; white-space: nowrap; }
-#page-home .digest-tiles { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; }
-#page-home .digest-tile { min-width: 0; min-height: 70px; padding: 9px 11px; border: 1px solid var(--border); border-radius: 10px; background: var(--bg-tertiary); display: flex; flex-direction: column; justify-content: flex-start; gap: 4px; }
-#page-home .digest-tile-label { color: var(--text-secondary); font-size: .75rem; white-space: nowrap; }
-#page-home .digest-tile-label i { color: var(--accent); margin-right: 3px; }
-#page-home .digest-tile-value { color: var(--text-primary); font-weight: 800; font-size: 1.25rem; font-variant-numeric: tabular-nums; line-height: 1.1; }
-#page-home .digest-tile-value small { font-weight: 600; font-size: .75rem; color: var(--text-muted); }
-#page-home .digest-new-faces { display: flex; margin-top: 4px; }
-#page-home .digest-new-faces a { margin-right: -5px; border-radius: 50%; }
+/* 2026-11-11 (owner: "look at all that wasted space for those metrics"): the four counts are one strip, a number over
+ what it counts, on one row at every width; the change sits beside the number, green up and red down. */
+#page-home .digest-tiles { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); border: 1px solid var(--border); border-radius: 12px; background: var(--bg-tertiary); overflow: hidden; }
+#page-home .digest-tile { min-width: 0; padding: 8px 10px 7px; display: flex; flex-direction: column; gap: 2px; }
+#page-home .digest-tile + .digest-tile { border-left: 1px solid var(--border); }
+#page-home .digest-tile-label { color: var(--text-secondary); font-size: .72rem; line-height: 1.25; overflow-wrap: anywhere; }
+#page-home .digest-tile-label i { color: var(--accent); margin-right: 2px; font-size: .68rem; }
+#page-home .digest-tile-value { display: flex; align-items: baseline; flex-wrap: wrap; gap: 0 5px; color: var(--text-primary); font-weight: 800; font-size: 1.12rem; font-variant-numeric: tabular-nums; line-height: 1.15; }
+#page-home .digest-delta { font-weight: 650; font-size: .7rem; color: var(--text-muted); }
+#page-home .digest-delta.is-up { color: var(--success, #22c55e); }
+#page-home .digest-delta.is-down { color: color-mix(in srgb, var(--danger, #ef4444) 80%, var(--text-muted)); }
+#page-home .digest-new-faces { display: flex; margin-top: 3px; }
+#page-home .digest-new-faces a { margin-right: -6px; border-radius: 50%; }
+#page-home .digest-new-faces .digest-avatar { width: 20px; height: 20px; font-size: .6rem; }
#page-home .digest-avatar { width: 28px; height: 28px; flex: none; border-radius: 50%; object-fit: cover; background: var(--bg-tertiary); border: 1px solid var(--border); }
#page-home .digest-avatar { width: 28px; height: 28px; flex: none; border-radius: 50%; object-fit: cover; background: var(--bg-tertiary); border: 1px solid var(--border); }
#page-home .digest-avatar--initial { display: inline-grid; place-items: center; font-size: .75rem; font-weight: 800; color: var(--text-primary); }
#page-home .digest-avatar--initial { display: inline-grid; place-items: center; font-size: .75rem; font-weight: 800; color: var(--text-primary); }
#page-home .digest-section { margin-top: 11px; padding-top: 9px; border-top: 1px solid var(--border); }
#page-home .digest-section { margin-top: 11px; padding-top: 9px; border-top: 1px solid var(--border); }
@@ -283,13 +302,19 @@
@@ -283,13 +302,19 @@
#page-home .digest-topic-text strong, #page-home .digest-topic-text small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
#page-home .digest-topic-text strong, #page-home .digest-topic-text small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
#page-home .digest-topic-text strong { font-size: .79rem; font-weight: 600; }
#page-home .digest-topic-text strong { font-size: .79rem; font-weight: 600; }
#page-home .digest-topic-text small { margin-top: 2px; color: var(--text-muted); font-size: .75rem; }
#page-home .digest-topic-text small { margin-top: 2px; color: var(--text-muted); font-size: .75rem; }
-#page-home .digest-person { display: flex; align-items: center; gap: 8px; min-height: 47px; }
-#page-home .digest-person-main { min-width: 0; flex: 1; }
-#page-home .digest-person-main a { color: var(--text-primary); font-size: .8rem; font-weight: 700; text-decoration: none; }
-#page-home .digest-person-main small { display: -webkit-box; overflow: hidden; -webkit-line-clamp: 2; -webkit-box-orient: vertical; color: var(--text-muted); font-size: .75rem; line-height: 1.35; }
-#page-home .digest-person-actions { display: flex; align-items: center; gap: 4px; }
-#page-home .digest-person-actions a { display: inline-flex; align-items: center; justify-content: center; min-height: 36px; padding: 0 7px; border: 1px solid var(--border); border-radius: 7px; color: var(--text-secondary); font-size: .75rem; font-weight: 700; white-space: nowrap; text-decoration: none; }
-#page-home .digest-person-actions i { margin-right: 3px; }
+/* A person: face, name (whole, wrapping before it is ever cut) and one line, then two round icons. */
+#page-home .digest-person { display: flex; align-items: center; gap: 10px; min-height: 46px; padding: 3px 0; }
+#page-home .digest-person.is-live .digest-avatar { box-shadow: 0 0 0 2px var(--bg-secondary), 0 0 0 3.5px var(--live, #ef4444); }
+#page-home .digest-person-main { min-width: 0; flex: 1; display: grid; gap: 1px; }
+#page-home .digest-person-main a { color: var(--text-primary); font-size: .84rem; font-weight: 750; line-height: 1.25; text-decoration: none; overflow-wrap: anywhere; }
+#page-home .digest-person-main small { color: var(--text-muted); font-size: .74rem; line-height: 1.35; }
+#page-home .digest-person.is-live .digest-person-main small { color: color-mix(in srgb, var(--live, #ef4444) 70%, var(--text-secondary)); font-weight: 600; }
+#page-home .digest-person-actions { flex: none; display: flex; align-items: center; gap: 6px; }
+#page-home .digest-person-actions a { display: grid; place-items: center; width: 36px; height: 36px; border: 1px solid var(--border); border-radius: 50%; color: var(--text-secondary); font-size: .8rem; text-decoration: none; transition: color var(--ov-dur-2, .15s), border-color var(--ov-dur-2, .15s), transform var(--ov-dur-2, .15s) var(--ov-ease-spring, ease); }
+#page-home .digest-person-actions a:hover { border-color: var(--accent); transform: translateY(-1px); }
+#page-home .digest-person-actions a.is-go { color: #fff; border-color: transparent; background: var(--accent); }
+#page-home .digest-person.is-live .digest-person-actions a.is-go { background: var(--live, #ef4444); }
+#page-home .digest-person-actions a.is-go i { margin-left: 2px; }
#page-home .digest-across { display: flex; align-items: center; gap: 6px 12px; margin-top: 10px; padding-top: 9px; border-top: 1px solid var(--border); }
#page-home .digest-across { display: flex; align-items: center; gap: 6px 12px; margin-top: 10px; padding-top: 9px; border-top: 1px solid var(--border); }
#page-home .digest-across strong { flex: none; font-size: .71rem; }
#page-home .digest-across strong { flex: none; font-size: .71rem; }
#page-home .digest-across a { min-width: 0; display: flex; align-items: center; color: var(--text-secondary); text-decoration: none; font-size: .75rem; }
#page-home .digest-across a { min-width: 0; display: flex; align-items: center; color: var(--text-secondary); text-decoration: none; font-size: .75rem; }
@@ -305,8 +330,8 @@
@@ -305,8 +330,8 @@
#page-home .digest-section { min-width: 0; }
#page-home .digest-section { min-width: 0; }
}
}
@media (max-width: 700px) {
@media (max-width: 700px) {
- #page-home .digest-tiles { grid-template-columns: repeat(2, minmax(0, 1fr)); }
- #page-home .digest-tile { min-height: 62px; }
+ #page-home .digest-tile { padding: 7px 7px 6px; }
+ #page-home .digest-tile-label i { display: none; }
#page-home .digest-across { align-items: flex-start; flex-wrap: wrap; }
#page-home .digest-across { align-items: flex-start; flex-wrap: wrap; }
#page-home .digest-across strong { width: 100%; }
#page-home .digest-across strong { width: 100%; }
#page-home .digest-across a { max-width: 48%; }
#page-home .digest-across a { max-width: 48%; }
@@ -317,7 +342,8 @@
@@ -317,7 +342,8 @@
#page-home .digest-topic-text strong { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; white-space: normal; }
#page-home .digest-topic-text strong { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; white-space: normal; }
}
}
@media (pointer: coarse) {
@media (pointer: coarse) {
- #page-home .digest-topic-row, #page-home .digest-person, #page-home .digest-person-actions a, #page-home .digest-across a { min-height: 44px; }
+ #page-home .digest-topic-row, #page-home .digest-person, #page-home .digest-across a { min-height: 44px; }
+ #page-home .digest-person-actions a { width: 44px; height: 44px; }
#page-home .digest-new-faces a { min-width: 32px; min-height: 32px; }
#page-home .digest-new-faces a { min-width: 32px; min-height: 32px; }
}
}
.streamer-group-goal { display: flex; align-items: center; gap: 8px; margin: 6px 5px 2px; font-size: 0.76rem; }
.streamer-group-goal { display: flex; align-items: center; gap: 8px; margin: 6px 5px 2px; font-size: 0.76rem; }
@@ -1349,11 +1375,10 @@ html[data-ov-motion=off] .star-petals{display: none}
@@ -1349,11 +1375,10 @@ html[data-ov-motion=off] .star-petals{display: none}
.home-rail-tail { display: none; }
.home-rail-tail { display: none; }
@keyframes homeRailPulse { from { opacity: 1; } to { opacity: .55; } }
@keyframes homeRailPulse { from { opacity: 1; } to { opacity: .55; } }
-/* 2026-10-31, the phone home, shorter again: the featured stream's card leaves the Live Now grid on a phone (the
- featured block above already shows it), and when it was the only stream there the grid's header goes too; the
- intro at the foot starts closed (app-home.js: initHomeTabs { closable }). */
+/* 2026-10-31, the phone home, shorter again: when the featured stream is the only one live, the Live Now grid and
+ its header leave the phone home (the featured block above already shows it). With two or more live, Live Now lists
+ every one, the featured one included (owner, 2026-11-11). The intro at the foot starts closed (initHomeTabs). */
@media (max-width: 640px) {
@media (max-width: 640px) {
- #stream-grid-live .stream-card.is-featured { display: none; }
@supports selector(:has(*)) {
@supports selector(:has(*)) {
#stream-grid-live:not(:has(.stream-card:not(.is-featured))):has(.stream-card) { display: none; }
#stream-grid-live:not(:has(.stream-card:not(.is-featured))):has(.stream-card) { display: none; }
.section-header:has(+ #stream-grid-live:not(:has(.stream-card:not(.is-featured))):has(.stream-card)) { display: none; }
.section-header:has(+ #stream-grid-live:not(:has(.stream-card:not(.is-featured))):has(.stream-card)) { display: none; }
@@ -1568,10 +1568,12 @@ function renderRecentlyOnline(containerId, streamers, append) {
@@ -1568,10 +1568,12 @@ function renderRecentlyOnline(containerId, streamers, append) {
name.href = channelHref;
name.href = channelHref;
const when = el('span', 'streamer-group-last-online' + (s.live ? ' is-live' : ''), s.live ? 'Live now' : timeAgo(s.last_online_at));
const when = el('span', 'streamer-group-last-online' + (s.live ? ' is-live' : ''), s.live ? 'Live now' : timeAgo(s.last_online_at));
if (!s.live) when.title = 'Last live ' + timeAgo(s.last_online_at);
if (!s.live) when.title = 'Last live ' + timeAgo(s.last_online_at);
- const top = el('span', 'recent-streamer-top');
- top.append(name, when);
- who.append(top);
- if (facts.usual_time) who.append(el('span', 'recent-streamer-usual', facts.usual_time));
+ // The name has its line to itself and wraps before it is ever cut; "Live now" or when they were last live goes on
+ // the line under it with their usual time (owner, 2026-11-11: "@Matic…" beside a Live now chip).
+ const meta = el('span', 'recent-streamer-meta');
+ meta.append(when);
+ if (facts.usual_time) meta.append(el('span', 'recent-streamer-usual', facts.usual_time));
+ who.append(name, meta);
head.append(face, who);
head.append(face, who);
card.append(head);
card.append(head);
// This week, as even tiles: a number and what it counts. One with nothing to say is not drawn; the rest share
// This week, as even tiles: a number and what it counts. One with nothing to say is not drawn; the rest share
@@ -1586,7 +1588,9 @@ function renderRecentlyOnline(containerId, streamers, append) {
@@ -1586,7 +1588,9 @@ function renderRecentlyOnline(containerId, streamers, append) {
stats.append(n);
stats.append(n);
};
};
stat('fa-tower-broadcast', facts.streams_this_week || '', Number(facts.streams_this_week) === 1 ? 'stream' : 'streams', 'Streams this week');
stat('fa-tower-broadcast', facts.streams_this_week || '', Number(facts.streams_this_week) === 1 ? 'stream' : 'streams', 'Streams this week');
- stat('fa-clock', facts.hours_this_week ? facts.hours_this_week + ' h' : '', 'live', 'Hours live this week');
+ // Whole hours from ten up ("15 h", not "15.3 h"): a number that fits its tile at a phone's width (2026-11-11).
+ const hrs = Number(facts.hours_this_week) || 0;
+ stat('fa-clock', hrs ? (hrs >= 10 ? Math.round(hrs) : hrs) + ' h' : '', 'live', `Hours live this week: ${hrs}`);
stat('fa-fire', facts.days_in_a_row >= 3 ? facts.days_in_a_row + ' d' : '', 'in a row', 'Days in a row');
stat('fa-fire', facts.days_in_a_row >= 3 ? facts.days_in_a_row + ' d' : '', 'in a row', 'Days in a row');
const week = stats.childElementCount > 0;
const week = stats.childElementCount > 0;
if (!week && facts.last_stream_seconds) stat('fa-clock', formatDuration(facts.last_stream_seconds), 'last stream', 'Length of the last stream');
if (!week && facts.last_stream_seconds) stat('fa-clock', formatDuration(facts.last_stream_seconds), 'last stream', 'Length of the last stream');
@@ -130,31 +130,54 @@
@@ -130,31 +130,54 @@
const viewers = Number(s.total_viewer_count || s.viewer_count || 0);
const viewers = Number(s.total_viewer_count || s.viewer_count || 0);
const up = (typeof formatUptime === 'function' && s.started_at) ? formatUptime(s.started_at) : '';
const up = (typeof formatUptime === 'function' && s.started_at) ? formatUptime(s.started_at) : '';
const canPlay = !constrained() && (s.protocol === 'rtmp' || s.protocol === 'jsmpeg');
const canPlay = !constrained() && (s.protocol === 'rtmp' || s.protocol === 'jsmpeg');
+ // 2026-11-11 (owner: "you don't need the featured live text on that area … have it floating on top of it"): the
+ // stream's title, who and how long ride on the picture, so the card is the picture and what it says right now.
+ const inferred = s.ai_category && s.ai_category === s.category;
+ // The session overview only when it says something Right now does not (a young stream's overview is that note).
+ const ovText = String(s.ai_overview_short || s.ai_overview || '').trim();
+ const same = (a, b) => !!a && !!b && a.slice(0, 48) === b.slice(0, 48);
+ const overview = ovText && !same(ovText, now) ? ovText : '';
st.el.innerHTML = `
st.el.innerHTML = `
<div class="hf" data-stream-id="${escText(s.id)}">
<div class="hf" data-stream-id="${escText(s.id)}">
<div class="hf-media">
<div class="hf-media">
${s.thumbnail_url ? `<div class="hf-poster" style="background-image:url('${escText(s.thumbnail_url)}')"></div>` : ''}
${s.thumbnail_url ? `<div class="hf-poster" style="background-image:url('${escText(s.thumbnail_url)}')"></div>` : ''}
- <span class="hf-live"><i class="fa-solid fa-circle"></i> LIVE</span>
- <span class="hf-viewers"><i class="fa-solid fa-eye"></i> ${viewers.toLocaleString()}</span>
- <a class="hf-media-link" href="${escText(chan)}" onclick="return handleLinkClick(event, '${escText(chan)}')" aria-label="Watch ${escText(s.display_name || s.username)}"></a>
- ${canPlay ? `<button type="button" class="hf-sound" aria-pressed="${st.unmuted}"><i class="fa-solid ${st.unmuted ? 'fa-volume-high' : 'fa-volume-xmark'}"></i> ${st.unmuted ? 'Sound on' : 'Unmute'}</button>` : `<span class="hf-preview-note">${constrained() ? 'Live frames · saving data' : 'Live frames · tap to watch'}</span>`}
+ <a class="hf-media-link" href="${escText(chan)}" onclick="return handleLinkClick(event, '${escText(chan)}')" aria-label="Watch ${escText(s.title || 'the stream')} with ${escText(s.display_name || s.username)}"></a>
+ <div class="hf-top">
+ <span class="hf-live" title="Featured live stream"><i class="fa-solid fa-circle"></i> LIVE</span>
+ <span class="hf-viewers" title="Watching now"><i class="fa-solid fa-eye"></i> ${viewers.toLocaleString()}</span>
+ ${canPlay ? '' : `<span class="hf-preview-note" title="${constrained() ? 'Saving data: a new frame every few seconds' : 'A new frame every few seconds; open the stream to watch it live'}">${constrained() ? 'Frames · saving data' : 'Live frames'}</span>`}
+ <span class="hf-top-end">
+ ${canPlay ? `<button type="button" class="hf-sound" aria-pressed="${st.unmuted}" aria-label="${st.unmuted ? 'Mute' : 'Unmute'}" title="${st.unmuted ? 'Mute' : 'Unmute'}"><i class="fa-solid ${st.unmuted ? 'fa-volume-high' : 'fa-volume-xmark'}"></i></button>` : ''}
+ <button type="button" class="hf-hide" title="Hide the featured stream (bring it back from the Live Now header)" aria-label="Hide the featured stream"><i class="fa-solid fa-xmark"></i></button>
+ </span>
+ </div>
+ <div class="hf-cap" aria-hidden="true">
+ <h3 class="hf-title">${escText(s.title || 'Untitled Stream')}</h3>
+ <span class="hf-who">
+ ${typeof _avatarSpan === 'function' ? _avatarSpan(s.avatar_url, s.username, s.profile_color) : ''}
+ <b>${escText('@' + (s.username || s.display_name || ''))}</b>
+ ${s.category ? `<span class="hf-tag"${inferred ? ' title="Category inferred by OpenVibe's AI from the stream"' : ''}>${escText(s.category)}${inferred ? '<span class="cat-inferred"> · inferred</span>' : ''}</span>` : ''}
+ ${up ? `<span class="hf-up"><i class="fa-solid fa-clock"></i> ${escText(up)}</span>` : ''}
+ </span>
+ </div>
</div>
</div>
<div class="hf-panel">
<div class="hf-panel">
- <div class="hf-kicker"><b><i class="fa-solid fa-satellite-dish"></i> Featured live</b><button type="button" class="hf-hide" title="Hide the featured stream (you can bring it back from the Live Now header)" aria-label="Hide the featured stream"><i class="fa-solid fa-xmark"></i></button></div>
- <h3 class="hf-title">${escText(s.title || 'Untitled Stream')}</h3>
- <a class="hf-who" href="${escText(chan)}" onclick="return handleLinkClick(event, '${escText(chan)}')">
- ${typeof _avatarSpan === 'function' ? _avatarSpan(s.avatar_url, s.username, s.profile_color) : ''}
- <b>${escText('@' + (s.username || s.display_name || ''))}</b>
- ${s.category ? `<span class="hf-tag"${s.ai_category && s.ai_category === s.category ? ' title="Category inferred by OpenVibe's AI from the stream"' : ''}>${escText(s.category)}${s.ai_category && s.ai_category === s.category ? '<span class="cat-inferred"> · inferred</span>' : ''}</span>` : ''}
- ${up ? `<span class="muted"><i class="fa-solid fa-clock"></i> ${escText(up)}</span>` : ''}
- </a>
- ${now ? `<div class="hf-now"><div class="hf-now-head"><i class="fa-solid fa-wand-magic-sparkles"></i> Right now <span>${m.captured_at ? escText(typeof timeAgo === 'function' ? timeAgo(m.captured_at) : '') : ''}</span></div>${escText(now)}</div>` : ''}
- ${s.ai_overview_short || s.ai_overview ? `<div class="hf-overview">${escText(s.ai_overview_short || s.ai_overview)}</div>` : ''}
+ <div class="hf-head">
+ <h3 class="hf-title">${escText(s.title || 'Untitled Stream')}</h3>
+ <a class="hf-who" href="${escText(chan)}" onclick="return handleLinkClick(event, '${escText(chan)}')">
+ ${typeof _avatarSpan === 'function' ? _avatarSpan(s.avatar_url, s.username, s.profile_color) : ''}
+ <b>${escText('@' + (s.username || s.display_name || ''))}</b>
+ ${s.category ? `<span class="hf-tag">${escText(s.category)}</span>` : ''}
+ ${up ? `<span class="hf-up"><i class="fa-solid fa-clock"></i> ${escText(up)}</span>` : ''}
+ </a>
+ </div>
+ ${now ? `<div class="hf-now"><span class="hf-now-head"><i class="fa-solid fa-wand-magic-sparkles"></i> Right now <span>${m.captured_at ? escText(typeof timeAgo === 'function' ? timeAgo(m.captured_at) : '') : ''}</span></span>${escText(now)}</div>` : ''}
+ ${overview ? `<div class="hf-overview">${escText(overview)}</div>` : ''}
<div class="hf-actions">
<div class="hf-actions">
<a class="hf-watch" href="${escText(chan)}" onclick="return handleLinkClick(event, '${escText(chan)}')"><i class="fa-solid fa-play"></i> Watch & chat</a>
<a class="hf-watch" href="${escText(chan)}" onclick="return handleLinkClick(event, '${escText(chan)}')"><i class="fa-solid fa-play"></i> Watch & chat</a>
- ${d.count > 1 ? `<button type="button" class="hf-next"><i class="fa-solid fa-forward"></i> Next <small>${d.count} live</small></button>` : ''}
- <span class="hf-chat"><i class="fa-solid fa-comments"></i> Chat is open to everyone — no account needed</span>
+ ${d.count > 1 ? `<button type="button" class="hf-next" title="The next live stream"><i class="fa-solid fa-forward"></i> Next <small>${d.count} live</small></button>` : ''}
</div>
</div>
+ <span class="hf-chat"><i class="fa-solid fa-comments"></i> Anyone can chat, no account needed</span>
</div>
</div>
</div>`;
</div>`;
$('.hf-hide').addEventListener('click', () => setOff(true));
$('.hf-hide').addEventListener('click', () => setOff(true));
@@ -163,7 +186,8 @@
@@ -163,7 +186,8 @@
st.unmuted = !st.unmuted;
st.unmuted = !st.unmuted;
if (st.player) st.player.setMuted(!st.unmuted);
if (st.player) st.player.setMuted(!st.unmuted);
sound.setAttribute('aria-pressed', String(st.unmuted));
sound.setAttribute('aria-pressed', String(st.unmuted));
- sound.innerHTML = `<i class="fa-solid ${st.unmuted ? 'fa-volume-high' : 'fa-volume-xmark'}"></i> ${st.unmuted ? 'Sound on' : 'Unmute'}`;
+ sound.setAttribute('aria-label', st.unmuted ? 'Mute' : 'Unmute'); sound.title = st.unmuted ? 'Mute' : 'Unmute';
+ sound.innerHTML = `<i class="fa-solid ${st.unmuted ? 'fa-volume-high' : 'fa-volume-xmark'}"></i>`;
});
});
const next = $('.hf-next');
const next = $('.hf-next');
if (next) next.addEventListener('click', () => { st.skip = s.id; load(true); });
if (next) next.addEventListener('click', () => { st.skip = s.id; load(true); });
@@ -218,8 +242,7 @@
@@ -218,8 +242,7 @@
const s = d.stream;
const s = d.stream;
const v = $('.hf-viewers');
const v = $('.hf-viewers');
if (v) v.innerHTML = `<i class="fa-solid fa-eye"></i> ${Number(s.total_viewer_count || s.viewer_count || 0).toLocaleString()}`;
if (v) v.innerHTML = `<i class="fa-solid fa-eye"></i> ${Number(s.total_viewer_count || s.viewer_count || 0).toLocaleString()}`;
- const t = $('.hf-title');
- if (t && t.textContent !== (s.title || 'Untitled Stream')) t.textContent = s.title || 'Untitled Stream';
+ for (const t of st.el.querySelectorAll('.hf-title')) if (t.textContent !== (s.title || 'Untitled Stream')) t.textContent = s.title || 'Untitled Stream';
const now = $('.hf-now');
const now = $('.hf-now');
const m = d.moment;
const m = d.moment;
if (m && m.description) {
if (m && m.description) {
@@ -267,9 +290,9 @@
@@ -267,9 +290,9 @@
}
}
/**
/**
- * The featured stream's card in the Live Now grid carries .is-featured, so a phone shows that stream once, in
- * the featured block (2026-10-31: with one stream live the phone home drew it twice, 1,000 px apart). Wider
- * screens keep the card (features/home.css).
+ * The featured stream's card in the Live Now grid carries .is-featured. Live Now lists every live stream (owner,
+ * 2026-11-11: a streamer live on two slots showed one); only when the featured stream is the one stream live does a
+ * phone leave the grid out, so it is not drawn twice 1,000 px apart (2026-10-31; features/home.css).
*/
*/
function markGrid() {
function markGrid() {
const id = st.current && !st.el?.hidden ? String(st.current.id) : '';
const id = st.current && !st.el?.hidden ? String(st.current.id) : '';
@@ -1304,21 +1304,6 @@ async function setPipsForManagedStream(managedStreamId, pips) {
@@ -1304,21 +1304,6 @@ async function setPipsForManagedStream(managedStreamId, pips) {
return list;
return list;
}
}
-/**
- * Slots live right now as a PIP over another live stream of their own streamer (a screen share's camera,
- * public/js/broadcast-cam-pip.js): the live list leaves them out, since they show inside that stream. A co-host's stream
- * shown as a PIP is not among them: it is someone else's channel and keeps its own place.
- */
-async function getLiveOwnPipSourceSlots() {
- try {
- const rows = await all(`SELECT DISTINCT p.source_msid FROM stream_pips p
- JOIN managed_streams src ON src.id = p.source_msid
- JOIN streams host ON host.managed_stream_id = p.managed_stream_id AND host.is_live = 1
- WHERE host.user_id = src.user_id`);
- return new Set(rows.map((r) => Number(r.source_msid)));
- } catch { return new Set(); }
-}
-
/** A person's slots, as PIP sources: id, title, slug, and whether each is live now. */
/** A person's slots, as PIP sources: id, title, slug, and whether each is live now. */
async function getPipSourceSlots(userId) {
async function getPipSourceSlots(userId) {
try {
try {
@@ -3788,7 +3773,7 @@ module.exports = {
@@ -3788,7 +3773,7 @@ module.exports = {
updateManagedStream, deleteManagedStream,
updateManagedStream, deleteManagedStream,
setLiveStreamsContentLabels, suggestStreamContentLabels,
setLiveStreamsContentLabels, suggestStreamContentLabels,
getManagedStreamBroadcastSettings, updateManagedStreamBroadcastSettings,
getManagedStreamBroadcastSettings, updateManagedStreamBroadcastSettings,
- getPipOverlayForManagedStream, getPipCandidateSlots, getPipsForManagedStream, setPipsForManagedStream, getPipSourceSlots, getLiveOwnPipSourceSlots, MAX_PIPS,
+ getPipOverlayForManagedStream, getPipCandidateSlots, getPipsForManagedStream, setPipsForManagedStream, getPipSourceSlots, MAX_PIPS,
countManagedStreamsByUser, getManagedStreamLimit,
countManagedStreamsByUser, getManagedStreamLimit,
isValidManagedStreamSlug, isManagedStreamSlugTaken,
isValidManagedStreamSlug, isManagedStreamSlugTaken,
ensureStreamerRoleOnFeed,
ensureStreamerRoleOnFeed,
@@ -36,8 +36,13 @@ const avatar = (user) => user.avatar_url
@@ -36,8 +36,13 @@ const avatar = (user) => user.avatar_url
? `<img class="digest-avatar" src="${esc(user.avatar_url)}" alt="" loading="lazy">`
? `<img class="digest-avatar" src="${esc(user.avatar_url)}" alt="" loading="lazy">`
: `<span class="digest-avatar digest-avatar--initial">${esc((user.display_name || user.username || '?').slice(0, 1).toUpperCase())}</span>`;
: `<span class="digest-avatar digest-avatar--initial">${esc((user.display_name || user.username || '?').slice(0, 1).toUpperCase())}</span>`;
const name = (user) => esc(user.display_name || user.username);
const name = (user) => esc(user.display_name || user.username);
-const delta = (value) => value > 0 ? `+${value}` : String(value);
-const tile = (icon, label, value, change, extra = '') => `<div class="digest-tile"><span class="digest-tile-label"><i class="fa-solid ${icon}" aria-hidden="true"></i> ${label}</span><span class="digest-tile-value">${Number(value || 0).toLocaleString()}${change == null ? '' : ` <small title="Today compared with yesterday">${delta(change)}</small>`}</span>${extra}</div>`;
+const delta = (value) => value > 0 ? `+${value}` : value < 0 ? `\u2212${-value}` : '\u00b10';
+// One cell of the strip (owner, 2026-11-11: "look at all that wasted space for those metrics"): the number with its
+// change beside it, what it counts under it, four cells on one row at every width.
+const tile = (icon, label, value, change, extra = '') => {
+ const c = change == null ? '' : ` <small class="digest-delta${change > 0 ? ' is-up' : change < 0 ? ' is-down' : ''}" title="Today compared with yesterday">${delta(change)}</small>`;
+ return `<div class="digest-tile"><span class="digest-tile-value">${Number(value || 0).toLocaleString()}${c}</span><span class="digest-tile-label"><i class="fa-solid ${icon}" aria-hidden="true"></i> ${label}</span>${extra}</div>`;
+};
const validProfile = (username) => /^[a-z0-9_-]{1,64}$/i.test(String(username || ''));
const validProfile = (username) => /^[a-z0-9_-]{1,64}$/i.test(String(username || ''));
const profile = (u) => validProfile(u.username) ? `/@${encodeURIComponent(u.username)}` : '/';
const profile = (u) => validProfile(u.username) ? `/@${encodeURIComponent(u.username)}` : '/';
const text = (s, max = 145) => esc(String(s || '').slice(0, max));
const text = (s, max = 145) => esc(String(s || '').slice(0, max));
@@ -57,10 +62,21 @@ function render(d) {
@@ -57,10 +62,21 @@ function render(d) {
const link = `/rundown/e/${Number(t.stream_id)}#rd-take-${Number(t.id)}`;
const link = `/rundown/e/${Number(t.stream_id)}#rd-take-${Number(t.id)}`;
return `<a class="digest-topic-row" href="${link}"><span class="digest-topic-chip digest-topic-chip--${esc(t.kind || 'other')}">${text(t.topic, 40)}</span><span class="digest-topic-text">${avatar(t)}<span><strong>${text(t.text)}</strong><small>in ${name(t)}'s stream · ${esc(age(t.said_at))}</small></span></span></a>`;
return `<a class="digest-topic-row" href="${link}"><span class="digest-topic-chip digest-topic-chip--${esc(t.kind || 'other')}">${text(t.topic, 40)}</span><span class="digest-topic-text">${avatar(t)}<span><strong>${text(t.text)}</strong><small>in ${name(t)}'s stream · ${esc(age(t.said_at))}</small></span></span></a>`;
}).join('');
}).join('');
- const people = [...d.liveNow.map((u) => ({ ...u, live: true })), ...d.streamed].slice(0, 3).map((u) => {
- const watch = u.watch_url || (u.live ? profile(u) : null);
- const details = u.live ? `Live now · ${Number(u.viewer_count || 0)} watching` : `streamed ${Number(u.sessions) === 1 ? 'once' : `${Number(u.sessions)} times`} · ${Math.max(1, Math.round(Number(u.hours || 0) * 60))} min · peak ${Number(u.peak_viewers || 0)} watching`;
- return `<div class="digest-person">${avatar(u)}<span class="digest-person-main"><a href="${profile(u)}">${name(u)}</a><small>${esc(details)}</small></span><span class="digest-person-actions">${watch ? `<a href="${esc(watch)}">Watch</a>` : ''}<a href="/rundown/${encodeURIComponent(u.username)}"><i class="fa-solid fa-newspaper" aria-hidden="true"></i> Rundown</a></span></div>`;
+ // One row a person: someone live on two slots is one row that says so, not the same face twice (2026-11-11).
+ const live = [];
+ for (const u of d.liveNow) {
+ const had = live.find((x) => x.username === u.username);
+ if (had) { had.streams += 1; had.viewer_count = Number(had.viewer_count || 0) + Number(u.viewer_count || 0); } else live.push({ ...u, live: true, streams: 1 });
+ }
+ const people = [...live, ...d.streamed].slice(0, 3).map((u) => {
+ const go = u.watch_url || (u.live ? profile(u) : null);
+ const mins = Math.max(1, Math.round(Number(u.hours || 0) * 60));
+ const details = u.live
+ ? `Live now${u.streams > 1 ? ` on ${u.streams} streams` : ''} · ${Number(u.viewer_count || 0)} watching`
+ : `Streamed ${Number(u.sessions) === 1 ? 'once' : `${Number(u.sessions)} times`} · ${mins >= 90 ? `${Math.round(mins / 6) / 10} h` : `${mins} min`} · peak ${Number(u.peak_viewers || 0)}`;
+ // The two ways in are icons with their names on them, so the person's name and line keep the row's width.
+ const who = name(u);
+ return `<div class="digest-person${u.live ? ' is-live' : ''}">${avatar(u)}<span class="digest-person-main"><a href="${profile(u)}">${who}</a><small>${esc(details)}</small></span><span class="digest-person-actions">${go ? `<a class="is-go" href="${esc(go)}" title="Watch" aria-label="Watch ${who}"><i class="fa-solid fa-play" aria-hidden="true"></i></a>` : ''}<a href="/rundown/${encodeURIComponent(u.username)}" title="Rundown" aria-label="${who}'s Rundown"><i class="fa-solid fa-newspaper" aria-hidden="true"></i></a></span></div>`;
}).join('');
}).join('');
const across = d.across.length ? `<div class="digest-across"><strong>Across OpenVibe</strong>${d.across.map((x) => `<a href="${esc(x.url)}"><i class="fa-solid ${esc(x.icon)}" aria-hidden="true"></i><span>${text(x.title, 62)} <small>· ${esc(x.site)}</small></span></a>`).join('')}</div>` : '';
const across = d.across.length ? `<div class="digest-across"><strong>Across OpenVibe</strong>${d.across.map((x) => `<a href="${esc(x.url)}"><i class="fa-solid ${esc(x.icon)}" aria-hidden="true"></i><span>${text(x.title, 62)} <small>· ${esc(x.site)}</small></span></a>`).join('')}</div>` : '';
return `<div class="digest-head"><h2 class="digest-title">Since you were here <span>· ${esc(since)}</span></h2></div><div class="digest-tiles">${tiles}</div>${topics ? `<section class="digest-section"><h3>What they talked about</h3>${topics}</section>` : ''}${people ? `<section class="digest-section"><h3>Who streamed</h3>${people}</section>` : ''}${across}`;
return `<div class="digest-head"><h2 class="digest-title">Since you were here <span>· ${esc(since)}</span></h2></div><div class="digest-tiles">${tiles}</div>${topics ? `<section class="digest-section"><h3>What they talked about</h3>${topics}</section>` : ''}${people ? `<section class="digest-section"><h3>Who streamed</h3>${people}</section>` : ''}${across}`;
@@ -274,7 +274,9 @@ const FEATURED_ROTATE_MS = 4 * 60 * 1000;
@@ -274,7 +274,9 @@ const FEATURED_ROTATE_MS = 4 * 60 * 1000;
router.get('/featured', async (req, res) => {
router.get('/featured', async (req, res) => {
res.set('Cache-Control', 'public, max-age=10');
res.set('Cache-Control', 'public, max-age=10');
try {
try {
- const live = (await db.getLiveStreams() || []).filter((s) => !s.is_nsfw);
+ // The same streams Live Now lists (streaming/routes.js GET /): nothing labelled 18+ on the home page.
+ const labels = require('../labels');
+ const live = (await db.getLiveStreams() || []).filter((s) => !s.is_nsfw && !labels.labelsOf(s).labels.includes('18+'));
require('../media-proxy/live-preview').attach(live);
require('../media-proxy/live-preview').attach(live);
require('../streaming/live-thumb-cadence').markListed(live.map((x) => x.id));
require('../streaming/live-thumb-cadence').markListed(live.map((x) => x.id));
if (!live.length) return res.json({ stream: null, count: 0 });
if (!live.length) return res.json({ stream: null, count: 0 });
@@ -1198,12 +1198,12 @@ router.get('/', optionalAuth, async (req, res) => {
@@ -1198,12 +1198,12 @@ router.get('/', optionalAuth, async (req, res) => {
// the poll period so a card can never look stale for a whole cycle.
// the poll period so a card can never look stale for a whole cycle.
res.set('Cache-Control', 'public, max-age=5');
res.set('Cache-Control', 'public, max-age=5');
const restreamManager = require('./restream-manager');
const restreamManager = require('./restream-manager');
- // A streamer's camera that is the PIP of their own live stream shows inside it, not as a stream of its own.
- const insidePip = await db.getLiveOwnPipSourceSlots();
+ // Every live slot is a stream of its own and is listed, a slot that is also the PIP of its streamer's other
+ // stream included (owner, 2026-11-11: "he's live twice … those are 2 different streams"; it was left out
+ // since 2026-10-26, so a robot camera shown as a PIP vanished from Live Now while it was the featured stream).
// The home page is the owner's one clean page (L1): nothing labelled 18+ is listed there, whatever a
// The home page is the owner's one clean page (L1): nothing labelled 18+ is listed there, whatever a
// viewer's prefs say. Every other surface shows it with its chips; this is the only place a label hides.
// viewer's prefs say. Every other surface shows it with its chips; this is the only place a label hides.
- let streams = (await db.getLiveStreams()).filter((s) => !(s.managed_stream_id && insidePip.has(Number(s.managed_stream_id)))
- && !contentLabels.labelsOf(s).labels.includes('18+'));
+ let streams = (await db.getLiveStreams()).filter((s) => !contentLabels.labelsOf(s).labels.includes('18+'));
require('../media-proxy/live-preview').attach(streams);
require('../media-proxy/live-preview').attach(streams);
require('./live-thumb-cadence').markListed(streams.map((x) => x.id));
require('./live-thumb-cadence').markListed(streams.map((x) => x.id));
// Filters narrow the list in place, so its order (viewer_count DESC, started_at DESC) is untouched.
// Filters narrow the list in place, so its order (viewer_count DESC, started_at DESC) is untouched.
@@ -248,7 +248,8 @@ console.log('OK I: geometry persists, hide is recoverable, audio defaults to mut
@@ -248,7 +248,8 @@ console.log('OK I: geometry persists, hide is recoverable, audio defaults to mut
console.log('OK O: the viewer draws every live PIP, polls, and plays WebRTC and RTMP sources');
console.log('OK O: the viewer draws every live PIP, polls, and plays WebRTC and RTMP sources');
}
}
-// ── P: the live list: a streamer's camera that is the PIP of their own live stream shows inside it, not as a tile ──
+// ── P: the live list lists every live slot: a streamer's own slot that is also the PIP of their other live stream is a
+// stream of its own and keeps its tile (owner, 2026-11-11: "he's live twice … those are 2 different streams") ──
{
{
// Slot 10 (owner, live as 502) lists 11 (owner's webcam, live as 500) and 12 (someone else's, live as 501).
// Slot 10 (owner, live as 502) lists 11 (owner's webcam, live as 500) and 12 (someone else's, live as 501).
const app = express();
const app = express();
@@ -256,10 +257,10 @@ console.log('OK I: geometry persists, hide is recoverable, audio defaults to mut
@@ -256,10 +257,10 @@ console.log('OK I: geometry persists, hide is recoverable, audio defaults to mut
const server = await new Promise((r) => { const s = http.createServer(app).listen(0, '127.0.0.1', () => r(s)); });
const server = await new Promise((r) => { const s = http.createServer(app).listen(0, '127.0.0.1', () => r(s)); });
try {
try {
const ids = async () => (await (await fetch(`http://127.0.0.1:${server.address().port}/api/streams`)).json()).streams.map((x) => Number(x.id)).sort();
const ids = async () => (await (await fetch(`http://127.0.0.1:${server.address().port}/api/streams`)).json()).streams.map((x) => Number(x.id)).sort();
- assert.deepStrictEqual(await ids(), [501, 502], "the owner's webcam (500) is inside the screen share; the co-host (501) keeps a tile");
+ assert.deepStrictEqual(await ids(), [500, 501, 502], "the owner's webcam (500), the co-host (501) and the screen share (502) each have a tile");
await db.run('UPDATE streams SET is_live = 0 WHERE id = 502');
await db.run('UPDATE streams SET is_live = 0 WHERE id = 502');
- assert.deepStrictEqual(await ids(), [500, 501], 'once the screen share ends, the webcam is a stream like any other');
- console.log("OK P: the live list leaves out a streamer's own camera as long as it is the PIP of their live stream");
+ assert.deepStrictEqual(await ids(), [500, 501], 'once the screen share ends, its tile goes and the others stay');
+ console.log('OK P: the live list lists every live slot, PIP sources included');
} finally { server.close(); }
} finally { server.close(); }
}
}