openvibers/openvibe.live Commits
Following in the topics row; the picked topic remembered (#525)
Changes
@@@ -473,6 +473,8 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }.home-topic-n { font-size: 0.72rem; font-weight: 700; color: var(--text-muted); font-variant-numeric: tabular-nums; }.home-topic[aria-pressed="true"] { background: var(--accent); border-color: var(--accent); color: var(--on-accent, #fff); }.home-topic[aria-pressed="true"] .home-topic-n { color: inherit; opacity: 0.8; }++/* Following: channels the viewer follows that are live now, marked with the live dot. */++.home-topic[data-topic="@following"]::before { content: ''; width: 7px; height: 7px; border-radius: 50%; background: var(--live-red, #ef4444); }@media (pointer: coarse) { .home-topic { min-height: 44px; } }.stream-card-metaright { margin-left: auto; display: inline-flex; align-items: center; gap: 12px; color: var(--text-muted); font-size: 0.75rem; }.stream-card-uptime, .stream-card-vcount { display: inline-flex; align-items: center; white-space: nowrap; }@@@ -1783,20 +1783,45 @@ function _streamTopics(s) {return out;}++// The chip a viewer picked comes back the next time it is live (market outcome 2: a default set), in this browser++// only; _homeTopic is what is applied now, which is All while the picked topic is off air.++const _TOPIC_KEY = 'ov_home_topic';++let _homeTopicPref = (() => { try { return (typeof localStorage !== 'undefined' && localStorage.getItem(_TOPIC_KEY)) || ''; } catch { return ''; } })();let _homeTopic = '';--/** The chip row over the live grid: shown when at least two streams are live and they have two topics or more. */++// Following (market outcome 3): the live channels the signed-in viewer follows, from GET /api/streams/following++// (private, never cached by the edge), read at most once a minute. A chip of its own, first after All.++const _FOLLOWING = '@following';++let _homeFollowing = { at: 0, ids: new Set(), busy: false };++let _homeLastStreams = [];++function _homeFollowingRefresh() {++ const me = typeof currentUser !== 'undefined' && currentUser;++ if (!me || typeof api !== 'function') { if (!me) _homeFollowing = { at: 0, ids: new Set(), busy: false }; return; }++ if (_homeFollowing.busy || Date.now() - _homeFollowing.at < 60000) return;++ _homeFollowing.busy = true;++ api('/streams/following').then((d) => {++ _homeFollowing = { at: Date.now(), ids: new Set(((d && d.user_ids) || []).map(Number)), busy: false };++ _homeTopicsSync(_homeLastStreams);++ }).catch(() => { _homeFollowing = { ..._homeFollowing, at: Date.now(), busy: false }; });++}++/** The chip row over the live grid: shown when at least two streams are live and they have two topics or more, or++ * some of them are channels the viewer follows. */function _homeTopicsSync(streams) {const row = document.getElementById('home-topics');if (!row) return;++ _homeLastStreams = streams;++ _homeFollowingRefresh();const counts = new Map();for (const s of streams) for (const t of _streamTopics(s)) counts.set(t, (counts.get(t) || 0) + 1);-- const show = streams.length >= 2 && counts.size >= 2;++ const followingN = streams.filter((s) => _homeFollowing.ids.has(Number(s.user_id))).length;++ const show = streams.length >= 2 && (counts.size >= 2 || followingN > 0);row.hidden = !show;if (!show) { _homeTopic = ''; _homeTopicsApply(); return; }-- if (_homeTopic && !counts.has(_homeTopic)) _homeTopic = '';++ const available = new Set(counts.keys());++ if (followingN) available.add(_FOLLOWING);++ _homeTopic = available.has(_homeTopicPref) ? _homeTopicPref : '';// Most streams first, then A to Z; the row scrolls sideways, so every topic stays reachable.const list = [...counts].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));-- const key = `${streams.length}|${list.map(([t, n]) => `${t}:${n}`).join(',')}`;++ const key = `${streams.length}|${followingN}|${list.map(([t, n]) => `${t}:${n}`).join(',')}`;if (row.dataset.key !== key) {row.dataset.key = key;const chip = (topic, label, n) => {@@@ -1812,6 +1837,7 @@ function _homeTopicsSync(streams) {return b;};const nodes = [chip('', 'All', streams.length)];++ if (followingN) nodes.push(chip(_FOLLOWING, 'Following', followingN));for (const [t, n] of list) nodes.push(chip(t, _capTag(t), n));row.replaceChildren(...nodes);if (!row._ovBound) {@@@ -1819,7 +1845,10 @@ function _homeTopicsSync(streams) {row.addEventListener('click', (e) => {const b = e.target.closest('.home-topic');if (!b) return;-- _homeTopic = b.dataset.topic === _homeTopic ? '' : b.dataset.topic;++ const next = b.dataset.topic === _homeTopic ? '' : b.dataset.topic;++ _homeTopicPref = next;++ _homeTopic = next;++ try { if (next) localStorage.setItem(_TOPIC_KEY, next); else localStorage.removeItem(_TOPIC_KEY); } catch { /* private mode */ }_homeTopicsApply();});}@@@ -1832,7 +1861,10 @@ function _homeTopicsApply() {const grid = document.getElementById('stream-grid-live');if (!grid) return;grid.querySelectorAll('.stream-card[data-stream-id]').forEach((card) => {-- card.hidden = !!_homeTopic && !(card.dataset.topics || '').split('|').includes(_homeTopic);++ const keep = !_homeTopic || (_homeTopic === _FOLLOWING++ ? _homeFollowing.ids.has(Number(card.dataset.user))++ : (card.dataset.topics || '').split('|').includes(_homeTopic));++ card.hidden = !keep;});}@@@ -1853,7 +1885,7 @@ function streamCardHTML(s, isLive) {const cat = String(s.category || '').replace(/[-_]+/g, ' ').trim().toLowerCase();const shown = topics.filter((t) => t !== cat).slice(0, 2);return `-- <a class="stream-card"${isLive ? ` data-topics="${esc(topics.join('|'))}"` : ''} data-stream-id="${esc(String(s.id || ''))}" href="${esc(navUrl)}" onclick="return handleLinkClick(event, '${esc(navUrl)}')">++ <a class="stream-card"${isLive ? ` data-topics="${esc(topics.join('|'))}" data-user="${esc(String(s.user_id || ''))}"` : ''} data-stream-id="${esc(String(s.id || ''))}" href="${esc(navUrl)}" onclick="return handleLinkClick(event, '${esc(navUrl)}')"><div class="stream-card-thumb${s.is_nsfw ? ' stream-card-nsfw-blur' : ''}" style="--thumb-tint:${esc(_thumbTint(s))}">${thumbImg(thumb, 'fa-circle-nodes', s.title, !isLive && s.vod_id ? `/api/thumbnails/generate/vod/${s.vod_id}` : null)}${streamTypeBadge(s.browser_mode, s.streaming_method)}@@@ -22,7 +22,7 @@ const BUDGETS = {htmlRawKB: 128, // 119.3 with the icons drawn on the server (2026-11-01, plan L54 K4: the OpenVibe glyphs in the HTML, 20.8 KB brotli); measured 94.9 beforehtmlBrotliKB: 30,jsFiles: 26, // measured 23 (incl. route scripts for "/")-- jsRawKB: 1251, // 1250.2 (2026-11-01: topics live now, readable chat names on light themes); 1247.7 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 1244.3 with openvibe-shared 4.66.0: the mark with depth and motion, ov-fx loaded on request (2026-10-31); 1243.5 with openvibe-shared 4.64.0: the look travels between sites, the Look panel in the menu (2026-10-31); 1242.7 with openvibe-shared 4.59.0: the Pair mark beside the name, set in the brand face, and the ring on page moves (2026-10-31); 1238.8 with the home's featured stream once and the intro closed on phones (2026-10-31); 1237.4 with the home's two more tab blocks on phones (2026-10-30); 1233.9 with the home's Catch up tabs on phones (2026-10-30); 1230.8 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 1227.8 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 1223.5 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 1215.4 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); measured 1203.9 (2026-10-28: openvibe-shared 4.30.0, the bell center: tabs with counts, the Unread filter, thumbnails, swipe, the phone sheet; 4.31.0's ✦ adds about 3 KB); 1187.2 (2026-10-28: openvibe-shared 4.27.0, Notifications 2.0: opaque themed toasts held while a menu is open, grouping, priority, System cards and the rich bell panel); 1158.8 (2026-10-28: openvibe-shared 4.25.0, the menu's orb, messages, the phone sheet and auto-hide in the navbar; the messenger opens from the bar)++ jsRawKB: 1253, // 1252.3 (2026-11-01: Following in the topics row, the picked topic remembered); 1250.2 (2026-11-01: topics live now, readable chat names on light themes); 1247.7 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 1244.3 with openvibe-shared 4.66.0: the mark with depth and motion, ov-fx loaded on request (2026-10-31); 1243.5 with openvibe-shared 4.64.0: the look travels between sites, the Look panel in the menu (2026-10-31); 1242.7 with openvibe-shared 4.59.0: the Pair mark beside the name, set in the brand face, and the ring on page moves (2026-10-31); 1238.8 with the home's featured stream once and the intro closed on phones (2026-10-31); 1237.4 with the home's two more tab blocks on phones (2026-10-30); 1233.9 with the home's Catch up tabs on phones (2026-10-30); 1230.8 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 1227.8 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 1223.5 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 1215.4 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); measured 1203.9 (2026-10-28: openvibe-shared 4.30.0, the bell center: tabs with counts, the Unread filter, thumbnails, swipe, the phone sheet; 4.31.0's ✦ adds about 3 KB); 1187.2 (2026-10-28: openvibe-shared 4.27.0, Notifications 2.0: opaque themed toasts held while a menu is open, grouping, priority, System cards and the rich bell panel); 1158.8 (2026-10-28: openvibe-shared 4.25.0, the menu's orb, messages, the phone sheet and auto-hide in the navbar; the messenger opens from the bar)jsBrotliKB: 293.3, // 292.6 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 291.1 with openvibe-shared 4.57.1 (the Dock's async token and scope-aware controls, 4.57.0; the tool frame's facts, 4.56.0) (2026-10-31); 290.6 with the home's two more tab blocks on phones (2026-10-30); 289.8 with the home's Catch up tabs on phones (2026-10-30); 288.2 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 287.4 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); measured 283.2 (2026-10-28: openvibe-shared 4.30.0, the bell center as above); 279.6 (2026-10-28: openvibe-shared 4.27.0, Notifications 2.0 as above); 273.1 (2026-10-28: openvibe-shared 4.25.0 as above); 270.5 (2026-10-28: openvibe-shared 4.22.0, the navbar's search magnifier with / and Ctrl+K and the More menu; search.js and loading.js load only when used); 269.6 (2026-10-28: openvibe-shared 4.19.0, the account menu's gear and people-group icons inline; 268 on 2026-10-27: openvibe-shared 4.16.0, the folded balance chip, Across OpenVibe with site marks and the icon preload in the navbar); 265.3 (2026-10-27: openvibe-shared 4.15.0, the compact footer opens into the whole one, and the pulse line); 263.3 on 10-23 (openvibe-shared 4.9.0, status text twins in every theme); 262.5 on 10-17 (openvibe-shared 4.4.0, every site in the launcher)cssRawKB: 270, // measured ~243 since the first-paint subset (style.core.css) replaced style.css (~717 before)cssBrotliKB: 55, // measured ~47.5 (was ~130)@@@ -1222,6 +1222,21 @@ router.get('/', optionalAuth, async (req, res) => {});// ── Tags In Use (the browse filters) ─────────────────────────++// ── The Live Channels I Follow (the home's Following chip) ─────++// Ids only, of channels live right now that the signed-in viewer follows (Live's projection of Network's follows).++// Private: never cached by a browser or the edge.++router.get('/following', requireAuth, async (req, res) => {++ res.set('Cache-Control', 'private, no-store');++ try {++ const rows = await db.all(`SELECT DISTINCT s.user_id FROM streams s JOIN follows f ON f.streamer_id = s.user_id++ WHERE f.follower_id = ? AND s.is_live = 1`, [req.user.id]);++ res.json({ user_ids: rows.map((r) => Number(r.user_id)) });++ } catch (err) {++ console.error('[Streams] Following error:', err.message);++ res.status(500).json({ error: 'Failed to read the channels you follow' });++ }++});++router.get('/tags', async (req, res) => {try {// The tag set changes slowly and is the same for everyone (like the stream list, with a longer window).@@@ -27,9 +27,10 @@ function fakeDom(cardTopics) {};return { document, row, chips, cards };}--function load(dom) {++function load(dom, { currentUser, api } = {}) {const _capTag = (s) => String(s).split(' ').map((w) => w[0].toUpperCase() + w.slice(1)).join(' ');-- return new Function('document', '_capTag', `${block}; return { _streamTopics, _homeTopicsSync, _homeTopicsApply, get topic() { return _homeTopic; } };`)(dom.document, _capTag);++ return new Function('document', '_capTag', 'currentUser', 'api', `${block}; return { _streamTopics, _homeTopicsSync, _homeTopicsApply, get topic() { return _homeTopic; },++ setFollowing(ids) { _homeFollowing = { at: Date.now(), ids: new Set(ids), busy: false }; } };`)(dom.document, _capTag, currentUser, api);}// A stream's topics: category first, then its own tags and the AI's, normalized, meta words dropped.@@@ -78,8 +79,32 @@ const streams = [assert.strictEqual(m.topic, '', 'no stream has it any more');}++// Following (market outcome 3): the live channels the signed-in viewer follows are a chip of their own, first after++// All, and it filters by the card's user.++{++ const dom = fakeDom(['gaming|fortnite', 'gaming|league', 'outdoors|hiking']);++ dom.cards.forEach((c, i) => { c.dataset.user = String(10 + i); });++ const m = load(dom, { currentUser: { id: 1 }, api: () => new Promise(() => {}) });++ m.setFollowing([10, 12]);++ m._homeTopicsSync(streams.map((s, i) => ({ ...s, user_id: 10 + i })));++ assert.deepStrictEqual(dom.chips.slice(0, 2).map((c) => c.dataset.topic), ['', '@following']);++ assert.strictEqual(dom.chips[1].children[2].textContent, '2', 'two followed channels are live');++ dom.row.onclick({ target: { closest: () => dom.chips[1] } });++ assert.deepStrictEqual(dom.cards.map((c) => c.hidden), [false, true, false], 'only the followed channels stay');++ // Signed out, there is no Following chip.++ const out = fakeDom(['gaming|fortnite', 'gaming|league', 'outdoors|hiking']);++ load(out)._homeTopicsSync(streams.map((s, i) => ({ ...s, user_id: 10 + i })));++ assert.ok(!out.chips.some((c) => c.dataset.topic === '@following'));++}++// The card carries its topics for the filter, and the row sits over the grid, hidden until filled.assert.match(src, /data-topics="\$\{esc\(topics\.join\('\|'\)\)\}"/, 'the card names its topics');++assert.match(src, /data-user="\$\{esc\(String\(s\.user_id \|\| ''\)\)\}"/, 'and its channel, for Following');++// The picked chip is remembered in this browser and comes back when that topic is live again.++assert.match(block, /localStorage\.setItem\(_TOPIC_KEY, next\)/);++const routes = fs.readFileSync(path.join(__dirname, '..', 'server', 'streaming', 'routes.js'), 'utf8');++assert.match(routes, /router\.get\('\/following', requireAuth,[\s\S]{0,120}'private, no-store'/, 'Following is private');++assert.ok(routes.indexOf("router.get('/following'") < routes.indexOf("router.get('/:id'"), 'before the /:id route');const frag = fs.readFileSync(path.join(__dirname, '..', 'public', 'fragments', 'home.html'), 'utf8');assert.match(frag, /<div class="home-topics" id="home-topics" role="group" aria-label="[^"]+" hidden><\/div>\s*<div class="stream-grid" id="stream-grid-live">/);const css = fs.readFileSync(path.join(__dirname, '..', 'public', 'css', 'features', 'home.css'), 'utf8');nnew file mode 100644@@@ -0,0 +1,66 @@++'use strict';++/**++ * GET /api/streams/following: the ids of channels live right now that the signed-in viewer follows (the home's++ * Following chip, Live market outcome 3). Private: never cached by a browser or the edge. Signed out: 401.++ * node test/streams-following.test.js++ */++const assert = require('assert');++const http = require('http');++const express = require('express');++++process.env.NODE_ENV = 'test';++console.warn = () => {};++const quiet = console.log;++console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };++++const db = require('../server/db/database');++// The route's own requireAuth reads a token; here a header names the signed-in user instead.++const auth = require('../server/auth/auth');++auth.requireAuth = (req, res, next) => {++ const id = Number(req.headers['x-test-user']);++ if (!id) return res.status(401).json({ error: 'Authentication required' });++ req.user = { id };++ return next();++};++++(async () => {++ await db.initDb();++ const mk = async (username) => {++ await db.createUser({ username, display_name: username, stream_key: username.padEnd(32, '0') });++ return Number((await db.get('SELECT id FROM users WHERE username = ?', [username])).id);++ };++ const ts = (msAgo) => new Date(Date.now() - msAgo).toISOString().slice(0, 19).replace('T', ' ');++ const stream = async (userId, live) => db.run(++ `INSERT INTO streams (user_id, title, protocol, is_live, started_at, ended_at, created_at) VALUES (?, ?, 'webrtc', ?, ?, ?, ?)`,++ [userId, `${userId} stream`, live ? 1 : 0, ts(60000), live ? null : ts(30000), ts(60000)]);++ const follow = async (follower, streamer) => db.run('INSERT INTO follows (follower_id, streamer_id) VALUES (?, ?)', [follower, streamer]);++++ const viewer = await mk('viewer'), bob = await mk('bob'), carol = await mk('carol'), dave = await mk('dave');++ await stream(bob, true); // followed, live++ await stream(carol, true); // live, not followed++ await stream(dave, false); // followed, not live++ await follow(viewer, bob);++ await follow(viewer, dave);++++ const app = express();++ app.use('/api/streams', require('../server/streaming/routes'));++ const server = await new Promise((resolve) => { const s = http.createServer(app).listen(0, '127.0.0.1', () => resolve(s)); });++ const base = `http://127.0.0.1:${server.address().port}`;++ try {++ const out = await fetch(`${base}/api/streams/following`);++ assert.strictEqual(out.status, 401, 'signed out: 401');++++ const me = await fetch(`${base}/api/streams/following`, { headers: { 'x-test-user': String(viewer) } });++ assert.strictEqual(me.status, 200);++ assert.strictEqual(me.headers.get('cache-control'), 'private, no-store', 'never cached');++ assert.deepStrictEqual((await me.json()).user_ids, [bob], 'only followed channels that are live now');++++ const nobody = await fetch(`${base}/api/streams/following`, { headers: { 'x-test-user': String(carol) } });++ assert.deepStrictEqual((await nobody.json()).user_ids, [], 'following nobody live: an empty list');++ console.log('streams following: all checks passed');++ } finally {++ server.close();++ await db.closeDb?.();++ }++ process.exit(0);++})().catch((e) => { console.error(e); process.exit(1); });
@@ -473,6 +473,8 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
@@ -473,6 +473,8 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
.home-topic-n { font-size: 0.72rem; font-weight: 700; color: var(--text-muted); font-variant-numeric: tabular-nums; }
.home-topic-n { font-size: 0.72rem; font-weight: 700; color: var(--text-muted); font-variant-numeric: tabular-nums; }
.home-topic[aria-pressed="true"] { background: var(--accent); border-color: var(--accent); color: var(--on-accent, #fff); }
.home-topic[aria-pressed="true"] { background: var(--accent); border-color: var(--accent); color: var(--on-accent, #fff); }
.home-topic[aria-pressed="true"] .home-topic-n { color: inherit; opacity: 0.8; }
.home-topic[aria-pressed="true"] .home-topic-n { color: inherit; opacity: 0.8; }
+/* Following: channels the viewer follows that are live now, marked with the live dot. */
+.home-topic[data-topic="@following"]::before { content: ''; width: 7px; height: 7px; border-radius: 50%; background: var(--live-red, #ef4444); }
@media (pointer: coarse) { .home-topic { min-height: 44px; } }
@media (pointer: coarse) { .home-topic { min-height: 44px; } }
.stream-card-metaright { margin-left: auto; display: inline-flex; align-items: center; gap: 12px; color: var(--text-muted); font-size: 0.75rem; }
.stream-card-metaright { margin-left: auto; display: inline-flex; align-items: center; gap: 12px; color: var(--text-muted); font-size: 0.75rem; }
.stream-card-uptime, .stream-card-vcount { display: inline-flex; align-items: center; white-space: nowrap; }
.stream-card-uptime, .stream-card-vcount { display: inline-flex; align-items: center; white-space: nowrap; }
@@ -1783,20 +1783,45 @@ function _streamTopics(s) {
@@ -1783,20 +1783,45 @@ function _streamTopics(s) {
return out;
return out;
}
}
+// The chip a viewer picked comes back the next time it is live (market outcome 2: a default set), in this browser
+// only; _homeTopic is what is applied now, which is All while the picked topic is off air.
+const _TOPIC_KEY = 'ov_home_topic';
+let _homeTopicPref = (() => { try { return (typeof localStorage !== 'undefined' && localStorage.getItem(_TOPIC_KEY)) || ''; } catch { return ''; } })();
let _homeTopic = '';
let _homeTopic = '';
-/** The chip row over the live grid: shown when at least two streams are live and they have two topics or more. */
+// Following (market outcome 3): the live channels the signed-in viewer follows, from GET /api/streams/following
+// (private, never cached by the edge), read at most once a minute. A chip of its own, first after All.
+const _FOLLOWING = '@following';
+let _homeFollowing = { at: 0, ids: new Set(), busy: false };
+let _homeLastStreams = [];
+function _homeFollowingRefresh() {
+ const me = typeof currentUser !== 'undefined' && currentUser;
+ if (!me || typeof api !== 'function') { if (!me) _homeFollowing = { at: 0, ids: new Set(), busy: false }; return; }
+ if (_homeFollowing.busy || Date.now() - _homeFollowing.at < 60000) return;
+ _homeFollowing.busy = true;
+ api('/streams/following').then((d) => {
+ _homeFollowing = { at: Date.now(), ids: new Set(((d && d.user_ids) || []).map(Number)), busy: false };
+ _homeTopicsSync(_homeLastStreams);
+ }).catch(() => { _homeFollowing = { ..._homeFollowing, at: Date.now(), busy: false }; });
+}
+/** The chip row over the live grid: shown when at least two streams are live and they have two topics or more, or
+ * some of them are channels the viewer follows. */
function _homeTopicsSync(streams) {
function _homeTopicsSync(streams) {
const row = document.getElementById('home-topics');
const row = document.getElementById('home-topics');
if (!row) return;
if (!row) return;
+ _homeLastStreams = streams;
+ _homeFollowingRefresh();
const counts = new Map();
const counts = new Map();
for (const s of streams) for (const t of _streamTopics(s)) counts.set(t, (counts.get(t) || 0) + 1);
for (const s of streams) for (const t of _streamTopics(s)) counts.set(t, (counts.get(t) || 0) + 1);
- const show = streams.length >= 2 && counts.size >= 2;
+ const followingN = streams.filter((s) => _homeFollowing.ids.has(Number(s.user_id))).length;
+ const show = streams.length >= 2 && (counts.size >= 2 || followingN > 0);
row.hidden = !show;
row.hidden = !show;
if (!show) { _homeTopic = ''; _homeTopicsApply(); return; }
if (!show) { _homeTopic = ''; _homeTopicsApply(); return; }
- if (_homeTopic && !counts.has(_homeTopic)) _homeTopic = '';
+ const available = new Set(counts.keys());
+ if (followingN) available.add(_FOLLOWING);
+ _homeTopic = available.has(_homeTopicPref) ? _homeTopicPref : '';
// Most streams first, then A to Z; the row scrolls sideways, so every topic stays reachable.
// Most streams first, then A to Z; the row scrolls sideways, so every topic stays reachable.
const list = [...counts].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
const list = [...counts].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
- const key = `${streams.length}|${list.map(([t, n]) => `${t}:${n}`).join(',')}`;
+ const key = `${streams.length}|${followingN}|${list.map(([t, n]) => `${t}:${n}`).join(',')}`;
if (row.dataset.key !== key) {
if (row.dataset.key !== key) {
row.dataset.key = key;
row.dataset.key = key;
const chip = (topic, label, n) => {
const chip = (topic, label, n) => {
@@ -1812,6 +1837,7 @@ function _homeTopicsSync(streams) {
@@ -1812,6 +1837,7 @@ function _homeTopicsSync(streams) {
return b;
return b;
};
};
const nodes = [chip('', 'All', streams.length)];
const nodes = [chip('', 'All', streams.length)];
+ if (followingN) nodes.push(chip(_FOLLOWING, 'Following', followingN));
for (const [t, n] of list) nodes.push(chip(t, _capTag(t), n));
for (const [t, n] of list) nodes.push(chip(t, _capTag(t), n));
row.replaceChildren(...nodes);
row.replaceChildren(...nodes);
if (!row._ovBound) {
if (!row._ovBound) {
@@ -1819,7 +1845,10 @@ function _homeTopicsSync(streams) {
@@ -1819,7 +1845,10 @@ function _homeTopicsSync(streams) {
row.addEventListener('click', (e) => {
row.addEventListener('click', (e) => {
const b = e.target.closest('.home-topic');
const b = e.target.closest('.home-topic');
if (!b) return;
if (!b) return;
- _homeTopic = b.dataset.topic === _homeTopic ? '' : b.dataset.topic;
+ const next = b.dataset.topic === _homeTopic ? '' : b.dataset.topic;
+ _homeTopicPref = next;
+ _homeTopic = next;
+ try { if (next) localStorage.setItem(_TOPIC_KEY, next); else localStorage.removeItem(_TOPIC_KEY); } catch { /* private mode */ }
_homeTopicsApply();
_homeTopicsApply();
});
});
}
}
@@ -1832,7 +1861,10 @@ function _homeTopicsApply() {
@@ -1832,7 +1861,10 @@ function _homeTopicsApply() {
const grid = document.getElementById('stream-grid-live');
const grid = document.getElementById('stream-grid-live');
if (!grid) return;
if (!grid) return;
grid.querySelectorAll('.stream-card[data-stream-id]').forEach((card) => {
grid.querySelectorAll('.stream-card[data-stream-id]').forEach((card) => {
- card.hidden = !!_homeTopic && !(card.dataset.topics || '').split('|').includes(_homeTopic);
+ const keep = !_homeTopic || (_homeTopic === _FOLLOWING
+ ? _homeFollowing.ids.has(Number(card.dataset.user))
+ : (card.dataset.topics || '').split('|').includes(_homeTopic));
+ card.hidden = !keep;
});
});
}
}
@@ -1853,7 +1885,7 @@ function streamCardHTML(s, isLive) {
@@ -1853,7 +1885,7 @@ function streamCardHTML(s, isLive) {
const cat = String(s.category || '').replace(/[-_]+/g, ' ').trim().toLowerCase();
const cat = String(s.category || '').replace(/[-_]+/g, ' ').trim().toLowerCase();
const shown = topics.filter((t) => t !== cat).slice(0, 2);
const shown = topics.filter((t) => t !== cat).slice(0, 2);
return `
return `
- <a class="stream-card"${isLive ? ` data-topics="${esc(topics.join('|'))}"` : ''} data-stream-id="${esc(String(s.id || ''))}" href="${esc(navUrl)}" onclick="return handleLinkClick(event, '${esc(navUrl)}')">
+ <a class="stream-card"${isLive ? ` data-topics="${esc(topics.join('|'))}" data-user="${esc(String(s.user_id || ''))}"` : ''} data-stream-id="${esc(String(s.id || ''))}" href="${esc(navUrl)}" onclick="return handleLinkClick(event, '${esc(navUrl)}')">
<div class="stream-card-thumb${s.is_nsfw ? ' stream-card-nsfw-blur' : ''}" style="--thumb-tint:${esc(_thumbTint(s))}">
<div class="stream-card-thumb${s.is_nsfw ? ' stream-card-nsfw-blur' : ''}" style="--thumb-tint:${esc(_thumbTint(s))}">
${thumbImg(thumb, 'fa-circle-nodes', s.title, !isLive && s.vod_id ? `/api/thumbnails/generate/vod/${s.vod_id}` : null)}
${thumbImg(thumb, 'fa-circle-nodes', s.title, !isLive && s.vod_id ? `/api/thumbnails/generate/vod/${s.vod_id}` : null)}
${streamTypeBadge(s.browser_mode, s.streaming_method)}
${streamTypeBadge(s.browser_mode, s.streaming_method)}
@@ -22,7 +22,7 @@ const BUDGETS = {
@@ -22,7 +22,7 @@ const BUDGETS = {
htmlRawKB: 128, // 119.3 with the icons drawn on the server (2026-11-01, plan L54 K4: the OpenVibe glyphs in the HTML, 20.8 KB brotli); measured 94.9 before
htmlRawKB: 128, // 119.3 with the icons drawn on the server (2026-11-01, plan L54 K4: the OpenVibe glyphs in the HTML, 20.8 KB brotli); measured 94.9 before
htmlBrotliKB: 30,
htmlBrotliKB: 30,
jsFiles: 26, // measured 23 (incl. route scripts for "/")
jsFiles: 26, // measured 23 (incl. route scripts for "/")
- jsRawKB: 1251, // 1250.2 (2026-11-01: topics live now, readable chat names on light themes); 1247.7 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 1244.3 with openvibe-shared 4.66.0: the mark with depth and motion, ov-fx loaded on request (2026-10-31); 1243.5 with openvibe-shared 4.64.0: the look travels between sites, the Look panel in the menu (2026-10-31); 1242.7 with openvibe-shared 4.59.0: the Pair mark beside the name, set in the brand face, and the ring on page moves (2026-10-31); 1238.8 with the home's featured stream once and the intro closed on phones (2026-10-31); 1237.4 with the home's two more tab blocks on phones (2026-10-30); 1233.9 with the home's Catch up tabs on phones (2026-10-30); 1230.8 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 1227.8 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 1223.5 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 1215.4 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); measured 1203.9 (2026-10-28: openvibe-shared 4.30.0, the bell center: tabs with counts, the Unread filter, thumbnails, swipe, the phone sheet; 4.31.0's ✦ adds about 3 KB); 1187.2 (2026-10-28: openvibe-shared 4.27.0, Notifications 2.0: opaque themed toasts held while a menu is open, grouping, priority, System cards and the rich bell panel); 1158.8 (2026-10-28: openvibe-shared 4.25.0, the menu's orb, messages, the phone sheet and auto-hide in the navbar; the messenger opens from the bar)
+ jsRawKB: 1253, // 1252.3 (2026-11-01: Following in the topics row, the picked topic remembered); 1250.2 (2026-11-01: topics live now, readable chat names on light themes); 1247.7 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 1244.3 with openvibe-shared 4.66.0: the mark with depth and motion, ov-fx loaded on request (2026-10-31); 1243.5 with openvibe-shared 4.64.0: the look travels between sites, the Look panel in the menu (2026-10-31); 1242.7 with openvibe-shared 4.59.0: the Pair mark beside the name, set in the brand face, and the ring on page moves (2026-10-31); 1238.8 with the home's featured stream once and the intro closed on phones (2026-10-31); 1237.4 with the home's two more tab blocks on phones (2026-10-30); 1233.9 with the home's Catch up tabs on phones (2026-10-30); 1230.8 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 1227.8 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 1223.5 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 1215.4 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); measured 1203.9 (2026-10-28: openvibe-shared 4.30.0, the bell center: tabs with counts, the Unread filter, thumbnails, swipe, the phone sheet; 4.31.0's ✦ adds about 3 KB); 1187.2 (2026-10-28: openvibe-shared 4.27.0, Notifications 2.0: opaque themed toasts held while a menu is open, grouping, priority, System cards and the rich bell panel); 1158.8 (2026-10-28: openvibe-shared 4.25.0, the menu's orb, messages, the phone sheet and auto-hide in the navbar; the messenger opens from the bar)
jsBrotliKB: 293.3, // 292.6 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 291.1 with openvibe-shared 4.57.1 (the Dock's async token and scope-aware controls, 4.57.0; the tool frame's facts, 4.56.0) (2026-10-31); 290.6 with the home's two more tab blocks on phones (2026-10-30); 289.8 with the home's Catch up tabs on phones (2026-10-30); 288.2 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 287.4 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); measured 283.2 (2026-10-28: openvibe-shared 4.30.0, the bell center as above); 279.6 (2026-10-28: openvibe-shared 4.27.0, Notifications 2.0 as above); 273.1 (2026-10-28: openvibe-shared 4.25.0 as above); 270.5 (2026-10-28: openvibe-shared 4.22.0, the navbar's search magnifier with / and Ctrl+K and the More menu; search.js and loading.js load only when used); 269.6 (2026-10-28: openvibe-shared 4.19.0, the account menu's gear and people-group icons inline; 268 on 2026-10-27: openvibe-shared 4.16.0, the folded balance chip, Across OpenVibe with site marks and the icon preload in the navbar); 265.3 (2026-10-27: openvibe-shared 4.15.0, the compact footer opens into the whole one, and the pulse line); 263.3 on 10-23 (openvibe-shared 4.9.0, status text twins in every theme); 262.5 on 10-17 (openvibe-shared 4.4.0, every site in the launcher)
jsBrotliKB: 293.3, // 292.6 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 291.1 with openvibe-shared 4.57.1 (the Dock's async token and scope-aware controls, 4.57.0; the tool frame's facts, 4.56.0) (2026-10-31); 290.6 with the home's two more tab blocks on phones (2026-10-30); 289.8 with the home's Catch up tabs on phones (2026-10-30); 288.2 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 287.4 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); measured 283.2 (2026-10-28: openvibe-shared 4.30.0, the bell center as above); 279.6 (2026-10-28: openvibe-shared 4.27.0, Notifications 2.0 as above); 273.1 (2026-10-28: openvibe-shared 4.25.0 as above); 270.5 (2026-10-28: openvibe-shared 4.22.0, the navbar's search magnifier with / and Ctrl+K and the More menu; search.js and loading.js load only when used); 269.6 (2026-10-28: openvibe-shared 4.19.0, the account menu's gear and people-group icons inline; 268 on 2026-10-27: openvibe-shared 4.16.0, the folded balance chip, Across OpenVibe with site marks and the icon preload in the navbar); 265.3 (2026-10-27: openvibe-shared 4.15.0, the compact footer opens into the whole one, and the pulse line); 263.3 on 10-23 (openvibe-shared 4.9.0, status text twins in every theme); 262.5 on 10-17 (openvibe-shared 4.4.0, every site in the launcher)
cssRawKB: 270, // measured ~243 since the first-paint subset (style.core.css) replaced style.css (~717 before)
cssRawKB: 270, // measured ~243 since the first-paint subset (style.core.css) replaced style.css (~717 before)
cssBrotliKB: 55, // measured ~47.5 (was ~130)
cssBrotliKB: 55, // measured ~47.5 (was ~130)
@@ -1222,6 +1222,21 @@ router.get('/', optionalAuth, async (req, res) => {
@@ -1222,6 +1222,21 @@ router.get('/', optionalAuth, async (req, res) => {
});
});
// ── Tags In Use (the browse filters) ─────────────────────────
// ── Tags In Use (the browse filters) ─────────────────────────
+// ── The Live Channels I Follow (the home's Following chip) ─────
+// Ids only, of channels live right now that the signed-in viewer follows (Live's projection of Network's follows).
+// Private: never cached by a browser or the edge.
+router.get('/following', requireAuth, async (req, res) => {
+ res.set('Cache-Control', 'private, no-store');
+ try {
+ const rows = await db.all(`SELECT DISTINCT s.user_id FROM streams s JOIN follows f ON f.streamer_id = s.user_id
+ WHERE f.follower_id = ? AND s.is_live = 1`, [req.user.id]);
+ res.json({ user_ids: rows.map((r) => Number(r.user_id)) });
+ } catch (err) {
+ console.error('[Streams] Following error:', err.message);
+ res.status(500).json({ error: 'Failed to read the channels you follow' });
+ }
+});
+
router.get('/tags', async (req, res) => {
router.get('/tags', async (req, res) => {
try {
try {
// The tag set changes slowly and is the same for everyone (like the stream list, with a longer window).
// The tag set changes slowly and is the same for everyone (like the stream list, with a longer window).
@@ -27,9 +27,10 @@ function fakeDom(cardTopics) {
@@ -27,9 +27,10 @@ function fakeDom(cardTopics) {
};
};
return { document, row, chips, cards };
return { document, row, chips, cards };
}
}
-function load(dom) {
+function load(dom, { currentUser, api } = {}) {
const _capTag = (s) => String(s).split(' ').map((w) => w[0].toUpperCase() + w.slice(1)).join(' ');
const _capTag = (s) => String(s).split(' ').map((w) => w[0].toUpperCase() + w.slice(1)).join(' ');
- return new Function('document', '_capTag', `${block}; return { _streamTopics, _homeTopicsSync, _homeTopicsApply, get topic() { return _homeTopic; } };`)(dom.document, _capTag);
+ return new Function('document', '_capTag', 'currentUser', 'api', `${block}; return { _streamTopics, _homeTopicsSync, _homeTopicsApply, get topic() { return _homeTopic; },
+ setFollowing(ids) { _homeFollowing = { at: Date.now(), ids: new Set(ids), busy: false }; } };`)(dom.document, _capTag, currentUser, api);
}
}
// A stream's topics: category first, then its own tags and the AI's, normalized, meta words dropped.
// A stream's topics: category first, then its own tags and the AI's, normalized, meta words dropped.
@@ -78,8 +79,32 @@ const streams = [
@@ -78,8 +79,32 @@ const streams = [
assert.strictEqual(m.topic, '', 'no stream has it any more');
assert.strictEqual(m.topic, '', 'no stream has it any more');
}
}
+// Following (market outcome 3): the live channels the signed-in viewer follows are a chip of their own, first after
+// All, and it filters by the card's user.
+{
+ const dom = fakeDom(['gaming|fortnite', 'gaming|league', 'outdoors|hiking']);
+ dom.cards.forEach((c, i) => { c.dataset.user = String(10 + i); });
+ const m = load(dom, { currentUser: { id: 1 }, api: () => new Promise(() => {}) });
+ m.setFollowing([10, 12]);
+ m._homeTopicsSync(streams.map((s, i) => ({ ...s, user_id: 10 + i })));
+ assert.deepStrictEqual(dom.chips.slice(0, 2).map((c) => c.dataset.topic), ['', '@following']);
+ assert.strictEqual(dom.chips[1].children[2].textContent, '2', 'two followed channels are live');
+ dom.row.onclick({ target: { closest: () => dom.chips[1] } });
+ assert.deepStrictEqual(dom.cards.map((c) => c.hidden), [false, true, false], 'only the followed channels stay');
+ // Signed out, there is no Following chip.
+ const out = fakeDom(['gaming|fortnite', 'gaming|league', 'outdoors|hiking']);
+ load(out)._homeTopicsSync(streams.map((s, i) => ({ ...s, user_id: 10 + i })));
+ assert.ok(!out.chips.some((c) => c.dataset.topic === '@following'));
+}
+
// The card carries its topics for the filter, and the row sits over the grid, hidden until filled.
// The card carries its topics for the filter, and the row sits over the grid, hidden until filled.
assert.match(src, /data-topics="\$\{esc\(topics\.join\('\|'\)\)\}"/, 'the card names its topics');
assert.match(src, /data-topics="\$\{esc\(topics\.join\('\|'\)\)\}"/, 'the card names its topics');
+assert.match(src, /data-user="\$\{esc\(String\(s\.user_id \|\| ''\)\)\}"/, 'and its channel, for Following');
+// The picked chip is remembered in this browser and comes back when that topic is live again.
+assert.match(block, /localStorage\.setItem\(_TOPIC_KEY, next\)/);
+const routes = fs.readFileSync(path.join(__dirname, '..', 'server', 'streaming', 'routes.js'), 'utf8');
+assert.match(routes, /router\.get\('\/following', requireAuth,[\s\S]{0,120}'private, no-store'/, 'Following is private');
+assert.ok(routes.indexOf("router.get('/following'") < routes.indexOf("router.get('/:id'"), 'before the /:id route');
const frag = fs.readFileSync(path.join(__dirname, '..', 'public', 'fragments', 'home.html'), 'utf8');
const frag = fs.readFileSync(path.join(__dirname, '..', 'public', 'fragments', 'home.html'), 'utf8');
assert.match(frag, /<div class="home-topics" id="home-topics" role="group" aria-label="[^"]+" hidden><\/div>\s*<div class="stream-grid" id="stream-grid-live">/);
assert.match(frag, /<div class="home-topics" id="home-topics" role="group" aria-label="[^"]+" hidden><\/div>\s*<div class="stream-grid" id="stream-grid-live">/);
const css = fs.readFileSync(path.join(__dirname, '..', 'public', 'css', 'features', 'home.css'), 'utf8');
const css = fs.readFileSync(path.join(__dirname, '..', 'public', 'css', 'features', 'home.css'), 'utf8');
new file mode 100644
new file mode 100644
@@ -0,0 +1,66 @@
@@ -0,0 +1,66 @@
+'use strict';
+/**
+ * GET /api/streams/following: the ids of channels live right now that the signed-in viewer follows (the home's
+ * Following chip, Live market outcome 3). Private: never cached by a browser or the edge. Signed out: 401.
+ * node test/streams-following.test.js
+ */
+const assert = require('assert');
+const http = require('http');
+const express = require('express');
+
+process.env.NODE_ENV = 'test';
+console.warn = () => {};
+const quiet = console.log;
+console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };
+
+const db = require('../server/db/database');
+// The route's own requireAuth reads a token; here a header names the signed-in user instead.
+const auth = require('../server/auth/auth');
+auth.requireAuth = (req, res, next) => {
+ const id = Number(req.headers['x-test-user']);
+ if (!id) return res.status(401).json({ error: 'Authentication required' });
+ req.user = { id };
+ return next();
+};
+
+(async () => {
+ await db.initDb();
+ const mk = async (username) => {
+ await db.createUser({ username, display_name: username, stream_key: username.padEnd(32, '0') });
+ return Number((await db.get('SELECT id FROM users WHERE username = ?', [username])).id);
+ };
+ const ts = (msAgo) => new Date(Date.now() - msAgo).toISOString().slice(0, 19).replace('T', ' ');
+ const stream = async (userId, live) => db.run(
+ `INSERT INTO streams (user_id, title, protocol, is_live, started_at, ended_at, created_at) VALUES (?, ?, 'webrtc', ?, ?, ?, ?)`,
+ [userId, `${userId} stream`, live ? 1 : 0, ts(60000), live ? null : ts(30000), ts(60000)]);
+ const follow = async (follower, streamer) => db.run('INSERT INTO follows (follower_id, streamer_id) VALUES (?, ?)', [follower, streamer]);
+
+ const viewer = await mk('viewer'), bob = await mk('bob'), carol = await mk('carol'), dave = await mk('dave');
+ await stream(bob, true); // followed, live
+ await stream(carol, true); // live, not followed
+ await stream(dave, false); // followed, not live
+ await follow(viewer, bob);
+ await follow(viewer, dave);
+
+ const app = express();
+ app.use('/api/streams', require('../server/streaming/routes'));
+ const server = await new Promise((resolve) => { const s = http.createServer(app).listen(0, '127.0.0.1', () => resolve(s)); });
+ const base = `http://127.0.0.1:${server.address().port}`;
+ try {
+ const out = await fetch(`${base}/api/streams/following`);
+ assert.strictEqual(out.status, 401, 'signed out: 401');
+
+ const me = await fetch(`${base}/api/streams/following`, { headers: { 'x-test-user': String(viewer) } });
+ assert.strictEqual(me.status, 200);
+ assert.strictEqual(me.headers.get('cache-control'), 'private, no-store', 'never cached');
+ assert.deepStrictEqual((await me.json()).user_ids, [bob], 'only followed channels that are live now');
+
+ const nobody = await fetch(`${base}/api/streams/following`, { headers: { 'x-test-user': String(carol) } });
+ assert.deepStrictEqual((await nobody.json()).user_ids, [], 'following nobody live: an empty list');
+ console.log('streams following: all checks passed');
+ } finally {
+ server.close();
+ await db.closeDb?.();
+ }
+ process.exit(0);
+})().catch((e) => { console.error(e); process.exit(1); });