openvibers/openvibe.surf Commits
Home: the Watch scene plays the real Live feed, not a written-in channel (#93)
Changes
@@@ -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@@@ -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 |@@@ -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",@@@ -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)); }@@@ -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());@@@ -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: [@@@ -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 () => {
@@ -4,6 +4,11 @@ What changed in OpenVibe.Surf, newest first. Each site also publishes its patch
@@ -4,6 +4,11 @@ What changed in OpenVibe.Surf, newest first. Each site also publishes its patch
## Unreleased — watch together
## 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`,
- **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
`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
`/watch/p/<id>` link. The page reuses the TV markup with a `sf-party-` bar (members, host, Share, Leave); the
@@ -29,7 +29,8 @@ OpenVibe.Surf is the Surf room. One room, three surfs:
@@ -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=`
`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 |
| 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
@@ -125,7 +126,7 @@ its status and prints its words. It is `private, no-store`; a `GET /web/go` star
| Route | What it is |
| 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` | 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 |
| `/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 |
| `/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 |
@@ -23,7 +23,7 @@
@@ -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'",
"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",
"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",
"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",
"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/*",
"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",
"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",
@@ -82,7 +82,10 @@ html.ov-motion .sf-run-speed i { animation: sf-speed 1.1s linear infinite; anima
@@ -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;
.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); }
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 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 { 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. */
/* 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)); }
.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)); }
@@ -49,7 +49,11 @@ function createPageRoutes(ctx) {
@@ -49,7 +49,11 @@ function createPageRoutes(ctx) {
});
});
const page = (res, status, html, viewer) => send(res, status, html, { viewer });
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) => {
r.get('/ride', async (req, res) => {
const out = await safe(() => ride.maps());
const out = await safe(() => ride.maps());
@@ -4,7 +4,7 @@
@@ -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
* 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.
* 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
* 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
* 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
* watch({ feed, topic }) the TV: what is on now, the guide (up next), topics
@@ -47,7 +47,40 @@ function runMotif() {
@@ -47,7 +47,40 @@ function runMotif() {
</div>`;
</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">
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-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>
<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() {
@@ -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>`,
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' } },
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.',
{ 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' } },
action: { href: '/watch', label: 'Start watching' } },
],
],
explore: { title: 'More on Surf', items: [
explore: { title: 'More on Surf', items: [
@@ -27,6 +27,64 @@ const { assetVersion } = require('../server/assets');
@@ -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.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.strictEqual(r.headers.get('cache-control'), 'public, max-age=60');
assert.match(r.headers.get('vary'), /Cookie/i);
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 () => {
await check('/ride lists the maps and embeds the first', async () => {