openvibers/openvibe.surf Commits

Home: the Watch scene plays the real Live feed, not a written-in channel (#93)

OP OpenVibers committed 6 days ago · bb11a2aef9 · 7 files, +111 −7

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex a9d34bc..818573e 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -4,6 +4,11 @@ What changed in OpenVibe.Surf, newest first. Each site also publishes its patch
## Unreleased — watch together
++- **Home Watch scene** (`server/views/index.js`, `server/http/pages.js`): the home no longer shows the written-in
++ `goosely · Night owls: Seattle walk` card. It fetches the head of the real `GET /api/v1/watch/feed` server-side
++ (the feed's own 30 s cache) and renders the live, clip or AI moment cards, each linked to its Live or Video page.
++ With no live streams it says "Nobody is live right now" over the latest clip; with no source at all it says so.
++- **Tests:** `test/pages.test.js` pins the real card/link, the no-live state and the all-empty state.
- **Watch together** (`server/watch/party.js`, `server/http/party.js`, `server/views/index.js`,
`public/js/surf.js`): a signed-in person opens a party (`POST /watch/party`) and shares an unguessable
`/watch/p/<id>` link. The page reuses the TV markup with a `sf-party-` bar (members, host, Share, Leave); the
ddiff --git a/README.md b/README.md
iindex f1105d8..90937a9 100644
---- a/README.md
++++ b/README.md
@@@ -29,7 +29,8 @@ OpenVibe.Surf is the Surf room. One room, three surfs:
`GET /api/v1/watch/feed?topic=all|live|clips|moments&cursor=`
--Merges three public OpenVibe.Live reads (`OV_LIVE_INTERNAL_URL`, default `http://127.0.0.1:3000`):
++Merges three public OpenVibe.Live reads (`OV_LIVE_INTERNAL_URL`, default `http://127.0.0.1:3000`). The home's
++Watch scene is the head of this same feed, fetched server-side, so it never shows a written-in channel:
| Source | Live route | What it gives |
|---|---|---|
@@@ -125,7 +126,7 @@ its status and prints its words. It is `private, no-store`; a `GET /web/go` star
| Route | What it is |
|---|---|
--| `/` | the Surf room: the run motif (ramps, speed lines, a ticking timer), the three tiles under the hero, scenes, questions |
++| `/` | the Surf room: the run motif (ramps, speed lines, a ticking timer), the three tiles under the hero, scenes (the Watch scene is the real Live feed's first cards), questions |
| `/ride` | the maps with tier, stages, runs and the record, and the embed of the first map |
| `/ride/:map` | one map: name, tier, stages, records if Games answers, the embed |
| `/watch` | the TV: the screen plays clips and moments in the page (HLS), the guide lists every channel; ↑ ↓, a swipe or a tap changes channel; a live stream that records to MP4 plays over Media's DVR playlist, a little behind live, and any other opens on its channel; **Watch together** opens a party |
ddiff --git a/STATUS.json b/STATUS.json
iindex 3910ef6..b6e6598 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -23,7 +23,7 @@
"Ride GET /api/v1/ride/maps and /api/v1/ride/maps/:id: proxies OpenVibe.Games' /api/surf/maps routes, adds each map's embed_url (https://openvibe.games/surf/embed?map=<id>&embed=1), caches 60 s, Games 404 → empty list/map shell with games: 'not_ready'",
"Web POST /api/v1/web/tasks: signed in, same-origin, query 3–500 characters, sends the person's surf_at token to OpenVibe.Actor as a Bearer with an Idempotency-Key and the prefixed browsing brief, answers { task_id, page }, and passes Actor's 4xx refusal through in words",
"POST /web/go: the same task as a plain form for no-JavaScript (this site only, the question never in a URL): 303 to the Actor task page, sign-in redirect for a guest, Actor's refusal in plain words",
-- "Designed pages: / (the run motif, three tiles, scenes, questions), /ride and /ride/:map, /watch (the TV and its guide, HLS in the page), /watch/p/:id (watch together), /web, /developers and a 404; public max-age=60 with Vary: Cookie, signed-in private, no-store",
++ "Designed pages: / (the run motif, three tiles, scenes, questions; its Watch scene is the real Live feed head, fetched server-side and linked to each item), /ride and /ride/:map, /watch (the TV and its guide, HLS in the page), /watch/p/:id (watch together), /web, /developers and a 404; public max-age=60 with Vary: Cookie, signed-in private, no-store",
"Watch together: POST /watch/party makes an unguessable /watch/p/<id>, signed-in members join by opening it, the host commands { channel_key, position_s, playing, at } from this site, GET /watch/p/:id/events carries SSE to members (drift > 2 s corrected), and anyone can leave; the Map is capped at 200 parties/50 members, an empty party is dropped 30 min after its last member leaves, and a restart ends every party",
"Crawl artifacts (robots.txt, sitemap.xml with the public pages and Ride maps, llms.txt), /api/health, /api/ready (degraded when Network/Live/Games/Actor do not answer), /release.json, /release-metrics, /shared/*",
"Helmet with the network's CSP (Games and Live frames, openvibe.media HLS), per-IP 120/min on /api and /auth, content-hashed /css and /js, graceful stop",
ddiff --git a/public/css/surf.css b/public/css/surf.css
iindex 867d93f..ff83fd5 100644
---- a/public/css/surf.css
++++ b/public/css/surf.css
@@@ -82,7 +82,10 @@ html.ov-motion .sf-run-speed i { animation: sf-speed 1.1s linear infinite; anima
.sf-demo-screen { position: relative; display: flex; align-items: flex-end; gap: 8px; aspect-ratio: 16 / 6; max-height: 210px; padding: 12px; border-radius: 12px; color: #fff; font-weight: 700; font-size: .85rem;
background: radial-gradient(120% 120% at 70% 30%, color-mix(in srgb, var(--room-accent) 45%, transparent), transparent 60%), linear-gradient(160deg, #1e293b, #0b1020); }
.sf-demo-tv ul { list-style: none; margin: 0; padding: 0 4px; display: grid; gap: 4px; font-size: .82rem; color: var(--text-secondary, #96a7c2); }
++.sf-demo-tv a { color: inherit; text-decoration: none; }
++.sf-demo-tv a:hover, .sf-demo-tv a:focus-visible { text-decoration: underline; }
.sf-pill-live { padding: 1px 7px; border-radius: 5px; background: var(--live-red, #e11d48); color: #fff; font-size: .68rem; font-weight: 800; }
++.sf-pill-live--clip, .sf-pill-live--moment { background: var(--room-accent, #00bef6); }
/* Ride: map cards. */
.sf-maps { list-style: none; margin: 16px 0 0; padding: 0; display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(min(100%, 280px), 1fr)); }
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 54543b4..3f70610 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -49,7 +49,11 @@ function createPageRoutes(ctx) {
});
const page = (res, status, html, viewer) => send(res, status, html, { viewer });
-- r.get('/', (req, res) => page(res, 200, views.home(), req.viewer));
++ r.get('/', async (req, res) => {
++ // The Watch scene on the home is the real Live guide, fetched server-side (the feed caches 30 s).
++ const out = await safe(() => feed.feed('all'));
++ return page(res, 200, views.home({ feed: out }), req.viewer);
++ });
r.get('/ride', async (req, res) => {
const out = await safe(() => ride.maps());
ddiff --git a/server/views/index.js b/server/views/index.js
iindex b78554b..fc230a1 100644
---- a/server/views/index.js
++++ b/server/views/index.js
@@@ -4,7 +4,7 @@
* JavaScript; public/js/surf.js adds the TV (HLS in the page, ↑ ↓ and swipe to change channel), the Web form's
* in-page start, and the home's ticking timer.
*
-- * home() the front door: the run motif, three tiles under one Start, scenes, questions
++ * home({ feed }) the front door: the run motif, three tiles under one Start, scenes, questions
* ride({ maps, games }) every map as a card, and the first one to play
* map({ id, map, top, games }) one map: tier, stages, author, the game, its fastest runs
* watch({ feed, topic }) the TV: what is on now, the guide (up next), topics
@@@ -47,7 +47,40 @@ function runMotif() {
</div>`;
}
--function home() {
++const TV_PREVIEW_MAX = 3;
++const previewKind = (it) => (it && it.kind === 'moment' ? 'moment' : it && it.kind === 'clip' ? 'clip' : 'live');
++const PREVIEW_PILL = { live: 'LIVE', clip: 'CLIP', moment: 'AI MOMENT' };
++const previewPage = (it) => (it && it.play && /^https?:\/\//.test(String(it.play.page || '')) ? it.play.page : '/watch');
++
++/**
++ * The Watch scene on the home: the same real Live guide as /watch, cut to its first cards. Nothing is invented: a
++ * card is only rendered for an item the feed returned, and every card links to the item's own Live or Video page.
++ * With no live streams but clips to show it says so ("Nobody is live right now") and shows the latest clip; with
++ * nothing at all it says that, honestly, instead of inventing a channel.
++ */
++function homeWatch(feed) {
++ const items = feed && Array.isArray(feed.items) ? feed.items.slice(0, TV_PREVIEW_MAX) : [];
++ if (!items.length) {
++ const missing = !feed || !feed.sources || feed.sources.live !== 'ok';
++ const note = missing
++ ? 'OpenVibe.Live did not answer just now. Watch again in a moment.'
++ : 'Nobody is live right now, and there is no clip up yet. Check Watch again in a moment.';
++ return `<div class="sf-demo-tv" id="sf-home-watch"><div class="sf-demo-screen"><span class="sf-pill-live">${missing ? 'OFFLINE' : 'OFF AIR'}</span><span>${esc(note)}</span></div><ul><li><a href="/watch"><b>Open Watch</b> Live streams, clips and AI moments</a></li></ul></div>`;
++ }
++ const first = items[0];
++ const k = previewKind(first);
++ const who = first.channel && first.channel.username ? `@${first.channel.username}` : '';
++ const liveAnswered = !!(feed.sources && feed.sources.live === 'ok');
++ const quiet = k !== 'live' && liveAnswered ? 'Nobody is live right now · ' : '';
++ const screen = `<a class="sf-demo-screen" href="${esc(previewPage(first))}"><span class="sf-pill-live sf-pill-live--${esc(k)}">${PREVIEW_PILL[k]}</span><span>${esc(quiet)}${who ? `${esc(who)} · ` : ''}${esc(first.title || '')}</span></a>`;
++ const rows = items.slice(1).map((it) => {
++ const rwho = it.channel && it.channel.username ? `@${it.channel.username} · ` : '';
++ return `<li><a href="${esc(previewPage(it))}"><b>Up next</b> ${esc(rwho)}${esc(it.title || '')}</a></li>`;
++ }).join('');
++ return `<div class="sf-demo-tv" id="sf-home-watch">${screen}${rows ? `<ul>${rows}</ul>` : ''}</div>`;
++}
++
++function home({ feed = null } = {}) {
const tiles = `<ul class="sf-tiles" id="sf-tiles">
<li class="sf-tile" id="sf-tile-ride" data-sf-surf="ride"><a href="/ride"><span class="sf-tile-ic">${kit.icon('game')}</span><b>Ride</b><span>CS-style surf in the browser: ramps, a millisecond timer, your ghost and the record.</span><span class="sf-tile-go">Ride a map${kit.icon('chevron')}</span></a></li>
<li class="sf-tile" id="sf-tile-web" data-sf-surf="web"><a href="/web"><span class="sf-tile-ic">${kit.icon('globe')}</span><b>Web</b><span>Surf with Vibe: say what you want, an agent browses for you and reports back with its sources.</span><span class="sf-tile-go">Ask Vibe${kit.icon('chevron')}</span></a></li>
@@@ -70,7 +103,7 @@ function home() {
html: `<ol class="sf-demo-steps"><li class="is-done">Searched the web for “Lisbon in March, cheap flights”</li><li class="is-done">Read 4 pages · compared prices on 2 sites</li><li class="is-now">Writing the report with its sources…</li></ol>`,
action: { href: '/web', label: 'Ask Vibe' } },
{ title: 'Lean back and surf what is on', text: 'Live streams first, then the clips people cut and the AI moments of the week, each saying why it is on. Swipe on a phone or press ↑ ↓ on a keyboard.',
-- html: `<div class="sf-demo-tv"><div class="sf-demo-screen"><span class="sf-pill-live">LIVE</span><span>goosely · Night owls: Seattle walk</span></div><ul><li><b>Up next</b> Top AI moment this week</li><li>New clip · Robot build, part 2</li></ul></div>`,
++ html: homeWatch(feed),
action: { href: '/watch', label: 'Start watching' } },
],
explore: { title: 'More on Surf', items: [
ddiff --git a/test/pages.test.js b/test/pages.test.js
iindex 4e2a877..d1f6985 100644
---- a/test/pages.test.js
++++ b/test/pages.test.js
@@@ -27,6 +27,64 @@ const { assetVersion } = require('../server/assets');
assert.ok(org && Array.isArray(org.sameAs) && org.sameAs.includes('https://github.com/OpenVibers'), 'home has the OpenVibe Organization');
assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=60');
assert.match(r.headers.get('vary'), /Cookie/i);
++ // The Watch scene is the real Live guide, not a written-in example channel.
++ assert.match(r.text, /id="sf-home-watch"/);
++ assert.match(r.text, /href="https:\/\/openvibe\.live\/@bo"/, 'the busiest live stream links to its channel');
++ assert.match(r.text, /Surf practice, all day/);
++ assert.match(r.text, /<span class="sf-pill-live sf-pill-live--live">LIVE<\/span>/);
++ assert.ok(!/goosely|Night owls|Robot build/.test(r.text), 'no fake stream, channel or clip on the home');
++ });
++
++ await check('the home says nobody is live and shows the latest clip when Live has no streams', async () => {
++ t.ctx.feed.clear();
++ const streams = t.upstreams.state.streams;
++ const clips = t.upstreams.state.clips;
++ const moments = t.upstreams.state.moments;
++ t.upstreams.state.streams = [];
++ t.upstreams.state.moments = [];
++ t.upstreams.state.clips = [{ id: 101, href: 'https://openvibe.video/c/101', title: 'Kickflip over the pier', created_at: '2026-10-28T09:00:00.000Z', channel: { username: 'ada', display_name: 'Ada' }, preview_url: 'https://openvibe.media/c/101/master.m3u8' }];
++ const r = await t.get('/');
++ t.upstreams.state.streams = streams;
++ t.upstreams.state.clips = clips;
++ t.upstreams.state.moments = moments;
++ t.ctx.feed.clear();
++ assert.strictEqual(r.status, 200, r.text);
++ assert.match(r.text, /Nobody is live right now/);
++ assert.match(r.text, /@ada · Kickflip over the pier/);
++ assert.match(r.text, /href="https:\/\/openvibe\.video\/c\/101"/, 'the clip links to its Video page');
++ assert.match(r.text, /<span class="sf-pill-live sf-pill-live--clip">CLIP<\/span>/);
++ assert.ok(!/placeholder|Unknown channel|goosely/.test(r.text));
++ });
++
++ await check('the home is honest when no Live source answers and when every source is empty', async () => {
++ t.ctx.feed.clear();
++ t.upstreams.state.fail.streams = 503;
++ t.upstreams.state.fail.clips = 503;
++ t.upstreams.state.fail.moments = 503;
++ const down = await t.get('/');
++ t.upstreams.state.fail.streams = null;
++ t.upstreams.state.fail.clips = null;
++ t.upstreams.state.fail.moments = null;
++ t.ctx.feed.clear();
++ assert.strictEqual(down.status, 200, down.text);
++ assert.match(down.text, /OpenVibe\.Live did not answer just now/);
++ assert.match(down.text, /href="\/watch"/);
++ assert.ok(!/goosely|Night owls|Robot build|Unknown channel/.test(down.text));
++
++ const streams = t.upstreams.state.streams;
++ const clips = t.upstreams.state.clips;
++ const moments = t.upstreams.state.moments;
++ t.upstreams.state.streams = [];
++ t.upstreams.state.clips = [];
++ t.upstreams.state.moments = [];
++ const empty = await t.get('/');
++ t.upstreams.state.streams = streams;
++ t.upstreams.state.clips = clips;
++ t.upstreams.state.moments = moments;
++ t.ctx.feed.clear();
++ assert.strictEqual(empty.status, 200, empty.text);
++ assert.match(empty.text, /Nobody is live right now, and there is no clip up yet/);
++ assert.ok(!/goosely|Night owls|Robot build|Unknown channel/.test(empty.text));
});
await check('/ride lists the maps and embeds the first', async () => {