openvibers/openvibe.live Commits

A portrait stream on a phone is the screen again; no jump to About; the robot panel is a card; Recently Online built up (#768)

OP OpenVibers committed 3 days ago · b826595c80 · 7 files, +166 −59

* A portrait stream on a phone is the screen again; the page no longer jumps to About; the robot panel is a card; Recently Online built up

* Recently Online: when they were live at the right of the name, the usual time whole, three lines that fade, More under them

Changes

ddiff --git a/public/css/features/channel.css b/public/css/features/channel.css
iindex 7567fda0..54ec1146 100644
---- a/public/css/features/channel.css
++++ b/public/css/features/channel.css
@@@ -1993,3 +1993,55 @@ html[data-ov-motion=off] #page-channel .ch-tab-panel{scroll-margin-top: calc(var
background: rgba(255, 255, 255, .12); -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px); color: #fff; }
.video-controls .viewer-badge i, .video-controls .uptime-badge i { font-size: 12px; color: rgba(255, 255, 255, .75); }
@media (max-width: 480px) { .video-controls .uptime-badge { display: none; } .video-controls .viewer-badge { padding: 0 9px; } }
++/* ── A portrait stream on a phone, put right (11-10) ─────────────────────────────────────────────────────────────
++ The owner, watching Maticus: the channel's whole header (chips, the title, a row of wide buttons) lay over the top
++ of the picture and under the player's own controls, and the chat could not be folded. The rules of 11-08 and 11-09
++ for the channel line and the action row were written for a landscape stream, where the line sits under the player;
++ over a portrait picture they stacked everything on it. Here the picture is the screen again:
++ - the channel line is one row over the top: the picture, the name over "Live now · N watching", Follow, More.
++ A tap on it opens the rest as a panel (the chips, the title, Share, Ask), on a shade that covers the picture;
++ - the player's own controls sit under that row, never under the panel;
++ - the chat keeps the lower part and has its fold button back (its header is hidden here, so the button rides at the
++ chat's top right); folded, the picture is whole and the Chat pill brings it back;
++ - "Tap for sound" is in the middle of the picture, not under the name. */
++@media (max-width: 768px) and (orientation: portrait) {
++ #page-channel.ch-is-live .stream-layout:has(.video-container.is-vertical) .ch-info-bar:not(.ch-info-open) { flex-wrap: nowrap; row-gap: 0; padding: 8px 10px 20px 12px; }
++ #page-channel.ch-is-live .stream-layout:has(.video-container.is-vertical) .ch-info-bar:not(.ch-info-open) .ch-info-bar-left { flex: 1 1 auto; min-width: 0; display: flex; align-items: center; gap: 10px; }
++ #page-channel.ch-is-live .stream-layout:has(.video-container.is-vertical) .ch-info-bar:not(.ch-info-open) .ch-avatar { width: 38px; height: 38px; flex: none; margin: 0; }
++ #page-channel.ch-is-live .stream-layout:has(.video-container.is-vertical) .ch-info-bar:not(.ch-info-open) :is(.ch-meta, .ch-social-pills, .stream-labels, .ch-stream-title, .ch-staff-badge, #ch-share-slot, #ch-ask-slot) { display: none !important; }
++ #page-channel.ch-is-live .stream-layout:has(.video-container.is-vertical) .ch-info-bar .ch-presence { margin: 1px 0 0; font-size: .78rem; color: #fff; }
++ #page-channel.ch-is-live .stream-layout:has(.video-container.is-vertical) .ch-info-bar-top h2::after { content: ""; display: inline-block; width: 6px; height: 6px; margin: 0 0 3px 8px; border: solid currentColor; border-width: 0 2px 2px 0; transform: rotate(45deg); opacity: .75; transition: transform .2s; }
++ #page-channel.ch-is-live .stream-layout:has(.video-container.is-vertical) .ch-info-bar.ch-info-open .ch-info-bar-top h2::after { transform: translateY(3px) rotate(225deg); }
++ #page-channel.ch-is-live .stream-layout:has(.video-container.is-vertical) .ch-info-bar:not(.ch-info-open) .ch-info-bar-right { flex: 0 0 auto; width: auto; gap: 6px; }
++ #page-channel.ch-is-live .stream-layout:has(.video-container.is-vertical) .ch-info-bar:not(.ch-info-open) .ch-action--follow { flex: none; height: 40px; min-height: 40px; padding: 0 14px; }
++ #page-channel.ch-is-live .stream-layout:has(.video-container.is-vertical) .ch-info-bar:not(.ch-info-open) .ch-action--more { width: 40px; height: 40px; min-height: 40px; background: rgba(0, 0, 0, .35); color: #fff; border-color: rgba(255, 255, 255, .22); }
++ /* Opened: a panel over the picture, everything in it, scrolling inside if it is long. */
++ #page-channel.ch-is-live .stream-layout:has(.video-container.is-vertical) .ch-info-bar.ch-info-open { z-index: 12; padding: 12px 12px 16px; max-height: 72%; overflow-y: auto; overscroll-behavior: contain; background: rgba(8, 10, 16, .88); -webkit-backdrop-filter: blur(14px); backdrop-filter: blur(14px); border-radius: 0 0 18px 18px; box-shadow: 0 18px 40px rgba(0, 0, 0, .5); animation: chInfoDrop var(--ov-dur-2, .25s) var(--ov-ease-out, cubic-bezier(.22, 1, .36, 1)) both; }
++ @keyframes chInfoDrop { from { opacity: 0; transform: translateY(calc(-10px * var(--ov-move, 1))); } }
++ /* The player's controls: under the folded row, and out of the way of the opened panel. */
++ #page-channel.ch-is-live .stream-layout:has(.video-container.is-vertical) .video-overlay { top: 60px; }
++ #page-channel.ch-is-live .stream-layout:has(.video-container.is-vertical):has(.ch-info-bar.ch-info-open) .video-overlay { visibility: hidden; }
++ #page-channel.ch-is-live .video-container.is-vertical .unmute-pill { left: 50%; top: 42%; transform: translate(-50%, -50%); }
++ /* The chat's fold button, back: its header is hidden over a portrait picture, so the button alone is shown, at the
++ chat's top right, where the picture is left clear. */
++ #page-channel.ch-is-live .stream-layout:has(.video-container.is-vertical) > .chat-sidebar .chat-header { display: flex !important; position: absolute; z-index: 2; right: 8px; top: 30%; padding: 0; min-height: 0; background: none; border: 0; }
++ #page-channel.ch-is-live .stream-layout:has(.video-container.is-vertical) > .chat-sidebar .chat-header > :not(.ch-chat-collapse) { display: none !important; }
++ #page-channel.ch-is-live .stream-layout:has(.video-container.is-vertical) > .chat-sidebar .chat-header .ch-chat-collapse { width: 44px; height: 44px; margin: 0; border-radius: 50%; color: #fff; background: rgba(0, 0, 0, .45); border: 1px solid rgba(255, 255, 255, .22); -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px); }
++ /* Folded: the picture keeps the whole screen (the layout does not shrink to nothing under an absolute player). */
++ #page-channel.ch-is-live.chat-collapsed .stream-layout:has(.video-container.is-vertical) { height: calc(100dvh - var(--navbar-h) - var(--live-tabs-h, 0px)); }
++ /* The chat takes a little less of the picture, and its goal bar is a thin strip. */
++ #page-channel.ch-is-live.page .stream-layout:has(.video-container.is-vertical) > .chat-sidebar { height: var(--watch-chat-h, 42%); }
++}
++@media (max-width: 768px) and (orientation: portrait) {
++ #page-channel.ch-is-live .stream-layout:has(.video-container.is-vertical) .ch-info-bar:not(.ch-info-open) .ch-username { display: none; }
++}
++
++/* ── The robot's panel (OpenVibe.Bot) ──────────────────────────────────────────────────────────────────────────────
++ It was a bare frame at the browser's default 300 by 150 with scrollbars, and a black band beside it (the owner,
++ 11-10: "all this broken space"). A card under the player, as wide as the column; its height can be pulled taller. */
++#ch-bot-section { margin: 10px 0 0; border: 1px solid var(--border); border-radius: 16px; background: var(--bg-card, var(--bg-secondary)); overflow: hidden; }
++#ch-bot-section > h3 { display: flex; align-items: center; gap: 9px; margin: 0; padding: 10px 14px; font-size: .9rem; font-weight: 700; color: var(--text-primary); border-bottom: 1px solid var(--border); }
++#ch-bot-section > h3::before { content: ""; flex: none; width: 8px; height: 8px; border-radius: 50%; background: var(--success-text, #34d399); box-shadow: 0 0 0 3px color-mix(in srgb, var(--success-text, #34d399) 22%, transparent); }
++#ch-bot-container { resize: vertical; overflow: hidden; height: clamp(230px, 36vh, 460px); min-height: 160px; max-height: 80vh; }
++#ch-bot-container iframe { display: block; width: 100%; height: 100%; border: 0; background: transparent; }
++@media (max-width: 768px) { #ch-bot-section { margin: 8px; } #ch-bot-container { height: clamp(220px, 44vh, 420px); resize: none; } }
ddiff --git a/public/css/features/home.css b/public/css/features/home.css
iindex a1e8d6e7..650a890b 100644
---- a/public/css/features/home.css
++++ b/public/css/features/home.css
@@@ -78,31 +78,50 @@
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-head { min-height: 54px; padding: 8px 12px 6px; flex-wrap: nowrap; }
--.recent-streamer-face { display: grid; place-items: center; width: 38px; height: 38px; flex: none; border-radius: 50%; overflow: hidden; background: color-mix(in srgb, var(--accent) 58%, var(--bg-tertiary)); color: var(--text-primary); font-weight: 800; }
--.recent-streamer-face img { width: 100%; height: 100%; object-fit: cover; }
--.recent-streamer-head .streamer-group-last-online.is-live { color: var(--live, #ef4444); font-weight: 800; }
--.recent-streamer-stats { position: relative; display: flex; align-items: center; gap: 9px; flex-wrap: nowrap; height: 38px; box-sizing: border-box; overflow: hidden; padding: 15px 12px 0; font-size: .75rem; line-height: 1.3; }
--.recent-streamer-cap { position: absolute; left: 12px; top: 0; font-size: .64rem; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; color: var(--text-muted); }
--.recent-streamer-stat { display: inline-flex; align-items: center; gap: 5px; white-space: nowrap; font-weight: 700; color: var(--text-primary); }
--.recent-streamer-stat i { font-size: .7rem; color: var(--accent); }
--.recent-streamer-habit { margin: 3px 12px 6px; min-height: 1.3em; font-size: .76rem; line-height: 1.3; color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
--.recent-streamer-about { position: relative; margin: 0 12px 8px; }
--.recent-streamer-summary { margin: 0; min-height: 2.8em; color: var(--text-secondary); font-size: .8rem; line-height: 1.4; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
++/* 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); }
++.recent-streamer-face img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
++.recent-streamer-face.is-live { box-shadow: 0 0 0 2px var(--bg-card), 0 0 0 4px var(--live, #ef4444); }
++.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; }
++.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.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; }
++.recent-streamer-stats { display: grid; grid-auto-flow: column; grid-auto-columns: minmax(0, 1fr); gap: 6px; padding: 0 14px 10px; }
++.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-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. */
++.recent-streamer-about.is-cut:not(.is-open) .recent-streamer-summary { -webkit-mask-image: linear-gradient(to bottom, #000 58%, transparent); mask-image: linear-gradient(to bottom, #000 58%, transparent); }
.recent-streamer-summary.expanded { display: block; }
--/* More sits on the end of the second line, over a fade into the card, not on a row of its own; its box for a finger
-- is larger than its word (::after). */
--.recent-streamer-more { position: absolute; right: 0; bottom: 0; padding: 0 0 0 30px; border: 0; color: var(--accent); font: inherit; font-size: .76rem; font-weight: 700; line-height: 1.47; cursor: pointer;
-- background: linear-gradient(90deg, transparent, var(--bg-card) 26px); }
--.recent-streamer-more::after { content: ''; position: absolute; inset: -14px -10px -12px 0; }
++/* More: its own short row under the text, at the right; its box for a finger is larger than its word (::after). */
++.recent-streamer-more { position: relative; display: block; margin: -2px 0 0 auto; padding: 0; border: 0; background: none; color: var(--accent); font: inherit; font-size: .78rem; font-weight: 700; line-height: 1.4; cursor: pointer; }
++.recent-streamer-more::after { content: ''; position: absolute; inset: -12px -10px -10px -14px; }
.recent-streamer-more[hidden] { display: none; }
--.recent-streamer-about.is-open .recent-streamer-more { position: relative; display: block; margin: 4px 0 0; padding: 0; background: none; }
++.recent-streamer-about.is-open .recent-streamer-more { margin-top: 4px; }
.streamer-group-stream-thumb.is-blank { display: grid; place-items: center; background: color-mix(in srgb, var(--accent) 10%, var(--bg-tertiary)); color: var(--text-muted); font-size: .9rem; }
--.recent-streamer-history { margin-top: auto; padding-bottom: 0; }
--.recent-stream-row { min-height: 61px; }
--.recent-stream-row .streamer-group-stream-thumb { aspect-ratio: 16/9; height: auto; }
--.recent-stream-row .streamer-group-stream-info { display: flex; flex-direction: column; }
--.streamer-group-header { padding: 12px 14px 0; }
++/* Two replays side by side: the picture with its length on it, the title under. */
++.recent-streamer-history { margin-top: auto; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; padding: 0 14px 14px; }
++.recent-streamer-history:empty { display: none; }
++.recent-stream-row { display: grid; gap: 6px; min-width: 0; color: inherit; text-decoration: none; }
++.recent-stream-thumb { position: relative; display: grid; place-items: center; aspect-ratio: 16 / 9; border-radius: 10px; overflow: hidden; background: var(--bg-tertiary); color: var(--text-muted); transition: transform var(--ov-dur-2, .15s), box-shadow var(--ov-dur-2, .15s); }
++.recent-stream-thumb img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
++.recent-stream-row:hover .recent-stream-thumb { transform: translateY(-2px); box-shadow: 0 8px 18px -10px rgba(0, 0, 0, .6); }
++.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-row:hover .recent-stream-title { color: var(--accent); }
.streamer-group-identity {
display: flex; align-items: center; gap: 8px;
text-decoration: none; color: inherit; flex-wrap: wrap;
@@@ -1499,5 +1518,19 @@ html[data-ov-motion=off] .star-petals{display: none}
[data-theme-mode="light"] .ovnet::before, html.light .ovnet::before { opacity: .35; }
html[data-ov-motion=off] .ovnet-sky i{opacity: .45}html[data-ov-motion=off] .ovnet.is-armed .ovnet-site{opacity: 1;--lift: 0px}
--/* The menu hint carries the menu's own orb (11-09: "Jump between sites… from the OV mark" pointed at the wrong button). */
--.ovnet-switch .ovnet-orb { flex: none; color: var(--accent-light, #60a5fa); }
++/* The line about the menu (11-10: it still drew the old nine dots): the menu button as it is drawn in the bar, in a
++ tile; what it is and where; an arrow that points up at it. Pressing the line lights the real button, then opens it. */
++.ovnet-cta[hidden] { display: none; }
++.ovnet-switch { gap: 12px; min-height: 60px; padding: 8px 16px 8px 10px; border-radius: 18px; text-align: left; transition: border-color .2s, background-color .2s, transform .12s; }
++.ovnet-switch:active { transform: scale(.98); }
++.ovnet-switch .ovnet-orb { flex: none; display: grid; place-items: center; width: 42px; height: 42px; border-radius: 13px; color: var(--ovnet-ink, var(--text-primary)); background: color-mix(in srgb, var(--accent, #3b82f6) 16%, transparent); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent, #3b82f6) 30%, transparent); }
++.ovnet-switch .ovnet-orb svg { display: block; overflow: visible; }
++.ovnet-switch:is(:hover, :focus-visible) .ovmb :is(.ovmb-bar, .ovmb-dot, .ovmb-app) { animation: ovmb-wave .7s ease-in-out both; }
++.ovnet-switch-t { display: grid; gap: 1px; min-width: 0; }
++.ovnet-switch-t b { font-size: .95rem; color: var(--ovnet-ink, var(--text-primary)); }
++.ovnet-switch-t small { font-size: .78rem; color: var(--ovnet-soft, var(--text-secondary)); }
++.ovnet-switch-go { flex: none; margin-left: auto; width: 9px; height: 9px; border: solid var(--accent-light, #60a5fa); border-width: 2px 2px 0 0; transform: rotate(-45deg); animation: ovnetGo 2.4s ease-in-out infinite; }
++@keyframes ovnetGo { 0%, 70%, 100% { transform: translate(0, 0) rotate(-45deg); opacity: .8; } 85% { transform: translate(0, calc(-5px * var(--ov-move, 1))) rotate(-45deg); opacity: 1; } }
++/* The real button, lit for a moment when the line is pressed. */
++.ovnav-orb.ovnet-point { animation: ovnetPoint .9s cubic-bezier(.2, .8, .2, 1); border-radius: 12px; }
++@keyframes ovnetPoint { 0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent, #3b82f6) 70%, transparent); } 100% { box-shadow: 0 0 0 16px transparent; } }
ddiff --git a/public/fragments/home.html b/public/fragments/home.html
iindex f4e22e34..5bb8c369 100644
---- a/public/fragments/home.html
++++ b/public/fragments/home.html
@@@ -367,7 +367,7 @@
<div class="ovnet-groups" data-sites="55"><div class="ovnet-group" id="ovnet-g-streams" role="group" aria-labelledby="ovnet-gh-streams" data-group="streams" style="--g:#ef4444"><h3 id="ovnet-gh-streams"><i class="fa-solid fa-tower-broadcast" aria-hidden="true"></i><span>Streams &amp; places</span><b class="ovnet-n">7</b></h3><p class="ovnet-gline">Go live, watch, share pictures and video</p><ul class="ovnet-sites" data-ovk-scroll><li><a class="ovnet-site" href="https://openvibe.live/" data-site="live" style="--i:0"><span class="ov-icon" data-icon="live" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Live</b><small>openvibe.live</small><span>Go live, watch streams</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.video/" data-site="media-hub" style="--i:1"><span class="ov-icon" data-icon="video" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Video</b><small>openvibe.video</small><span>Clips and past streams</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.pics/" data-site="pics" style="--i:2"><span class="ov-icon" data-icon="pics" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Pics</b><small>openvibe.pics</small><span>Share pictures</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.download/" data-site="download" style="--i:3"><span class="ov-icon" data-icon="download" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Download</b><small>openvibe.download</small><span>Your files</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.cam/" data-site="cam" style="--i:4"><span class="ov-icon" data-icon="cam" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Cam</b><small>openvibe.cam</small><span>Your camera, your cameras, and Live&#39;s cam button</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.tours/" data-site="tours" style="--i:5"><span class="ov-icon" data-icon="tours" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Tours</b><small>openvibe.tours</small><span>Go anywhere: fly, stay, walk, watch</span></span></a></li><li><a class="ovnet-site" href="https://openre.stream/" data-site="stream" style="--i:6"><span class="ov-icon" data-icon="stream" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Restream</b><small>openre.stream</small><span>Stream everywhere</span></span></a></li></ul></div><div class="ovnet-group" id="ovnet-g-games" role="group" aria-labelledby="ovnet-gh-games" data-group="games" style="--g:#a855f7"><h3 id="ovnet-gh-games"><i class="fa-solid fa-gamepad" aria-hidden="true"></i><span>Games &amp; laughs</span><b class="ovnet-n">6</b></h3><p class="ovnet-gline">Play in the browser, laugh, explore</p><ul class="ovnet-sites" data-ovk-scroll><li><a class="ovnet-site" href="https://openvibe.games/" data-site="games" style="--i:7"><span class="ov-icon" data-icon="games" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Games</b><small>openvibe.games</small><span>Play in your browser</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.fun/" data-site="fun" style="--i:8"><span class="ov-icon" data-icon="fun" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Fun</b><small>openvibe.fun</small><span>Something to do</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.casino/" data-site="casino" style="--i:9"><span class="ov-icon" data-icon="casino" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Casino</b><small>openvibe.casino</small><span>Learn it, play it, never pay it</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.meme/" data-site="meme" style="--i:10"><span class="ov-icon" data-icon="meme" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Meme</b><small>openvibe.meme</small><span>The internet&#39;s meme database</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.surf/" data-site="surf" style="--i:11"><span class="ov-icon" data-icon="surf" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Surf</b><small>openvibe.surf</small><span>Ride, browse, watch</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.quest/" data-site="quest" style="--i:12"><span class="ov-icon" data-icon="quest" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Quest</b><small>openvibe.quest</small><span>Earn badges</span></span></a></li></ul></div><div class="ovnet-group" id="ovnet-g-answers" role="group" aria-labelledby="ovnet-gh-answers" data-group="answers" style="--g:#f59e0b"><h3 id="ovnet-gh-answers"><i class="fa-solid fa-newspaper" aria-hidden="true"></i><span>News &amp; answers</span><b class="ovnet-n">8</b></h3><p class="ovnet-gline">News, reports, reviews and answers</p><ul class="ovnet-sites" data-ovk-scroll><li><a class="ovnet-site" href="https://openvibe.news/" data-site="news" style="--i:13"><span class="ov-icon" data-icon="news" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>News</b><small>openvibe.news</small><span>Sourced, not spun</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.report/" data-site="report" style="--i:14"><span class="ov-icon" data-icon="report" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Report</b><small>openvibe.report</small><span>Your numbers, honest</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.blog/" data-site="blog" style="--i:15"><span class="ov-icon" data-icon="blog" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Blog</b><small>openvibe.blog</small><span>Write long-form</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.today/" data-site="today" style="--i:16"><span class="ov-icon" data-icon="today" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Today</b><small>openvibe.today</small><span>Everything happening for you, today</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.reviews/" data-site="reviews" style="--i:17"><span class="ov-icon" data-icon="reviews" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Reviews</b><small>openvibe.reviews</small><span>Honest reviews</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.vision/" data-site="vision" style="--i:18"><span class="ov-icon" data-icon="vision" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Vision</b><small>openvibe.vision</small><span>Ask a picture</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.wiki/" data-site="wiki" style="--i:19"><span class="ov-icon" data-icon="wiki" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Wiki</b><small>openvibe.wiki</small><span>Facts with sources</span></span></a></li><li><a class="ovnet-site" href="https://search.openvibe.network/" data-site="search" style="--i:20"><span class="ov-icon" data-icon="search" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Search</b><small>search.openvibe.network</small><span>Search everything</span></span></a></li></ul></div><div class="ovnet-group" id="ovnet-g-make" role="group" aria-labelledby="ovnet-gh-make" data-group="make" style="--g:#3b82f6"><h3 id="ovnet-gh-make"><i class="fa-solid fa-wand-magic-sparkles" aria-hidden="true"></i><span>Make &amp; build</span><b class="ovnet-n">8</b></h3><p class="ovnet-gline">Tools, sites, bots, designs and code</p><ul class="ovnet-sites" data-ovk-scroll><li><a class="ovnet-site" href="https://openvibe.tools/" data-site="tools" style="--i:21"><span class="ov-icon" data-icon="tools" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Tools</b><small>openvibe.tools</small><span>Every online tool</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.space/" data-site="space" style="--i:22"><span class="ov-icon" data-icon="space" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Space</b><small>openvibe.space</small><span>Host a site or app</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.bot/" data-site="bot" style="--i:23"><span class="ov-icon" data-icon="bot" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Bot</b><small>openvibe.bot</small><span>Drive a robot</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.design/" data-site="design" style="--i:24"><span class="ov-icon" data-icon="design" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Design</b><small>openvibe.design</small><span>Make it yours</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.host/" data-site="host" style="--i:25"><span class="ov-icon" data-icon="host" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Host</b><small>openvibe.host</small><span>Compare hosting</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.domains/" data-site="domains" style="--i:26"><span class="ov-icon" data-icon="domains" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Domains</b><small>openvibe.domains</small><span>Name it, price it, run it</span></span></a></li><li><a class="ovnet-site" href="https://connect.openvibe.website/" data-site="connect" style="--i:27"><span class="ov-icon" data-icon="connect" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Connect</b><small>connect.openvibe.website</small><span>Your computer, on the web, privately</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.codes/" data-site="codes" style="--i:28"><span class="ov-icon" data-icon="codes" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Codes</b><small>openvibe.codes</small><span>Code with any agent</span></span></a></li></ul></div><div class="ovnet-group" id="ovnet-g-shop" role="group" aria-labelledby="ovnet-gh-shop" data-group="shop" style="--g:#22c55e"><h3 id="ovnet-gh-shop"><i class="fa-solid fa-basket-shopping" aria-hidden="true"></i><span>Shop &amp; swap</span><b class="ovnet-n">8</b></h3><p class="ovnet-gline">Deals, jobs, food, homes and trades</p><ul class="ovnet-sites" data-ovk-scroll><li><a class="ovnet-site" href="https://openvibe.coupons/" data-site="coupons" style="--i:29"><span class="ov-icon" data-icon="coupons" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Coupons</b><small>openvibe.coupons</small><span>Codes that work</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.work/" data-site="work" style="--i:30"><span class="ov-icon" data-icon="work" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Work</b><small>openvibe.work</small><span>Find a job</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.deals/" data-site="deals" style="--i:31"><span class="ov-icon" data-icon="deals" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Deals</b><small>openvibe.deals</small><span>Deals people share</span></span></a></li><li><a class="ovnet-site" href="https://open.shoes/" data-site="shoes" style="--i:32"><span class="ov-icon" data-icon="shoes" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Shoes</b><small>open.shoes</small><span>Every shoe, every market, the right price</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.trade/" data-site="trade" style="--i:33"><span class="ov-icon" data-icon="trade" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Trade</b><small>openvibe.trade</small><span>Watch the markets</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.food/" data-site="food" style="--i:34"><span class="ov-icon" data-icon="food" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Food</b><small>openvibe.food</small><span>Food near you</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.rent/" data-site="rent" style="--i:35"><span class="ov-icon" data-icon="rent" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Rent</b><small>openvibe.rent</small><span>Places to rent</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.homes/" data-site="homes" style="--i:36"><span class="ov-icon" data-icon="homes" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Homes</b><small>openvibe.homes</small><span>Homes for sale</span></span></a></li></ul></div><div class="ovnet-group" id="ovnet-g-people" role="group" aria-labelledby="ovnet-gh-people" data-group="people" style="--g:#ec4899"><h3 id="ovnet-gh-people"><i class="fa-solid fa-user-group" aria-hidden="true"></i><span>People &amp; you</span><b class="ovnet-n">7</b></h3><p class="ovnet-gline">Your people, your account, your stuff</p><ul class="ovnet-sites" data-ovk-scroll><li><a class="ovnet-site" href="https://openvibe.community/" data-site="community" style="--i:37"><span class="ov-icon" data-icon="community" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Community</b><small>openvibe.community</small><span>Posts and pastes</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.network/" data-site="network" style="--i:38"><span class="ov-icon" data-icon="network" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Network</b><small>openvibe.network</small><span>Your account</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.chat/" data-site="chat" style="--i:39"><span class="ov-icon" data-icon="chat" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Chat</b><small>openvibe.chat</small><span>Chat with anyone</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.tips/" data-site="tips" style="--i:40"><span class="ov-icon" data-icon="tips" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Tips</b><small>openvibe.tips</small><span>Support streamers</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.vip/" data-site="vip" style="--i:41"><span class="ov-icon" data-icon="vip" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>VIP</b><small>openvibe.vip</small><span>Back your creators</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.help/" data-site="help" style="--i:42"><span class="ov-icon" data-icon="help" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Help</b><small>openvibe.help</small><span>Help nearby</span></span></a></li><li><a class="ovnet-site" href="https://inventory.openvibe.network/" data-site="inventory" style="--i:43"><span class="ov-icon" data-icon="inventory" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Inventory</b><small>inventory.openvibe.network</small><span>Your items</span></span></a></li></ul></div><div class="ovnet-group" id="ovnet-g-build" role="group" aria-labelledby="ovnet-gh-build" data-group="build" style="--g:#14b8a6"><h3 id="ovnet-gh-build"><i class="fa-solid fa-code" aria-hidden="true"></i><span>For developers</span><b class="ovnet-n">11</b></h3><p class="ovnet-gline">APIs and services to build on</p><ul class="ovnet-sites" data-ovk-scroll><li><a class="ovnet-site" href="https://openvibe.media/" data-site="media" style="--i:44"><span class="ov-icon" data-icon="media" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Media</b><small>openvibe.media</small><span>Media API</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.exchange/" data-site="exchange" style="--i:45"><span class="ov-icon" data-icon="exchange" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Exchange</b><small>openvibe.exchange</small><span>AI models, one account</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.zone/" data-site="zone" style="--i:46"><span class="ov-icon" data-icon="zone" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Zone</b><small>openvibe.zone</small><span>S3 storage</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.services/" data-site="services" style="--i:47"><span class="ov-icon" data-icon="services" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Services</b><small>openvibe.services</small><span>APIs, SDK and keys</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.actor/" data-site="actor" style="--i:48"><span class="ov-icon" data-icon="actor" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Actor</b><small>openvibe.actor</small><span>The agent engine</span></span></a></li><li><a class="ovnet-site" href="https://ai.openvibe.services/" data-site="ai" style="--i:49"><span class="ov-icon" data-icon="ai" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>AI</b><small>ai.openvibe.services</small><span>AI router, your keys</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.events/" data-site="events" style="--i:50"><span class="ov-icon" data-icon="events" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Events</b><small>openvibe.events</small><span>Event streams</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.watch/" data-site="watch" style="--i:51"><span class="ov-icon" data-icon="watch" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Watch</b><small>openvibe.watch</small><span>Know when it changes</span></span></a></li><li><a class="ovnet-site" href="https://play.openvibe.network/" data-site="play" style="--i:52"><span class="ov-icon" data-icon="play" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Play</b><small>play.openvibe.network</small><span>Game services</span></span></a></li><li><a class="ovnet-site" href="https://openvibe.run/" data-site="run" style="--i:53"><span class="ov-icon" data-icon="run" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Run</b><small>openvibe.run</small><span>Run jobs anywhere</span></span></a></li><li><a class="ovnet-site" href="https://bench.openvibe.services/" data-site="bench" style="--i:54"><span class="ov-icon" data-icon="bench" data-size="40" aria-hidden="true"></span><span class="ovnet-txt"><b>Bench</b><small>bench.openvibe.services</small><span>Which AI is best</span></span></a></li></ul></div></div>
<footer class="ovnet-foot">
<a class="ovnet-cta" href="/api/auth/sso/login?next=%2F" data-ovnet-signin><i class="fa-solid fa-user-plus" aria-hidden="true"></i> Create your free account</a>
-- <button type="button" class="ovnet-switch" data-ovnet-switcher><svg class="ovnet-orb" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"><g fill="currentColor"><circle cx="12" cy="12" r="2.5"/><circle cx="12" cy="5" r="2.15"/><circle cx="19" cy="12" r="2.15"/><circle cx="12" cy="19" r="2.15"/><circle cx="5" cy="12" r="2.15"/><circle cx="5" cy="5" r="2.15" opacity=".55"/><circle cx="19" cy="5" r="2.15" opacity=".55"/><circle cx="19" cy="19" r="2.15" opacity=".55"/><circle cx="5" cy="19" r="2.15" opacity=".55"/></g></svg> Every site is one tap away in the <b>menu</b></button>
++ <button type="button" class="ovnet-switch" data-ovnet-switcher><span class="ovnet-orb" aria-hidden="true"><svg class="ovmb" viewBox="0 0 24 24" width="22" height="22"><rect class="ovmb-bar" x="4" y="5.2" width="16" height="5.6" rx="2.8"/><circle class="ovmb-dot" cx="7.4" cy="8" r="1.15"/><rect class="ovmb-app ovmb-a0" x="4.2" y="14.6" width="4" height="4" rx="1.3"/><rect class="ovmb-app ovmb-a1" x="10" y="14.6" width="4" height="4" rx="1.3"/><rect class="ovmb-app ovmb-a2" x="15.8" y="14.6" width="4" height="4" rx="1.3"/></svg></span><span class="ovnet-switch-t"><b>Every site is in the menu</b><small>This button, at the top of every page</small></span><i class="ovnet-switch-go" aria-hidden="true"></i></button>
</footer>
<!--/ov:network--></div>
ddiff --git a/public/js/app-channel.js b/public/js/app-channel.js
iindex 9e2db8c2..14e7ea89 100644
---- a/public/js/app-channel.js
++++ b/public/js/app-channel.js
@@@ -2213,7 +2213,11 @@ function _markChannelJump(tab, fromTap = false) {
if (Math.abs(target - strip.scrollLeft) > 2) strip.scrollTo({ left: target, behavior: _channelReduceMotion() ? 'auto' : 'smooth' });
}
_updateChannelTabArrows();
-- if (fromTap || location.hash !== `#${tab}`) history.replaceState(history.state, '', `${location.pathname}${location.search}#${tab}`);
++ // The address names the section in view once the person is among the sections (or tapped one); at the top of the
++ // page it stays clean. What is written here is remembered, so the channel's next load does not take it as a link to
++ // that section and scroll there (11-10: the page jumped to About by itself, with #about in the address).
++ const want = fromTap || window.scrollY > 120 ? `#${tab}` : '';
++ if (location.hash !== want) { _chHashWritten = want; history.replaceState(history.state, '', `${location.pathname}${location.search}${want}`); }
}
function _updateChannelTabArrows() {
const strip = document.getElementById('ch-tabs');
@@@ -2359,9 +2363,11 @@ async function _loadGameBadge(username) {
// A URL hash like #ai-timeline / #about / #videos auto-opens that channel tab on load and
// scrolls the tab content into view (used by "view full AI timeline" links, deep links, etc.).
const CHANNEL_TAB_HASHES = ['about', 'videos', 'agents', 'clips', 'clips-taken', 'pastes', 'media', 'analytics', 'ai-timeline'];
++let _chHashWritten = ''; // the last section this page wrote into the address itself (not a link somebody followed)
function _applyChannelHashTab() {
const h = (location.hash || '').replace(/^#/, '').toLowerCase();
if (!h || !CHANNEL_TAB_HASHES.includes(h)) return;
++ if (_chHashWritten === `#${h}`) return;
const btn = document.querySelector(`#ch-tabs .ch-tab[data-tab="${h}"]`);
if (!btn || btn.style.display === 'none') return; // tab hidden / not present for this channel
// Jump to the section once it has had a beat to render its content.
ddiff --git a/public/js/app-home.js b/public/js/app-home.js
iindex fc156e16..3ad8cbac 100644
---- a/public/js/app-home.js
++++ b/public/js/app-home.js
@@@ -1269,8 +1269,13 @@ function initHomeNetwork() {
}
if (e.target.closest('[data-ovnet-switcher]')) {
// The OpenVibe menu (the orb at the top right) holds every site.
-- const menu = document.getElementById('openvibe-burger') || document.getElementById('openvibe-search-btn') || document.getElementById('openvibe-switch-btn');
-- if (menu) menu.click();
++ const visible = (el) => el && el.getBoundingClientRect().width > 0;
++ const menu = [document.getElementById('openvibe-burger'), document.getElementById('openvibe-search-btn'), document.getElementById('openvibe-switch-btn')].find(visible);
++ if (!menu) return;
++ // Show where the button is, then press it: the next time, the person knows where to go.
++ menu.classList.add('ovnet-point');
++ setTimeout(() => { menu.classList.remove('ovnet-point'); }, 950);
++ setTimeout(() => menu.click(), document.documentElement.dataset.ovMotion === 'off' ? 0 : 320);
}
});
// The first time it nears the screen: tiles rise in, the count counts up, the ring icons load.
@@@ -1503,36 +1508,48 @@ function renderRecentlyOnline(containerId, streamers, append) {
const facts = s.recent_facts || {};
const channelHref = '/@' + encodeURIComponent(s.username || '');
const card = el('article', 'streamer-group-card recent-streamer-card');
-- const head = el('a', 'streamer-group-identity recent-streamer-head');
-- head.href = channelHref;
-- const face = el('span', 'recent-streamer-face', (s.display_name || s.username || '?').charAt(0).toUpperCase());
++ const head = el('header', 'streamer-group-identity recent-streamer-head');
++ // The picture opens the person's menu, as a name in chat does (ovUserMenu); the name is the way to the channel.
++ const shown = s.display_name || s.username || '?';
++ const face = el('span', 'recent-streamer-face' + (s.live ? ' is-live' : ''), shown.charAt(0).toUpperCase());
++ face.setAttribute('role', 'button'); face.tabIndex = 0; face.title = shown + ': open menu';
++ face.dataset.username = shown; face.dataset.coreUsername = s.username || ''; face.dataset.userId = String(s.user_id ?? s.id ?? '');
++ face.addEventListener('click', (e) => ovUserMenu(e));
++ face.addEventListener('contextmenu', (e) => ovUserMenu(e));
++ face.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); ovUserMenu(e); } });
if (s.avatar_url) {
-- const img = el('img'); img.src = s.avatar_url; img.alt = ''; img.loading = 'lazy';
++ const img = el('img'); img.src = s.avatar_url; img.alt = ''; img.loading = 'lazy'; img.decoding = 'async';
img.onerror = () => img.remove(); face.append(img);
}
-- const name = el('strong', 'streamer-group-name', s.display_name || s.username);
++ const who = el('span', 'recent-streamer-who');
++ const name = el('a', 'streamer-group-name', shown);
++ 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);
-- head.append(face, name, when);
++ 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));
++ head.append(face, who);
card.append(head);
-- // The same three places for everyone, so a card with little to say is not a card with a hole in it: this
-- // week in numbers (each with its icon), when they are usually on, and two lines about the channel.
++ // This week, as even tiles: a number and what it counts. One with nothing to say is not drawn; the rest share
++ // the row, so a card with one number is not a card with two holes.
const stats = el('div', 'recent-streamer-stats');
-- const stat = (icon, value, label) => {
++ const stat = (icon, value, unit, label) => {
if (!value) return;
const n = el('span', 'recent-streamer-stat');
n.title = label;
const i = el('i', 'fa-solid ' + icon); i.setAttribute('aria-hidden', 'true');
-- n.append(i, el('span', null, value));
++ n.append(i, el('b', null, value), el('small', null, unit));
stats.append(n);
};
-- stat('fa-tower-broadcast', facts.streams_this_week ? 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' : '', 'Hours live this week');
-- stat('fa-fire', facts.days_in_a_row >= 3 ? facts.days_in_a_row + ' days' : '', 'Days in a row');
-- if (stats.childElementCount) stats.prepend(el('span', 'recent-streamer-cap', 'This week'));
-- card.append(stats);
-- const lastLine = facts.last_stream_seconds ? 'Last stream ' + formatDuration(facts.last_stream_seconds) : '';
-- card.append(el('p', 'recent-streamer-habit', [facts.usual_time, lastLine].filter(Boolean).join(' · ')));
++ 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');
++ 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');
++ if (week) card.append(el('p', 'recent-streamer-cap', 'This week'));
++ if (stats.childElementCount) card.append(stats);
const overview = String(s.ai_overview_short || s.ai_overview || '').trim();
const about = el('div', 'recent-streamer-about');
const summary = el('p', 'recent-streamer-summary', overview);
@@@ -1547,33 +1564,31 @@ function renderRecentlyOnline(containerId, streamers, append) {
more.setAttribute('aria-expanded', String(open));
});
about.append(more);
-- // "More" only where the two lines cut something off; it sits on the end of the second line.
-- requestAnimationFrame(() => { more.hidden = summary.scrollHeight <= summary.clientHeight + 2; });
++ // "More" only where the three lines cut something off; the text fades out there (.is-cut) and More is under it.
++ requestAnimationFrame(() => { const cut = summary.scrollHeight > summary.clientHeight + 2; more.hidden = !cut; about.classList.toggle('is-cut', cut); });
}
card.append(about);
-- const history = el('div', 'streamer-group-streams recent-streamer-history');
++ const history = el('div', 'recent-streamer-history');
for (const h of (facts.streams || []).slice(0, 2)) {
const replay = !h.is_live && h.vod_id;
-- const row = el('a', 'streamer-group-stream recent-stream-row');
++ const row = el('a', 'recent-stream-row' + (h.is_live ? ' is-live' : ''));
row.href = replay ? videoHref('vod', h.vod_id) : channelHref;
-- // A picture on every row: the replay's own, else the one Live can make from the recording, else a mark
-- // that says what the row is. Never an empty box.
-- const thumb = el('span', 'streamer-group-stream-thumb');
++ // A picture on every one: the replay's own, else the one Live can make from the recording, else a mark
++ // that says what it is. Never an empty box.
++ const thumb = el('span', 'recent-stream-thumb');
const made = replay ? `/api/thumbnails/generate/vod/${encodeURIComponent(h.vod_id)}` : null;
const src = _sgThumbSrc(h.thumbnail_url) || made;
-- const blank = () => { thumb.replaceChildren(); thumb.classList.add('is-blank'); const i = el('i', 'fa-solid ' + (h.is_live ? 'fa-tower-broadcast' : 'fa-play')); i.setAttribute('aria-hidden', 'true'); thumb.append(i); };
++ const blank = () => { thumb.querySelectorAll('img, i').forEach((n) => n.remove()); thumb.classList.add('is-blank'); const i = el('i', 'fa-solid ' + (h.is_live ? 'fa-tower-broadcast' : 'fa-play')); i.setAttribute('aria-hidden', 'true'); thumb.prepend(i); };
if (src) {
-- const img = el('img', 'streamer-group-stream-thumb-img');
++ const img = el('img');
img.alt = ''; img.loading = 'lazy'; img.decoding = 'async';
img.onerror = () => { if (made && img.dataset.made !== '1') { img.dataset.made = '1'; img.src = made; } else blank(); };
if (src === made) img.dataset.made = '1';
img.src = src;
thumb.append(img);
} else blank();
-- const info = el('span', 'streamer-group-stream-info');
-- info.append(el('span', 'streamer-group-stream-title', h.title || 'Stream'));
-- info.append(el('span', 'streamer-group-stream-time muted', (h.is_live ? 'Live' : 'Replay') + (h.length_seconds ? ' · ' + formatDuration(h.length_seconds) : '')));
-- row.append(thumb, info);
++ thumb.append(el('span', 'recent-stream-tag', (h.is_live ? 'Live' : 'Replay') + (h.length_seconds ? ' · ' + formatDuration(h.length_seconds) : '')));
++ row.append(thumb, el('span', 'recent-stream-title', h.title || 'Stream'));
history.append(row);
}
card.append(history);
ddiff --git a/scripts/build-network-section.js b/scripts/build-network-section.js
iindex 12a5b03f..46f512c2 100644
---- a/scripts/build-network-section.js
++++ b/scripts/build-network-section.js
@@@ -103,7 +103,7 @@ function render(frame) {
<div class="ovnet-groups" data-sites="${sites.length}">${blocks}</div>
<footer class="ovnet-foot">
<a class="ovnet-cta" href="/api/auth/sso/login?next=%2F" data-ovnet-signin><i class="fa-solid fa-user-plus" aria-hidden="true"></i> Create your free account</a>
-- <button type="button" class="ovnet-switch" data-ovnet-switcher><svg class="ovnet-orb" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"><g fill="currentColor"><circle cx="12" cy="12" r="2.5"/><circle cx="12" cy="5" r="2.15"/><circle cx="19" cy="12" r="2.15"/><circle cx="12" cy="19" r="2.15"/><circle cx="5" cy="12" r="2.15"/><circle cx="5" cy="5" r="2.15" opacity=".55"/><circle cx="19" cy="5" r="2.15" opacity=".55"/><circle cx="19" cy="19" r="2.15" opacity=".55"/><circle cx="5" cy="19" r="2.15" opacity=".55"/></g></svg> Every site is one tap away in the <b>menu</b></button>
++ <button type="button" class="ovnet-switch" data-ovnet-switcher><span class="ovnet-orb" aria-hidden="true"><svg class="ovmb" viewBox="0 0 24 24" width="22" height="22"><rect class="ovmb-bar" x="4" y="5.2" width="16" height="5.6" rx="2.8"/><circle class="ovmb-dot" cx="7.4" cy="8" r="1.15"/><rect class="ovmb-app ovmb-a0" x="4.2" y="14.6" width="4" height="4" rx="1.3"/><rect class="ovmb-app ovmb-a1" x="10" y="14.6" width="4" height="4" rx="1.3"/><rect class="ovmb-app ovmb-a2" x="15.8" y="14.6" width="4" height="4" rx="1.3"/></svg></span><span class="ovnet-switch-t"><b>Every site is in the menu</b><small>This button, at the top of every page</small></span><i class="ovnet-switch-go" aria-hidden="true"></i></button>
</footer>
`;
}
ddiff --git a/test/home-tabs.test.js b/test/home-tabs.test.js
iindex 52daf4e4..925fc09c 100644
---- a/test/home-tabs.test.js
++++ b/test/home-tabs.test.js
@@@ -134,6 +134,7 @@ check('the OpenVibe network is its own section: every site a real link, by group
for (const g of groups) assert.ok(net.includes(`<a href="#ovnet-g-${g}"`), `a jump link to ${g}`);
assert.ok(/<span class="ov-icon" data-icon="[a-z0-9-]+" data-size="40" aria-hidden="true"><\/span>/.test(net), 'ring icons are drawn later, not inlined');
assert.ok(net.includes('data-ovnet-switcher') && net.includes('data-ovnet-signin'), 'the switcher button and the sign-up');
++ assert.ok(/data-ovnet-switcher><span class="ovnet-orb" aria-hidden="true"><svg class="ovmb"[^>]*><rect class="ovmb-bar"/.test(net) && net.includes('<b>Every site is in the menu</b>') && !/one tap away in the/.test(net), 'the line about the menu carries the menu button as the bar draws it now, and says where it is');
});
check('the network section is built by its script, between its markers, and nothing in it is a tab', () => {