openvibers/openvibe.live Commits

The home page, compact: the featured stream on its picture, one strip of numbers, every live stream listed (#804)

OP OpenVibers committed 2 days ago · a46ab65b59 · 9 files, +193 −113

- Featured: the title, who and time ride on the picture (no "Featured live" row); hide and sound are round
  buttons on it; Right now runs into its note; a wide screen keeps the title beside the picture; the session
  overview shows only when it says something Right now does not.
- Since you were here: the four counts are one strip (number, its change in green or red, what it counts),
  one row a person (live on two slots is one row: "Live now on 2 streams"), Watch and Rundown as round icons.
- Live Now lists every live slot, a slot that is also its streamer's PIP included (Maticus live twice showed
  once); the featured stream stays in the phone grid when two or more are live; /home/featured uses the same
  18+ rule as the list.
- Recently Online: the name has its own line and wraps instead of "@Matic…", the live chip goes under it;
  stat tiles hold their numbers on a phone (15 h; icons go first); stream titles take two lines.

Changes

ddiff --git a/public/css/features/featured.css b/public/css/features/featured.css
iindex 2a85c827..cf56419c 100644
---- a/public/css/features/featured.css
++++ b/public/css/features/featured.css
@@@ -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 the
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; } }
ddiff --git a/public/css/features/home.css b/public/css/features/home.css
iindex dc6e1fb0..41361531 100644
---- a/public/css/features/home.css
++++ b/public/css/features/home.css
@@@ -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
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. */
--.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; }
ddiff --git a/public/js/app-home.js b/public/js/app-home.js
iindex ad7d09a5..3b898fd8 100644
---- a/public/js/app-home.js
++++ b/public/js/app-home.js
@@@ -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');
ddiff --git a/public/js/home-featured.js b/public/js/home-featured.js
iindex 9f1be7b4..42ec85e2 100644
---- a/public/js/home-featured.js
++++ b/public/js/home-featured.js
@@@ -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&#39;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&#39;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 &amp; chat</a>
-- ${d.count > 1 ? `<button type="button" class="hf-next"><i class="fa-solid fa-forward"></i> Next <small>${d.count} live</small></button>` : ''}
-- <span class="hf-chat"><i class="fa-solid fa-comments"></i> Chat is open to everyone — no account needed</span>
++ ${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) : '';
ddiff --git a/server/db/database.js b/server/db/database.js
iindex e3de6b56..5671aa62 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -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,
ddiff --git a/server/home/digest.js b/server/home/digest.js
iindex 322c9409..fe30697a 100644
---- a/server/home/digest.js
++++ b/server/home/digest.js
@@@ -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}`;
ddiff --git a/server/home/routes.js b/server/home/routes.js
iindex 31225e7c..410cdef1 100644
---- a/server/home/routes.js
++++ b/server/home/routes.js
@@@ -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 });
ddiff --git a/server/streaming/routes.js b/server/streaming/routes.js
iindex 8c530be2..22f51094 100644
---- a/server/streaming/routes.js
++++ b/server/streaming/routes.js
@@@ -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.
ddiff --git a/test/pip-camera-slot.test.js b/test/pip-camera-slot.test.js
iindex 6a32afcb..76f88e40 100644
---- a/test/pip-camera-slot.test.js
++++ b/test/pip-camera-slot.test.js
@@@ -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');
}
--// ── 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 mut
const 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(); }
}