openvibers/openrestream Commits
Give OpenRestream the shared app shell and designed session history (#244)
Changes
nnew file mode 100644@@@ -0,0 +1,32 @@++'use strict';++const kit = require('openvibe-shared/kit');++const { PLATFORMS, markSvg } = require('./platforms');++const esc = kit.esc;++const emptySessions = () => kit.empty({ icon: 'clock', title: 'Your next broadcast starts here', text: 'Your restream sessions show here: each time you go live, where it went and how it did', action: { href: '/go-live', label: 'Go live', icon: 'live' } }) + '<p class="ore-how"><a href="/#how-it-works">How restreaming works</a></p>';++const emptyStreams = () => kit.empty({ icon: 'live', title: 'One stream, every destination', text: 'Create a stream, connect your destinations and go live from your encoder.', action: { href: '/streams#new-stream', label: 'Create a stream', icon: 'plus' } });++function platform(id) {++ const p = PLATFORMS.find(p => p.id === id);++ return `<span class="ore-platform">${p ? markSvg(p) : kit.icon('link')}<span>${esc(p ? p.name : id || 'Destination')}</span></span>`;++}++function scope(all) {++ return `<nav class="ore-scope" aria-label="Session owner"><a href="/sessions"${!all ? ' aria-current="page"' : ''}>Mine</a><a href="/sessions?all=1"${all ? ' aria-current="page"' : ''}>Everyone</a></nav>`;++}++function session(s, definition, outputs = [], destinations = []) {++ const started = new Date(s.live_at || s.created_at);++ const valid = Number.isFinite(started.getTime());++ const running = ['starting', 'live', 'ending'].includes(s.state);++ const minutes = valid && s.ended_at ? Math.max(0, Math.round((Number(s.ended_at) - started.getTime()) / 60000)) : null;++ const length = minutes == null ? (running ? 'In progress' : 'Unavailable') : minutes < 1 ? 'Under a minute' : `${Math.floor(minutes / 60) ? Math.floor(minutes / 60) + 'h ' : ''}${minutes % 60}m`;++ const health = outputs.some(o => o.last_error || ['failed', 'error', 'lost'].includes(o.state)) ? 'Needs attention' : outputs.length ? (running ? 'Delivering' : 'Finished') : 'No output data';++ const title = definition && definition.title || 'Restream session';++ const sid = 'session-' + s.id;++ // Viewers only while it runs and someone is watching here (OpenRestream's own players); the destinations report no++ // totals, so an ended card never shows a number it does not have (the sheet says why).++ const watching = running && Number(s.viewers) > 0 ? `<div><dt>Watching here</dt><dd>${esc(String(Number(s.viewers)))}</dd></div>` : '';++ const metrics = `<dl class="ore-session-metrics"><div><dt>Length</dt><dd>${esc(length)}</dd></div><div><dt>Health</dt><dd>${esc(health)}</dd></div>${watching}</dl>`;++ const marks = `<div class="ore-session-destinations" data-ovk-scroll aria-label="Destinations">${destinations.filter(Boolean).map(d => platform(d.platform)).join('') || '<span class="muted">No destinations recorded</span>'}</div>`;++ const when = valid ? `<time datetime="${started.toISOString()}">${esc(started.toLocaleString('en-US', { timeZone: 'UTC', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }))} UTC</time>` : 'Start unavailable';++ const details = `<p>${when}</p>${metrics}${marks}<p class="muted">Viewer totals are not reported by these destinations.</p>` + kit.button({ href: '/sessions/' + s.id, icon: 'clock', label: 'Full session details', kind: 'quiet' });++ return `<article class="ore-session-card"><div class="ore-session-top"><span class="ovk-heading-icon">${kit.icon('live')}</span>${kit.badge(s.state, running ? 'live' : '')}</div><h2><a href="/sessions/${esc(s.id)}">${esc(title)}</a></h2><p class="muted">${when}</p>${metrics}${marks}<div class="ore-session-actions">${kit.sheetButton({ sheet: sid, icon: 'list', label: 'Session details', iconOnly: false })}</div>${kit.sheet({ id: sid, title, body: details })}</article>`;++}++module.exports = { emptySessions, emptyStreams, platform, scope, session };@@@ -34,10 +34,10 @@ main.ore .sc-hero,main.ore .sc-sec,main.ore .sc-cta{padding-left:0;padding-right.ore-stats{margin:0;display:grid;gap:10px}.ore-stats div{border:1px solid var(--ore-line);border-radius:12px;padding:12px 16px;background:var(--bg-card,#131c2e)}.ore-stats dt{font-size:1.25rem;font-weight:700}.ore-stats dd{margin:2px 0 0;color:var(--ore-muted);font-size:.92rem}/* The hero (11-07): the signal flow, one stream in, every platform out, a switch each. */--.ore-hero{display:grid;gap:28px;align-items:center;padding:28px 0 8px}++.ore-hero{display:grid;grid-template-columns:minmax(0,1fr);gap:28px;align-items:center;padding:28px 0 8px}@media (min-width:900px){.ore-hero{grid-template-columns:minmax(0,1fr) minmax(0,1.05fr);gap:40px;padding:56px 0 16px}}.ore-eyebrow{margin:0 0 10px;font-size:.78rem;font-weight:800;letter-spacing:.09em;text-transform:uppercase;color:color-mix(in srgb,var(--accent,#3b82f6) 70%,var(--ore-text))}--.ore-hero h1{margin:0;font-size:clamp(2.1rem,6vw,3.6rem);line-height:1.02;letter-spacing:-.045em;text-wrap:balance}++.ore-hero-copy{min-width:0}.ore-hero h1{margin:0;font-size:clamp(2.1rem,6vw,3.6rem);line-height:1.02;letter-spacing:-.045em;text-wrap:balance}.ore-accent{background:linear-gradient(100deg,#9146FF,#FF0033 45%,#53FC18 90%);-webkit-background-clip:text;background-clip:text;color:transparent}.ore-lede{margin:16px 0 0;max-width:52ch;font-size:1.06rem;line-height:1.6;color:var(--ore-muted)}.ore-actions{display:flex;flex-wrap:wrap;gap:10px;margin-top:22px}@@@ -110,7 +110,28 @@ main.ore .sc-hero,main.ore .sc-sec,main.ore .sc-cta{padding-left:0;padding-right.ore .nf-links a{display:inline-block;padding:9px 16px;border-radius:999px;border:1px solid var(--ore-line);background:var(--ore-panel);color:var(--ore-text);text-decoration:none;font-weight:600}.ore .nf-links a:hover,.ore .nf-links a:focus-visible{border-color:var(--ore-accent);outline:0}.ore form:has(input[name=record_to][value=videos]:checked) .ore-live-only{display:none}--@media (max-width:640px){.ore th:nth-child(n+4),.ore td:nth-child(n+4){display:none}}++.ore .ovk-page-head h1{margin:0}.ore .ovk-page-head a,.ore .ovk-btn,.ore .ovk-icon-btn{color:inherit;text-decoration:none}++.ore .ovk-btn--primary{color:var(--on-accent-strong,#fff)}++.ore-session-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(100%,320px),1fr));gap:16px}++.ore-session-card{padding:20px;background:var(--ore-panel);border:1px solid var(--ore-line);border-radius:20px;min-width:0}++.ore-session-card h2{margin:14px 0 4px;font-size:1.2rem}.ore-session-card h2 a{color:var(--ore-text);text-decoration:none}++.ore-session-top{display:flex;justify-content:space-between;align-items:center}++.ore-session-metrics{display:grid;grid-template-columns:repeat(auto-fit,minmax(88px,1fr));gap:12px;margin:18px 0}++.ore-session-metrics dt{font-size:.8rem;color:var(--ore-muted)}.ore-session-metrics dd{margin:4px 0 0;font-weight:650;overflow-wrap:anywhere}++.ore-session-destinations{display:flex;gap:8px;overflow:auto;position:relative;padding-bottom:8px}++.ore-platform{display:inline-flex;align-items:center;gap:7px;white-space:nowrap;border-radius:999px;background:var(--bg-secondary);padding:6px 10px;font-size:.85rem}++.ore-platform svg{width:20px;height:20px;flex:none}++.ore-session-actions{display:flex;justify-content:flex-end;margin-top:12px}++.ore-scope{display:flex;width:fit-content;gap:4px;padding:4px;border-radius:999px;background:var(--bg-secondary);border:1px solid var(--ore-line);margin-bottom:20px}++.ore-scope a{display:flex;align-items:center;justify-content:center;min-height:44px;min-width:90px;border-radius:999px;color:var(--ore-muted);text-decoration:none}++.ore-scope a[aria-current]{background:color-mix(in srgb,var(--ore-accent) 22%,var(--ore-panel));color:var(--ore-text);font-weight:700;box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--ore-accent) 45%,transparent)}++.ore-scope a:not([aria-current]):hover{color:var(--ore-text)}++.ore-how{text-align:center}.ore-how a{display:inline-flex;align-items:center;min-height:44px}++.ore :where(button,input,select){min-height:44px}.ore input[type=checkbox],.ore input[type=radio]{min-height:auto}++.ore table{display:block;overflow-x:auto;position:relative}.ore .ovk-sheet{color:var(--ore-text)}++@media(min-width:1000px){main.ore{padding-top:36px}}++@media(max-width:760px){main.ore{padding-top:16px}.ore-session-card{padding:16px}.ore .ovk-heading-icon{flex-shrink:0}}++/* Touch targets on phones (fleet audit, 2026-10-29): touch pointers only. */@media (pointer: coarse) {.ore-faq summary { min-height: 44px; }@@@ -145,7 +166,11 @@ function renderPage({ title, body, user, canonicalPath = '/', robots = 'noindex,links: [{ label: 'Streams', href: '/streams', icon: 'fa-tower-broadcast' },{ label: 'Sessions', href: '/sessions', icon: 'fa-clock-rotate-left' },++ { label: 'Destinations', href: '/destinations', icon: 'fa-satellite-dish' },++ { label: 'Go live', href: '/go-live', icon: 'fa-circle-play' },++ { label: 'You', href: '/settings', icon: 'fa-user' },],++ appBar: true,sessionUrl: '/auth/me',silentLogin: '/auth/login?silent=1&next={url}',loginUrl: `/auth/login?next=${encodeURIComponent(canonicalPath)}`,@@@ -173,8 +198,9 @@ ${icon}<script src="${ovServe.url('theme-loader.js')}" defer></script><style>${CSS}</style><!-- The shared showcase stylesheet on every page, not only the home: inner pages use its buttons (sc-btn), and without it a sign-in button drew as a bare link (phone sweep, 2026-11-01). It is scoped to its own sc- classes. -->--${[...new Set(['showcase.css', ...styles])].map((name) => `<link rel="stylesheet" href="${esc(ovServe.url(name))}">`).join('\n')}++${[...new Set(['showcase.css', 'kit.css', 'kit-data.css', ...styles])].map((name) => `<link rel="stylesheet" href="${esc(ovServe.url(name))}">`).join('\n')}<script src="${ovServe.url('navbar.js')}" defer></script>++<script src="${ovServe.url('kit-web.js')}" defer></script><script src="${ovServe.url('footer.js')}" defer></script></head><body>@@@ -20,6 +20,8 @@ const ovServe = require('openvibe-shared/serve');const seo = require('openvibe-shared/seo');const frame = require('openvibe-shared/frame');const showcase = require('openvibe-shared/showcase');++const kit = require('openvibe-shared/kit');++const cards = require('./cards');const express = require('express');const { renderPage, esc } = require('./layout');const { TWIN_PATH_RE } = require('../agent-pages');@@@ -84,7 +86,7 @@ function createUiRouter({ rt, auth, limits = null, liveLink = null }) {if (req.caller.kind === 'user' && req.caller.subject) return true;page(req, res, {title: 'Sign in', canonicalPath: req.path,-- body: `<h1>Sign in</h1><p>OpenRestream uses your OpenVibe account.</p><p><a class="sc-btn sc-primary" href="/auth/login?next=${encodeURIComponent(req.originalUrl)}">Sign in with OpenVibe</a></p>`,++ body: `${kit.pageHead({ title: 'Sign in', icon: 'user', back: { href: '/', label: 'Home' } })}<p>OpenRestream uses your OpenVibe account.</p><p><a class="sc-btn sc-primary" href="/auth/login?next=${encodeURIComponent(req.originalUrl)}">Sign in with OpenVibe</a></p>`,}, 401);return false;}@@@ -93,14 +95,14 @@ function createUiRouter({ rt, auth, limits = null, liveLink = null }) {const given = String((req.body && req.body._csrf) || '');const expected = csrfFor(req.caller.token || '');if (given.length === expected.length && crypto.timingSafeEqual(Buffer.from(given), Buffer.from(expected))) return true;-- page(req, res, { title: 'Expired form', body: '<h1>That form expired</h1><p>Go back, reload the page and try again.</p>' }, 403);++ page(req, res, { title: 'Expired form', body: kit.pageHead({ title: 'That form expired', icon: 'help', lede: 'Go back, reload the page and try again.', back: { href: '/', label: 'Home' } }) }, 403);return false;}async function ownDefinition(req, res, id) {const d = await store.definitions.get(id);if (!d || d.state === 'archived' || !auth.canAccess(req.caller, d.owner_subject)) {-- page(req, res, { title: 'Not found', body: '<h1>Stream not found</h1><p><a href="/streams">Your streams</a></p>' }, 404);++ page(req, res, { title: 'Not found', body: kit.pageHead({ title: 'Stream not found', icon: 'help', back: { href: '/streams', label: 'Streams' } }) }, 404);return null;}return d;@@@ -109,7 +111,7 @@ function createUiRouter({ rt, auth, limits = null, liveLink = null }) {async function ownDestination(req, res, id) {const dest = await store.outputs.destinationRow(id);const d = dest ? await ownDefinition(req, res, dest.definition_id) : null;-- if (!dest && !res.headersSent) page(req, res, { title: 'Not found', body: '<h1>Destination not found</h1>' }, 404);++ if (!dest && !res.headersSent) page(req, res, { title: 'Not found', body: kit.pageHead({ title: 'Destination not found', icon: 'help', back: { href: '/', label: 'Home' } }) }, 404);return d ? { dest, definition: d } : null;}@@@ -157,7 +159,7 @@ function createUiRouter({ rt, auth, limits = null, liveLink = null }) {name: 'OpenRestream', url: `${config.baseUrl}/`, description: FRONT_PAGE_DESCRIPTION, category: 'MultimediaApplication',}), seo.jsonLd.faq(FRONT_FAQ)],body: `${frontPage(config, f)}--${signedIn ? `<h2>Your streams</h2>${mine.length ? await streamTable(mine) : '<p class="muted">No streams yet.</p>'}<p><a href="/streams">Manage streams</a></p>` : '<p><a href="/auth/login?next=/streams">Sign in with OpenVibe</a> to manage your streams.</p>'}++${signedIn ? kit.section({ title: 'Your streams', icon: 'live', actions: [kit.button({ href: '/streams', label: 'Manage streams', icon: 'gear', kind: 'quiet' })], body: mine.length ? await streamTable(mine) : cards.emptyStreams() }) : '<p><a href="/auth/login?next=/streams">Sign in with OpenVibe</a> to manage your streams.</p>'}${frame.shipped({ service: 'openre', title: 'Recently shipped on OpenRestream' })}`,});});@@@ -190,7 +192,7 @@ ${frame.shipped({ service: 'openre', title: 'Recently shipped on OpenRestream' }['Recording', 'Per stream: a VOD, clips only, or nothing, saved to your OpenVibe.Video'],['Minimum', 'None: no follower, subscriber or eligibility threshold'],];-- return `<section class="sc-sec ore-tier" aria-labelledby="ore-tier-h"><h2 id="ore-tier-h">The free tier, in numbers</h2>++ return `<section class="sc-sec ore-tier" aria-labelledby="ore-tier-h"><h2 id="ore-tier-h" class="ovk-titled">${kit.icon('list', { cls: 'ovk-heading-icon' })}The free tier, in numbers</h2><dl class="ore-tier-grid">${rows.map(([k, v]) => `<div><dt>${esc(k)}</dt><dd>${esc(v)}</dd></div>`).join('')}</dl></section>`;}@@@ -216,19 +218,19 @@ ${frame.shipped({ service: 'openre', title: 'Recently shipped on OpenRestream' }<ul class="ore-dests">${dests}<li class="ore-dest ore-dest-own" style="--c:var(--accent,#3b82f6);--i:${PLATFORMS.length}"><label><input type="checkbox" class="ore-dest-in" checked><span class="ore-wire" aria-hidden="true"></span><span class="ore-dest-mark ore-dest-ov" aria-hidden="true">OV</span><span class="ore-dest-name">OpenVibe</span><span class="ore-switch" aria-hidden="true"></span></label></li></ul><p class="ore-total" aria-live="polite">Live on <span class="ore-n"></span> places at once</p></div>`;-- const hero = `<section class="ore-hero" aria-labelledby="ore-hero-h">++ const hero = `<section class="ore-hero" aria-label="OpenRestream"><div class="ore-hero-copy"><p class="ore-eyebrow">Open restreaming · free · no follower minimum</p>--<h1 id="ore-hero-h">Go live once.<br><span class="ore-accent">Stream everywhere.</span></h1>--<ul class="ore-logos" aria-label="Streams to">${PLATFORMS.map((pl) => `<li style="--c:${pl.color}" title="${esc(pl.name)}">${markSvg(pl)}<span class="ore-sr">${esc(pl.name)}</span></li>`).join('')}</ul>--<p class="ore-lede">Send one stream from OBS or your browser to Twitch, YouTube, Kick and any RTMP or SRT server at once. Your OpenVibe Live channel comes with it.</p>++${kit.pageHead({ title: 'Go live once. Stream everywhere.', icon: 'live', lede: 'Send one stream from OBS or your browser to Twitch, YouTube, Kick and any RTMP or SRT server at once. Your OpenVibe Live channel comes with it.' })}++<ul data-ovk-scroll="drift" class="ore-logos" aria-label="Streams to">${PLATFORMS.map((pl) => `<li style="--c:${pl.color}" title="${esc(pl.name)}">${markSvg(pl)}<span class="ore-sr">${esc(pl.name)}</span></li>`).join('')}</ul>++<div class="ore-actions"><a class="ore-btn ore-btn-primary" href="/streams">Start restreaming</a><a class="ore-btn" href="${esc(live)}">Go live from your browser</a></div><p class="ore-note">Alpha: RTMP in; RTMP, RTMPS and SRT out. Going live from a browser happens on OpenVibe.Live for now (<a href="${esc(live)}/docs/go-live-in-your-browser">how it works</a>).</p></div>${flow}</section>--${stats.length ? `<dl class="ore-stats ore-stats-strip">${stats.map(([v, d]) => `<div><dt>${n(v)}</dt><dd>${esc(d)}</dd></div>`).join('')}</dl>` : ''}`;-- return hero + showcase.features({++${stats.length ? `<dl data-ovk-scroll class="ore-stats ore-stats-strip">${stats.map(([v, d]) => `<div><dt>${n(v)}</dt><dd>${esc(d)}</dd></div>`).join('')}</dl>` : ''}`;++ const content = hero + showcase.features({title: 'What OpenRestream does',items: [{ icon: 'ov:live', title: 'One stream, every platform', text: 'Twitch, YouTube, Kick and any RTMP, RTMPS or SRT server at once. Each destination has its own health, logs and retries.' },@@@ -244,7 +246,7 @@ ${stats.length ? `<dl class="ore-stats ore-stats-strip">${stats.map(([v, d]) =>lede: 'Add each destination once, test it, and it goes live with you every time.',items: DESTINATIONS.map((d) => ({ title: d.name, text: d.text })),}) + showcase.steps({-- title: 'Restream with OBS',++ id: 'how-it-works', title: 'Restream with OBS',items: [{ title: 'Sign in', text: 'With your OpenVibe account.', href: '/auth/login?next=/streams' },{ title: 'Create a stream', text: 'Copy the RTMP server and key; the key is shown only once. OpenVibe Live is already ticked.' },@@@ -255,17 +257,17 @@ ${stats.length ? `<dl class="ore-stats ore-stats-strip">${stats.map(([v, d]) =>title: 'OBS settings',lede: 'In OBS, open Settings → Stream, choose Custom, and paste the server and the key from your stream\'s page.',samples: [{ label: 'Settings → Stream', code: `Service: Custom...\nServer: ${ingestServer(cfg)}\nStream Key: (from your stream's page, shown once)` }],-- }) + `<section class="sc-sec ore-faq" aria-labelledby="ore-faq-h"><h2 id="ore-faq-h">Questions</h2>++ }) + `<section class="sc-sec ore-faq" aria-labelledby="ore-faq-h"><h2 id="ore-faq-h" class="ovk-titled">${kit.icon('help', { cls: 'ovk-heading-icon' })}Questions</h2>${FRONT_FAQ.map((f) => `<details><summary>${esc(f.q)}</summary><p>${esc(f.a)}</p></details>`).join('\n')}</section>`;++ return content.replace(/<h2([^>]*)>(What OpenRestream does|Where it can stream|Restream with OBS|OBS settings)<\/h2>/g, (_, attrs, title) => `<h2${attrs} class="ovk-titled">${kit.icon('live', { cls: 'ovk-heading-icon' })}${title}</h2>`);}async function streamTable(list) {-- return `<table><tr><th>Stream</th><th>State</th><th>Now</th><th>Linked to</th></tr>${(await Promise.all(list.map(async (d) => {++ return kit.list(await Promise.all(list.map(async d => {const open = (await store.sessions.openFor(d.id))[0];-- const refs = d.external_refs.map(r => `${r.service}:${r.type}:${r.id}`).join(', ');-- return `<tr><td><a href="/streams/${esc(d.id)}">${esc(d.title)}</a></td><td>${pill(d.state)}</td><td>${open ? pill(open.state) : '<span class="muted">offline</span>'}</td><td class="mono">${esc(refs) || '—'}</td></tr>`;-- }))).join('')}</table>`;++ return kit.row({ href: '/streams/' + d.id, mark: kit.icon('live'), title: d.title, sub: open ? 'Live now' : 'Ready for your next broadcast', meta: d.state, badge: open ? kit.badge(open.state, 'live') : '' });++ })));}router.get('/streams', async (req, res) => {@@@ -273,8 +275,8 @@ ${FRONT_FAQ.map((f) => `<details><summary>${esc(f.q)}</summary><p>${esc(f.a)}</pconst list = await store.definitions.list({ owner_subject: req.caller.subject });page(req, res, {title: 'Streams', canonicalPath: '/streams',-- body: `<h1>Streams</h1>${list.length ? await streamTable(list) : '<p class="muted">No streams yet.</p>'}--<div class="card"><h2>New stream</h2>${form(req, '/streams', `++ body: `${kit.pageHead({ title: 'Streams', icon: 'live', back: { href: '/', label: 'Home' }, lede: 'Set up once. Send your next stream everywhere.', actions: [{ href: '#new-stream', icon: 'plus', label: 'New stream' }] })}${list.length ? await streamTable(list) : cards.emptyStreams()}++<div class="card" id="new-stream"><h2 class="ovk-titled">${kit.icon('plus', { cls: 'ovk-heading-icon' })}New stream</h2>${form(req, '/streams', `<label>Title</label><input type="text" name="title" maxlength="140" required>${whereItGoes(null)}<div class="ore-live-only"><div class="row"><div><label>Recording</label><select name="recording_mode"><option value="vod">Record a VOD</option><option value="clips">Clips only</option><option value="none">Do not record</option></select></div>@@@ -315,7 +317,7 @@ ${option('videos', 'Record only, to my videos', `Nothing goes live. Each sessionres.set('Cache-Control', 'no-store');page(req, res, {title: heading, canonicalPath: `/streams/${definition.id}`,-- body: `<h1>${esc(heading)}</h1>++ body: `${kit.pageHead({ title: heading, icon: 'key', back: { href: '/streams', label: 'Streams' } })}<div class="card"><p><strong>Copy the key now.</strong> OpenRestream stores only a hash of it; this page is the only time it is shown.</p><label>Server (OBS: Settings → Stream → Custom)</label><code class="secret">${esc(ep.rtmp ? ep.rtmp.url : '')}</code><label>Stream key</label><code class="secret">${esc(key.key)}</code></div>@@@ -336,6 +338,24 @@ ${liveNote(live)}${recordNote(definition)}await keyPage(req, res, definition, key, 'Stream created', live);});++ router.get('/destinations', async (req, res) => {++ if (!needUser(req, res)) return;++ const definitions = await store.definitions.list({ owner_subject: req.caller.subject });++ const rows = (await Promise.all(definitions.map(async d => (await store.outputs.destinations(d.id)).map(x =>++ kit.row({ href: '/destinations/' + x.id, mark: cards.platform(x.platform), title: x.name || x.platform, sub: d.title, meta: x.enabled ? 'Enabled' : 'Disabled' })++ )))).flat();++ page(req, res, { title: 'Destinations', canonicalPath: '/destinations', body: kit.pageHead({ title: 'Destinations', icon: 'link', back: { href: '/', label: 'Home' }, lede: 'Every place your streams go.', actions: [{ href: '/streams', icon: 'plus', label: 'Add a destination' }] }) + (rows.length ? kit.list(rows) : kit.empty({ icon: 'link', title: 'Choose your destinations', text: 'Open a stream to connect Twitch, YouTube, Kick or your own server.', action: { href: '/streams', icon: 'plus', label: 'Choose a stream' } })) });++ });++ router.get('/go-live', async (req, res) => {++ if (!needUser(req, res)) return;++ const definitions = await store.definitions.list({ owner_subject: req.caller.subject });++ page(req, res, { title: 'Go live', canonicalPath: '/go-live', body: kit.pageHead({ title: 'Go live', icon: 'live', back: { href: '/', label: 'Home' }, lede: 'Choose your encoder, check your destinations and start broadcasting.' }) + kit.section({ title: 'From your browser', icon: 'video', body: kit.empty({ icon: 'video', text: 'Use your camera or share your screen on OpenVibe Live.', action: { href: config.liveUrl + '/broadcast', icon: 'live', label: 'Open the studio' } }) }) + kit.section({ title: 'From OBS or an encoder', icon: 'live', body: definitions.length ? await streamTable(definitions) : cards.emptyStreams() }) });++ });++ router.get('/settings', async (req, res) => {++ if (!needUser(req, res)) return;++ page(req, res, { title: 'You', canonicalPath: '/settings', body: kit.pageHead({ title: 'You', icon: 'user', back: { href: '/', label: 'Home' }, lede: 'Your account and stream preferences.' }) + kit.section({ title: 'Stream settings', icon: 'gear', body: kit.empty({ icon: 'gear', text: 'Recording, playback and destination settings belong to each stream.', action: { href: '/streams', label: 'Manage streams', icon: 'gear' } }) }) + kit.section({ title: 'Your account', icon: 'user', body: kit.button({ href: 'https://openvibe.network/settings', label: 'Account settings', icon: 'gear', kind: 'quiet' }) + kit.button({ href: '/auth/logout', label: 'Sign out', icon: 'back', kind: 'quiet' }) }) });++ });++router.get('/streams/:id', async (req, res) => {if (!needUser(req, res)) return;const d = await ownDefinition(req, res, req.params.id);@@@ -349,28 +369,28 @@ ${liveNote(live)}${recordNote(definition)}const outFor = async (destId) => await outputs.find(o => o.destination_id === destId);page(req, res, {title: d.title, canonicalPath: `/streams/${d.id}`,-- body: `<h1>${esc(d.title)} ${pill(d.state)}</h1>++ body: `${kit.pageHead({ title: d.title, icon: 'live', back: { href: '/streams', label: 'Streams' }, actions: [{ href: '#stream-settings', label: 'Settings', icon: 'gear' }] })}${pill(d.state)}<p class="muted mono">${esc(d.id)}${d.external_refs.length ? ` · linked to ${esc(d.external_refs.map(r => `${r.service}:${r.type}:${r.id}`).join(', '))}` : ''}</p>${d.record_to ? recordNote(d) : liveStatus(d, req.query.live)}--<div class="card"><h2>Ingest</h2>++<div class="card"><h2 class="ovk-titled">${kit.icon('live', { cls: 'ovk-heading-icon' })}Ingest</h2>${ep.rtmp ? `<label>RTMP server</label><code class="secret">${esc(ep.rtmp.url)}</code>` : '<p class="muted">RTMP is not enabled for this stream.</p>'}--<table><tr><th>Key</th><th>Status</th><th>Created</th><th>Last used</th></tr>${keys.map(k => `<tr><td class="mono">ork_…${esc(k.hint)}</td><td>${pill(k.status)}${k.grace_until ? ` until ${esc(iso(k.grace_until))}` : ''}</td><td>${esc(iso(k.created_at))}</td><td>${esc(iso(k.last_used_at))}</td></tr>`).join('') || '<tr><td colspan="4">No usable key: rotate to get one.</td></tr>'}</table>++<table data-ovk-scroll><tr><th>Key</th><th>Status</th><th>Created</th><th>Last used</th></tr>${keys.map(k => `<tr><td class="mono">ork_…${esc(k.hint)}</td><td>${pill(k.status)}${k.grace_until ? ` until ${esc(iso(k.grace_until))}` : ''}</td><td>${esc(iso(k.created_at))}</td><td>${esc(iso(k.last_used_at))}</td></tr>`).join('') || '<tr><td colspan="4">No usable key: rotate to get one.</td></tr>'}</table>${form(req, `/streams/${d.id}/rotate`, `<div class="row"><div><label>Old key stays valid for (seconds, 0 = revoke now)</label><input type="number" name="grace_seconds" min="0" max="604800" value="0"></div><div><label><input type="checkbox" name="end_sessions" value="1"> also disconnect a running session</label></div></div><button type="submit" class="danger">Rotate key</button>`, { confirm: 'Issue a new key and retire the current one?' })}</div>--<div class="card"><h2>Now</h2>${open ? `<p>${pill(open.state)} since ${esc(iso(open.live_at || open.created_at))} on ${esc(open.worker_kind)} generation ${esc(open.worker_generation)} · <a href="/sessions/${esc(open.id)}">session</a></p>++<div class="card"><h2 class="ovk-titled">${kit.icon('live', { cls: 'ovk-heading-icon' })}Now</h2>${open ? `<p>${pill(open.state)} since ${esc(iso(open.live_at || open.created_at))} on ${esc(open.worker_kind)} generation ${esc(open.worker_generation)} · <a href="/sessions/${esc(open.id)}">session</a></p>${form(req, `/sessions/${open.id}/end`, '<button type="submit" class="danger">End session</button>', { cls: 'inline', confirm: 'Disconnect the encoder?' })}` : '<p class="muted">Offline.</p>'}</div>--<div class="card"><h2>Restream destinations</h2>${d.record_to ? '<p class="muted">Off while this stream records only to your videos.</p>' : ''}--${dests.length ? `<table><tr><th>Destination</th><th>Output</th><th>Health</th><th>Actions</th></tr>${(await Promise.all(dests.map(async (x) => {++<div class="card"><h2 class="ovk-titled">${kit.icon('link', { cls: 'ovk-heading-icon' })}Restream destinations</h2>${d.record_to ? '<p class="muted">Off while this stream records only to your videos.</p>' : ''}++${dests.length ? `<table data-ovk-scroll><tr><th>Destination</th><th>Output</th><th>Health</th><th>Actions</th></tr>${(await Promise.all(dests.map(async (x) => {const o = await outFor(x.id);return `<tr><td><strong>${esc(x.name || x.platform)}</strong> <span class="muted">${esc(x.platform)}</span><br><span class="mono">${esc(x.server_url)}</span><br><span class="muted">key ${esc(x.stream_key_hint || 'not set')}${x.enabled ? '' : ' · disabled'}${x.auto_start ? ' · auto-start' : ''}${x.hold_reason ? ` · held: ${esc(x.hold_reason)}` : ''}${x.cooldown_ms ? ` · cooling down until ${esc(x.cooldown_until)}` : ''}</span></td><td>${o ? pill(o.state) : '<span class="muted">—</span>'}</td><td>${o && o.progress ? `${esc(o.progress.fps ?? '?')} fps · ${esc(o.progress.bitrate_kbps ?? '?')} kbit/s · speed ${esc(o.progress.speed ?? '?')}` : ''}${o && o.last_error ? `<br><span class="muted">${esc(o.last_error)}</span>` : ''}${!o && x.last_error ? `<span class="muted">${esc(x.last_error)}</span>` : ''}</td><td>${form(req, `/destinations/${x.id}/test`, '<button type="submit" class="secondary">Test</button>', { cls: 'inline' })}${open && !d.record_to ? (o && ['pending', 'starting', 'live', 'error'].includes(o.state) ? form(req, `/destinations/${x.id}/stop`, '<button type="submit" class="secondary">Stop</button>', { cls: 'inline' }) : form(req, `/destinations/${x.id}/start`, '<button type="submit">Start</button>', { cls: 'inline' })) : ''}<a href="/destinations/${esc(x.id)}">Edit</a>${form(req, `/destinations/${x.id}/delete`, '<button type="submit" class="danger">Delete</button>', { cls: 'inline', confirm: 'Delete this destination?' })}</td></tr>`;-- }))).join('')}</table>` : '<p class="muted">No destinations.</p>'}++ }))).join('')}</table>` : kit.empty({ icon: 'link', text: 'Add the places this stream should go below.' })}<h3>Add a destination</h3>${form(req, `/streams/${d.id}/destinations`, destinationFields({}))}</div>--<div class="card"><h2>Settings</h2>${form(req, `/streams/${d.id}`, `++<div class="card"><h2 class="ovk-titled" id="stream-settings">${kit.icon('gear', { cls: 'ovk-heading-icon' })}Settings</h2>${form(req, `/streams/${d.id}`, `<label>Title</label><input type="text" name="title" maxlength="140" value="${esc(d.title)}">${whereItGoes(d.record_to)}<div class="row"><div class="ore-live-only"><label>Recording</label><select name="recording_mode">${['vod', 'clips', 'none'].map(m => `<option value="${m}"${d.recording_mode === m ? ' selected' : ''}>${m}</option>`).join('')}</select></div>@@@ -379,7 +399,7 @@ ${whereItGoes(d.record_to)}<div><label>State</label><select name="state">${['active', 'disabled'].map(m => `<option${d.state === m ? ' selected' : ''}>${m}</option>`).join('')}</select></div></div><label class="ore-live-only"><input type="checkbox" name="mirror_to_live" value="1"${d.mirror_to_live ? ' checked' : ''}> Show it live on my OpenVibe Live channel too</label><button type="submit">Save</button>`)}</div>--<h2>Sessions</h2>${sessionTable(sessions)}`,++<h2 class="ovk-titled">${kit.icon('clock', { cls: 'ovk-heading-icon' })}Sessions</h2>${await sessionTable(sessions)}`,});});@@@ -417,9 +437,14 @@ ${whereItGoes(d.record_to)}return out;}-- function sessionTable(list) {-- if (!list.length) return '<p class="muted">No sessions yet.</p>';-- return `<table><tr><th>Session</th><th>State</th><th>Started</th><th>Ended</th><th>Worker</th></tr>${list.map(s => `<tr><td><a class="mono" href="/sessions/${esc(s.id)}">${esc(s.id)}</a></td><td>${pill(s.state)}${s.failure_reason ? ` <span class="muted">${esc(s.failure_reason)}</span>` : ''}</td><td>${esc(iso(s.live_at || s.created_at))}</td><td>${esc(iso(s.ended_at))}</td><td>${esc(s.worker_kind)} #${esc(s.worker_generation)}</td></tr>`).join('')}</table>`;++ async function sessionTable(list) {++ if (!list.length) return cards.emptySessions();++ return `<div class="ore-session-grid">${(await Promise.all(list.map(async s => {++ const definition = await store.definitions.get(s.definition_id);++ const outputs = await store.outputs.outputsOfSession(s.id);++ const destinations = await Promise.all(outputs.map(o => store.outputs.getDestination(o.destination_id)));++ return cards.session(s, definition, outputs, destinations);++ }))).join('')}</div>`;}post('/streams/:id', async (req, res) => {@@@ -461,9 +486,9 @@ ${whereItGoes(d.record_to)}const logs = await store.outputs.logsOfDestination(x.id, 50);page(req, res, {title: `Destination ${x.name || x.platform}`, canonicalPath: `/destinations/${x.id}`,-- body: `<h1>${esc(x.name || x.platform)}</h1><p><a href="/streams/${esc(own.definition.id)}">← ${esc(own.definition.title)}</a></p>++ body: `${kit.pageHead({ title: x.name || x.platform, icon: 'link', back: { href: '/streams/' + own.definition.id, label: own.definition.title }, lede: 'Connection settings and delivery history.' })}<div class="card">${form(req, `/destinations/${x.id}`, destinationFields(x))}</div>--<h2>Log</h2>${logs.length ? `<table><tr><th>When</th><th>Level</th><th>Message</th></tr>${logs.map(l => `<tr><td>${esc(iso(l.at))}</td><td>${pill(l.level === 'error' ? 'failed' : l.level === 'warn' ? 'error' : 'ok')}</td><td class="mono">${esc(l.message)}</td></tr>`).join('')}</table>` : '<p class="muted">Nothing logged yet.</p>'}`,++<h2 class="ovk-titled">${kit.icon('list', { cls: 'ovk-heading-icon' })}Log</h2>${logs.length ? `<table data-ovk-scroll><tr><th>When</th><th>Level</th><th>Message</th></tr>${logs.map(l => `<tr><td>${esc(iso(l.at))}</td><td>${pill(l.level === 'error' ? 'failed' : l.level === 'warn' ? 'error' : 'ok')}</td><td class="mono">${esc(l.message)}</td></tr>`).join('')}</table>` : '<p class="muted">Nothing logged yet.</p>'}`,});});@@@ -488,7 +513,7 @@ ${whereItGoes(d.record_to)}await store.outputs.log(null, own.dest.id, result.ok ? 'info' : 'warn', `test ${result.ok ? 'passed' : 'failed'}: ${result.checks.map(c => `${c.check} ${c.ok ? 'ok' : 'FAIL'} (${c.detail})`).join('; ')}`);page(req, res, {title: 'Destination test',-- body: `<h1>Test ${result.ok ? pill('ok') : pill('failed')}</h1><table>${result.checks.map(c => `<tr><td>${esc(c.check)}</td><td>${c.ok ? pill('ok') : pill('failed')}</td><td>${esc(c.detail)}</td></tr>`).join('')}</table>++ body: `${kit.pageHead({ title: 'Destination test', icon: 'check', back: { href: '/destinations', label: 'Destinations' } })}${result.ok ? pill('ok') : pill('failed')}<table data-ovk-scroll>${result.checks.map(c => `<tr><td>${esc(c.check)}</td><td>${c.ok ? pill('ok') : pill('failed')}</td><td>${esc(c.detail)}</td></tr>`).join('')}</table><p class="muted">A passing test means the ingest is reachable. Whether the platform accepts the key shows once an output runs.</p><p><a href="/streams/${esc(own.definition.id)}">Back</a></p>`,});});@@@ -510,22 +535,22 @@ ${whereItGoes(d.record_to)}router.get('/sessions', async (req, res) => {if (!needUser(req, res)) return;const all = req.caller.staff && req.query.all === '1';-- page(req, res, { title: 'Sessions', canonicalPath: '/sessions', body: `<h1>Sessions</h1>${req.caller.staff ? `<p class="muted"><a href="/sessions${all ? '' : '?all=1'}">${all ? 'Only mine' : 'Everyone (staff)'}</a></p>` : ''}${sessionTable(await store.sessions.list({ owner_subject: all ? undefined : req.caller.subject, limit: 100 }))}` });++ page(req, res, { title: 'Sessions', canonicalPath: '/sessions', body: `${kit.pageHead({ title: 'Sessions', icon: 'clock', back: { href: '/', label: 'Home' }, lede: 'Your live history, from first frame to final viewer.', actions: [{ href: '/go-live', label: 'Go live', icon: 'live' }] })}${req.caller.staff ? cards.scope(all) : ''}${await sessionTable(await store.sessions.list({ owner_subject: all ? undefined : req.caller.subject, limit: 100 }))}` });});router.get('/sessions/:id', async (req, res) => {if (!needUser(req, res)) return;const s = await store.sessions.get(req.params.id);const d = s ? await store.definitions.row(s.definition_id) : null;-- if (!s || !d || !auth.canAccess(req.caller, d.owner_subject)) return page(req, res, { title: 'Not found', body: '<h1>Session not found</h1>' }, 404);++ if (!s || !d || !auth.canAccess(req.caller, d.owner_subject)) return page(req, res, { title: 'Not found', body: kit.pageHead({ title: 'Session not found', icon: 'help', back: { href: '/', label: 'Home' } }) }, 404);const outputs = await store.outputs.outputsOfSession(s.id);const rec = await store.recordings.bySession(s.id);const pb = await store.sessions.playback(s);const open = ['starting', 'live', 'ending'].includes(s.state);return page(req, res, {title: `Session ${s.id}`, canonicalPath: `/sessions/${s.id}`,-- body: `<h1>Session ${pill(s.state)}</h1><p class="mono">${esc(s.id)}</p><p><a href="/streams/${esc(d.id)}">← ${esc(d.title)}</a></p>--<div class="card"><table>++ body: `${kit.pageHead({ title: d.title || 'Session', icon: 'clock', back: { href: '/sessions', label: 'Sessions' }, lede: 'Session details, output health and lifecycle.' })}${pill(s.state)}++<div class="card"><table data-ovk-scroll><tr><th>Protocol</th><td>${esc(s.protocol)}</td></tr><tr><th>Worker</th><td>${esc(s.worker_kind)} generation ${esc(s.worker_generation)} <span class="mono muted">${esc(s.worker_id)}</span></td></tr><tr><th>Live since</th><td>${esc(iso(s.live_at))}</td></tr><tr><th>Ended</th><td>${esc(iso(s.ended_at))} ${esc(s.end_reason || s.failure_reason || '')}</td></tr>@@@ -533,15 +558,15 @@ ${whereItGoes(d.record_to)}<tr><th>Recording</th><td>${rec ? `${pill(rec.state)} ${rec.media_vod_id ? `Media ${esc(rec.media_app)} VOD ${esc(rec.media_vod_id)}` : ''} ${esc(rec.last_error || '')}${rec.media_app === config.media.videosAppId ? `<br>Private, in <a href="${esc(config.videoUrl)}/me/videos">your videos on OpenVibe.Video</a> once it is ready.` : ''}` : '<span class="muted">none</span>'}</td></tr><tr><th>Playback</th><td>${pb && pb.flv && s.state === 'live' ? `<a class="mono" href="${esc(pb.flv.public_url)}">${esc(pb.flv.public_url)}</a> (HTTP-FLV)` : '—'}</td></tr></table>${open ? form(req, `/sessions/${s.id}/end`, '<button type="submit" class="danger">End session</button>', { confirm: 'Disconnect the encoder?' }) : ''}</div>--<h2>Outputs</h2>${outputs.length ? `<table><tr><th>Output</th><th>State</th><th>Health</th><th>Restarts</th></tr>${(await outputs.map(o => `<tr><td class="mono">${esc(o.id)}<br><span class="muted">${esc(o.destination_id)}</span></td><td>${pill(o.state)}</td><td>${o.progress ? `${esc(o.progress.fps ?? '?')} fps · ${esc(o.progress.bitrate_kbps ?? '?')} kbit/s` : ''} ${esc(o.last_error || '')}</td><td>${esc(o.restart_attempts)}/${esc(o.max_restart_attempts)}</td></tr>`)).join('')}</table>` : '<p class="muted">No outputs.</p>'}--<h2>Lifecycle</h2><table>${(await store.sessions.transitions(s.id)).map(t => `<tr><td>${esc(iso(t.at))}</td><td>${esc(t.from_state || '·')} → ${pill(t.to_state)}</td><td>${esc(t.reason || '')}</td><td class="mono muted">${esc(t.actor || '')}</td></tr>`).join('')}</table>`,++<h2 class="ovk-titled">${kit.icon('link', { cls: 'ovk-heading-icon' })}Outputs</h2>${outputs.length ? `<table data-ovk-scroll><tr><th>Output</th><th>State</th><th>Health</th><th>Restarts</th></tr>${(await outputs.map(o => `<tr><td class="mono">${esc(o.id)}<br><span class="muted">${esc(o.destination_id)}</span></td><td>${pill(o.state)}</td><td>${o.progress ? `${esc(o.progress.fps ?? '?')} fps · ${esc(o.progress.bitrate_kbps ?? '?')} kbit/s` : ''} ${esc(o.last_error || '')}</td><td>${esc(o.restart_attempts)}/${esc(o.max_restart_attempts)}</td></tr>`)).join('')}</table>` : kit.empty({ icon: 'link', text: 'Delivery health appears here when this session sends to a destination.' })}++<h2 class="ovk-titled">${kit.icon('clock', { cls: 'ovk-heading-icon' })}Lifecycle</h2><table data-ovk-scroll>${(await store.sessions.transitions(s.id)).map(t => `<tr><td>${esc(iso(t.at))}</td><td>${esc(t.from_state || '·')} → ${pill(t.to_state)}</td><td>${esc(t.reason || '')}</td><td class="mono muted">${esc(t.actor || '')}</td></tr>`).join('')}</table>`,});});post('/sessions/:id/end', async (req, res) => {const s = await store.sessions.get(req.params.id);const d = s ? await store.definitions.row(s.definition_id) : null;-- if (!s || !d || !auth.canAccess(req.caller, d.owner_subject)) return page(req, res, { title: 'Not found', body: '<h1>Session not found</h1>' }, 404);++ if (!s || !d || !auth.canAccess(req.caller, d.owner_subject)) return page(req, res, { title: 'Not found', body: kit.pageHead({ title: 'Session not found', icon: 'help', back: { href: '/', label: 'Home' } }) }, 404);await store.sessions.requestEnd(s.id, req.caller.subject);return res.redirect(303, `/sessions/${s.id}`);});nnew file mode 100644@@@ -0,0 +1,38 @@++'use strict';++const assert = require('assert');++const { bootApi, request, userToken, suite, OWNER, OTHER } = require('./helpers');++const cards = require('../server/ui/cards');++const t = suite('app-shell');++t('session scope and new tabs retain owner boundaries', async () => {++ const api = await bootApi();++ try {++ const mine = await api.rt.store.definitions.create({ owner_subject: OWNER, title: 'My broadcast' });++ await api.rt.store.definitions.create({ owner_subject: OTHER, title: 'Private other broadcast' });++ const cookie = `ov_token=${userToken()}`;++ for (const path of ['/streams', '/destinations', '/settings', '/go-live', '/sessions?all=1']) {++ const r = await request(api.base, 'GET', path, { cookie });++ assert.strictEqual(r.status, 200, path);++ assert.ok(r.text.includes('ovk-page-head'), path);++ assert.ok(r.text.includes('"appBar":true'), path);++ assert.ok(!r.text.includes('Private other broadcast'), path);++ }++ const ordinary = await request(api.base, 'GET', '/sessions?all=1', { cookie });++ assert.ok(!ordinary.text.includes('Session owner'));++ assert.ok(ordinary.text.includes('Your restream sessions show here: each time you go live, where it went and how it did'));++ const staff = await request(api.base, 'GET', '/sessions?all=1', { cookie: `ov_token=${userToken({ role: 'admin' })}` });++ assert.ok(staff.text.includes('Session owner') && staff.text.includes('Everyone'));++ const anonymous = await request(api.base, 'GET', '/destinations');++ assert.ok(anonymous.status === 401 || anonymous.status === 302);++ assert.ok(mine.definition.id);++ } finally { await api.close(); }++});++t('session cards escape content and distinguish missing measurements', () => {++ const h = cards.session({ id: 's_test', state: 'ended', created_at: 1700000000000, ended_at: 1700003600000 }, { title: '<script>bad</script>' }, [{ state: 'error', last_error: 'lost' }], [{ platform: 'twitch' }]);++ assert.ok(h.includes('<script>') && !h.includes('<script>bad'));++ assert.ok(h.includes('1h 0m') && h.includes('Needs attention'));++ // No made-up number: an ended card has no Viewers figure, and its sheet says the destinations report none.++ assert.ok(!h.includes('<dt>Viewers</dt>') && h.includes('Viewer totals are not reported by these destinations.'));++ assert.ok(h.includes('data-ovk-sheet') && h.includes('<dialog') && h.includes('/sessions/s_test'));++ assert.ok(h.includes('Twitch') && h.includes('<svg'));++});++void t.run();@@@ -108,7 +108,8 @@ t('the front page leads with open restreaming, links to Live for browser go-livetry {const r = await request(api.base, 'GET', '/');assert.strictEqual(r.status, 200);-- assert.match(r.text, /<h1 id="ore-hero-h">Go live once\.<br><span class="ore-accent">Stream everywhere\.<\/span><\/h1>/);++ assert.match(r.text, /<h1>Go live once\. Stream everywhere\.<\/h1>/);++ assert.match(r.text, /ovk-page-head/);// The hero's signal flow (11-07): every platform with its own switch, the count by CSS, no script needed.const flow = (/<div class="ore-flow"[\s\S]*?<\/ul>/.exec(r.text) || [''])[0];for (const name of ['Twitch', 'YouTube', 'Kick', 'X', 'Facebook', 'TikTok', 'LinkedIn', 'OpenVibe']) assert.match(flow, new RegExp('<span class="ore-dest-name">' + name + '</span>'), name);@@@ -116,7 +117,7 @@ t('the front page leads with open restreaming, links to Live for browser go-liveassert.doesNotMatch(flow, /<script/);// The free tier's real limits, as the service runs them (market outcome 1): the cap from its own config.const tier = (/<section class="sc-sec ore-tier"[\s\S]*?<\/section>/.exec(r.text) || [''])[0];-- assert.match(tier, /<h2 id="ore-tier-h">The free tier, in numbers<\/h2>/);++ assert.match(tier, /<h2 id="ore-tier-h"[^>]*>[\s\S]*?The free tier, in numbers<\/h2>/);assert.match(tier, /<dt>Destinations<\/dt><dd>Up to 10 a stream, each with its own health, logs and retries<\/dd>/);assert.match(tier, /<dt>Quality<\/dt><dd>What you send: a stream from OBS is copied, never re-encoded<\/dd>/);assert.match(tier, /<dt>Out<\/dt><dd>RTMP, RTMPS and SRT<\/dd>/);
new file mode 100644
new file mode 100644
@@ -0,0 +1,32 @@
@@ -0,0 +1,32 @@
+'use strict';
+const kit = require('openvibe-shared/kit');
+const { PLATFORMS, markSvg } = require('./platforms');
+const esc = kit.esc;
+const emptySessions = () => kit.empty({ icon: 'clock', title: 'Your next broadcast starts here', text: 'Your restream sessions show here: each time you go live, where it went and how it did', action: { href: '/go-live', label: 'Go live', icon: 'live' } }) + '<p class="ore-how"><a href="/#how-it-works">How restreaming works</a></p>';
+const emptyStreams = () => kit.empty({ icon: 'live', title: 'One stream, every destination', text: 'Create a stream, connect your destinations and go live from your encoder.', action: { href: '/streams#new-stream', label: 'Create a stream', icon: 'plus' } });
+function platform(id) {
+ const p = PLATFORMS.find(p => p.id === id);
+ return `<span class="ore-platform">${p ? markSvg(p) : kit.icon('link')}<span>${esc(p ? p.name : id || 'Destination')}</span></span>`;
+}
+function scope(all) {
+ return `<nav class="ore-scope" aria-label="Session owner"><a href="/sessions"${!all ? ' aria-current="page"' : ''}>Mine</a><a href="/sessions?all=1"${all ? ' aria-current="page"' : ''}>Everyone</a></nav>`;
+}
+function session(s, definition, outputs = [], destinations = []) {
+ const started = new Date(s.live_at || s.created_at);
+ const valid = Number.isFinite(started.getTime());
+ const running = ['starting', 'live', 'ending'].includes(s.state);
+ const minutes = valid && s.ended_at ? Math.max(0, Math.round((Number(s.ended_at) - started.getTime()) / 60000)) : null;
+ const length = minutes == null ? (running ? 'In progress' : 'Unavailable') : minutes < 1 ? 'Under a minute' : `${Math.floor(minutes / 60) ? Math.floor(minutes / 60) + 'h ' : ''}${minutes % 60}m`;
+ const health = outputs.some(o => o.last_error || ['failed', 'error', 'lost'].includes(o.state)) ? 'Needs attention' : outputs.length ? (running ? 'Delivering' : 'Finished') : 'No output data';
+ const title = definition && definition.title || 'Restream session';
+ const sid = 'session-' + s.id;
+ // Viewers only while it runs and someone is watching here (OpenRestream's own players); the destinations report no
+ // totals, so an ended card never shows a number it does not have (the sheet says why).
+ const watching = running && Number(s.viewers) > 0 ? `<div><dt>Watching here</dt><dd>${esc(String(Number(s.viewers)))}</dd></div>` : '';
+ const metrics = `<dl class="ore-session-metrics"><div><dt>Length</dt><dd>${esc(length)}</dd></div><div><dt>Health</dt><dd>${esc(health)}</dd></div>${watching}</dl>`;
+ const marks = `<div class="ore-session-destinations" data-ovk-scroll aria-label="Destinations">${destinations.filter(Boolean).map(d => platform(d.platform)).join('') || '<span class="muted">No destinations recorded</span>'}</div>`;
+ const when = valid ? `<time datetime="${started.toISOString()}">${esc(started.toLocaleString('en-US', { timeZone: 'UTC', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }))} UTC</time>` : 'Start unavailable';
+ const details = `<p>${when}</p>${metrics}${marks}<p class="muted">Viewer totals are not reported by these destinations.</p>` + kit.button({ href: '/sessions/' + s.id, icon: 'clock', label: 'Full session details', kind: 'quiet' });
+ return `<article class="ore-session-card"><div class="ore-session-top"><span class="ovk-heading-icon">${kit.icon('live')}</span>${kit.badge(s.state, running ? 'live' : '')}</div><h2><a href="/sessions/${esc(s.id)}">${esc(title)}</a></h2><p class="muted">${when}</p>${metrics}${marks}<div class="ore-session-actions">${kit.sheetButton({ sheet: sid, icon: 'list', label: 'Session details', iconOnly: false })}</div>${kit.sheet({ id: sid, title, body: details })}</article>`;
+}
+module.exports = { emptySessions, emptyStreams, platform, scope, session };
@@ -34,10 +34,10 @@ main.ore .sc-hero,main.ore .sc-sec,main.ore .sc-cta{padding-left:0;padding-right
@@ -34,10 +34,10 @@ main.ore .sc-hero,main.ore .sc-sec,main.ore .sc-cta{padding-left:0;padding-right
.ore-stats{margin:0;display:grid;gap:10px}.ore-stats div{border:1px solid var(--ore-line);border-radius:12px;padding:12px 16px;background:var(--bg-card,#131c2e)}
.ore-stats{margin:0;display:grid;gap:10px}.ore-stats div{border:1px solid var(--ore-line);border-radius:12px;padding:12px 16px;background:var(--bg-card,#131c2e)}
.ore-stats dt{font-size:1.25rem;font-weight:700}.ore-stats dd{margin:2px 0 0;color:var(--ore-muted);font-size:.92rem}
.ore-stats dt{font-size:1.25rem;font-weight:700}.ore-stats dd{margin:2px 0 0;color:var(--ore-muted);font-size:.92rem}
/* The hero (11-07): the signal flow, one stream in, every platform out, a switch each. */
/* The hero (11-07): the signal flow, one stream in, every platform out, a switch each. */
-.ore-hero{display:grid;gap:28px;align-items:center;padding:28px 0 8px}
+.ore-hero{display:grid;grid-template-columns:minmax(0,1fr);gap:28px;align-items:center;padding:28px 0 8px}
@media (min-width:900px){.ore-hero{grid-template-columns:minmax(0,1fr) minmax(0,1.05fr);gap:40px;padding:56px 0 16px}}
@media (min-width:900px){.ore-hero{grid-template-columns:minmax(0,1fr) minmax(0,1.05fr);gap:40px;padding:56px 0 16px}}
.ore-eyebrow{margin:0 0 10px;font-size:.78rem;font-weight:800;letter-spacing:.09em;text-transform:uppercase;color:color-mix(in srgb,var(--accent,#3b82f6) 70%,var(--ore-text))}
.ore-eyebrow{margin:0 0 10px;font-size:.78rem;font-weight:800;letter-spacing:.09em;text-transform:uppercase;color:color-mix(in srgb,var(--accent,#3b82f6) 70%,var(--ore-text))}
-.ore-hero h1{margin:0;font-size:clamp(2.1rem,6vw,3.6rem);line-height:1.02;letter-spacing:-.045em;text-wrap:balance}
+.ore-hero-copy{min-width:0}.ore-hero h1{margin:0;font-size:clamp(2.1rem,6vw,3.6rem);line-height:1.02;letter-spacing:-.045em;text-wrap:balance}
.ore-accent{background:linear-gradient(100deg,#9146FF,#FF0033 45%,#53FC18 90%);-webkit-background-clip:text;background-clip:text;color:transparent}
.ore-accent{background:linear-gradient(100deg,#9146FF,#FF0033 45%,#53FC18 90%);-webkit-background-clip:text;background-clip:text;color:transparent}
.ore-lede{margin:16px 0 0;max-width:52ch;font-size:1.06rem;line-height:1.6;color:var(--ore-muted)}
.ore-lede{margin:16px 0 0;max-width:52ch;font-size:1.06rem;line-height:1.6;color:var(--ore-muted)}
.ore-actions{display:flex;flex-wrap:wrap;gap:10px;margin-top:22px}
.ore-actions{display:flex;flex-wrap:wrap;gap:10px;margin-top:22px}
@@ -110,7 +110,28 @@ main.ore .sc-hero,main.ore .sc-sec,main.ore .sc-cta{padding-left:0;padding-right
@@ -110,7 +110,28 @@ main.ore .sc-hero,main.ore .sc-sec,main.ore .sc-cta{padding-left:0;padding-right
.ore .nf-links a{display:inline-block;padding:9px 16px;border-radius:999px;border:1px solid var(--ore-line);background:var(--ore-panel);color:var(--ore-text);text-decoration:none;font-weight:600}
.ore .nf-links a{display:inline-block;padding:9px 16px;border-radius:999px;border:1px solid var(--ore-line);background:var(--ore-panel);color:var(--ore-text);text-decoration:none;font-weight:600}
.ore .nf-links a:hover,.ore .nf-links a:focus-visible{border-color:var(--ore-accent);outline:0}
.ore .nf-links a:hover,.ore .nf-links a:focus-visible{border-color:var(--ore-accent);outline:0}
.ore form:has(input[name=record_to][value=videos]:checked) .ore-live-only{display:none}
.ore form:has(input[name=record_to][value=videos]:checked) .ore-live-only{display:none}
-@media (max-width:640px){.ore th:nth-child(n+4),.ore td:nth-child(n+4){display:none}}
+.ore .ovk-page-head h1{margin:0}.ore .ovk-page-head a,.ore .ovk-btn,.ore .ovk-icon-btn{color:inherit;text-decoration:none}
+.ore .ovk-btn--primary{color:var(--on-accent-strong,#fff)}
+.ore-session-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(100%,320px),1fr));gap:16px}
+.ore-session-card{padding:20px;background:var(--ore-panel);border:1px solid var(--ore-line);border-radius:20px;min-width:0}
+.ore-session-card h2{margin:14px 0 4px;font-size:1.2rem}.ore-session-card h2 a{color:var(--ore-text);text-decoration:none}
+.ore-session-top{display:flex;justify-content:space-between;align-items:center}
+.ore-session-metrics{display:grid;grid-template-columns:repeat(auto-fit,minmax(88px,1fr));gap:12px;margin:18px 0}
+.ore-session-metrics dt{font-size:.8rem;color:var(--ore-muted)}.ore-session-metrics dd{margin:4px 0 0;font-weight:650;overflow-wrap:anywhere}
+.ore-session-destinations{display:flex;gap:8px;overflow:auto;position:relative;padding-bottom:8px}
+.ore-platform{display:inline-flex;align-items:center;gap:7px;white-space:nowrap;border-radius:999px;background:var(--bg-secondary);padding:6px 10px;font-size:.85rem}
+.ore-platform svg{width:20px;height:20px;flex:none}
+.ore-session-actions{display:flex;justify-content:flex-end;margin-top:12px}
+.ore-scope{display:flex;width:fit-content;gap:4px;padding:4px;border-radius:999px;background:var(--bg-secondary);border:1px solid var(--ore-line);margin-bottom:20px}
+.ore-scope a{display:flex;align-items:center;justify-content:center;min-height:44px;min-width:90px;border-radius:999px;color:var(--ore-muted);text-decoration:none}
+.ore-scope a[aria-current]{background:color-mix(in srgb,var(--ore-accent) 22%,var(--ore-panel));color:var(--ore-text);font-weight:700;box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--ore-accent) 45%,transparent)}
+.ore-scope a:not([aria-current]):hover{color:var(--ore-text)}
+.ore-how{text-align:center}.ore-how a{display:inline-flex;align-items:center;min-height:44px}
+.ore :where(button,input,select){min-height:44px}.ore input[type=checkbox],.ore input[type=radio]{min-height:auto}
+.ore table{display:block;overflow-x:auto;position:relative}.ore .ovk-sheet{color:var(--ore-text)}
+@media(min-width:1000px){main.ore{padding-top:36px}}
+@media(max-width:760px){main.ore{padding-top:16px}.ore-session-card{padding:16px}.ore .ovk-heading-icon{flex-shrink:0}}
+
/* Touch targets on phones (fleet audit, 2026-10-29): touch pointers only. */
/* Touch targets on phones (fleet audit, 2026-10-29): touch pointers only. */
@media (pointer: coarse) {
@media (pointer: coarse) {
.ore-faq summary { min-height: 44px; }
.ore-faq summary { min-height: 44px; }
@@ -145,7 +166,11 @@ function renderPage({ title, body, user, canonicalPath = '/', robots = 'noindex,
@@ -145,7 +166,11 @@ function renderPage({ title, body, user, canonicalPath = '/', robots = 'noindex,
links: [
links: [
{ label: 'Streams', href: '/streams', icon: 'fa-tower-broadcast' },
{ label: 'Streams', href: '/streams', icon: 'fa-tower-broadcast' },
{ label: 'Sessions', href: '/sessions', icon: 'fa-clock-rotate-left' },
{ label: 'Sessions', href: '/sessions', icon: 'fa-clock-rotate-left' },
+ { label: 'Destinations', href: '/destinations', icon: 'fa-satellite-dish' },
+ { label: 'Go live', href: '/go-live', icon: 'fa-circle-play' },
+ { label: 'You', href: '/settings', icon: 'fa-user' },
],
],
+ appBar: true,
sessionUrl: '/auth/me',
sessionUrl: '/auth/me',
silentLogin: '/auth/login?silent=1&next={url}',
silentLogin: '/auth/login?silent=1&next={url}',
loginUrl: `/auth/login?next=${encodeURIComponent(canonicalPath)}`,
loginUrl: `/auth/login?next=${encodeURIComponent(canonicalPath)}`,
@@ -173,8 +198,9 @@ ${icon}
@@ -173,8 +198,9 @@ ${icon}
<script src="${ovServe.url('theme-loader.js')}" defer></script>
<script src="${ovServe.url('theme-loader.js')}" defer></script>
<style>${CSS}</style>
<style>${CSS}</style>
<!-- The shared showcase stylesheet on every page, not only the home: inner pages use its buttons (sc-btn), and without it a sign-in button drew as a bare link (phone sweep, 2026-11-01). It is scoped to its own sc- classes. -->
<!-- The shared showcase stylesheet on every page, not only the home: inner pages use its buttons (sc-btn), and without it a sign-in button drew as a bare link (phone sweep, 2026-11-01). It is scoped to its own sc- classes. -->
-${[...new Set(['showcase.css', ...styles])].map((name) => `<link rel="stylesheet" href="${esc(ovServe.url(name))}">`).join('\n')}
+${[...new Set(['showcase.css', 'kit.css', 'kit-data.css', ...styles])].map((name) => `<link rel="stylesheet" href="${esc(ovServe.url(name))}">`).join('\n')}
<script src="${ovServe.url('navbar.js')}" defer></script>
<script src="${ovServe.url('navbar.js')}" defer></script>
+<script src="${ovServe.url('kit-web.js')}" defer></script>
<script src="${ovServe.url('footer.js')}" defer></script>
<script src="${ovServe.url('footer.js')}" defer></script>
</head>
</head>
<body>
<body>
@@ -20,6 +20,8 @@ const ovServe = require('openvibe-shared/serve');
@@ -20,6 +20,8 @@ const ovServe = require('openvibe-shared/serve');
const seo = require('openvibe-shared/seo');
const seo = require('openvibe-shared/seo');
const frame = require('openvibe-shared/frame');
const frame = require('openvibe-shared/frame');
const showcase = require('openvibe-shared/showcase');
const showcase = require('openvibe-shared/showcase');
+const kit = require('openvibe-shared/kit');
+const cards = require('./cards');
const express = require('express');
const express = require('express');
const { renderPage, esc } = require('./layout');
const { renderPage, esc } = require('./layout');
const { TWIN_PATH_RE } = require('../agent-pages');
const { TWIN_PATH_RE } = require('../agent-pages');
@@ -84,7 +86,7 @@ function createUiRouter({ rt, auth, limits = null, liveLink = null }) {
@@ -84,7 +86,7 @@ function createUiRouter({ rt, auth, limits = null, liveLink = null }) {
if (req.caller.kind === 'user' && req.caller.subject) return true;
if (req.caller.kind === 'user' && req.caller.subject) return true;
page(req, res, {
page(req, res, {
title: 'Sign in', canonicalPath: req.path,
title: 'Sign in', canonicalPath: req.path,
- body: `<h1>Sign in</h1><p>OpenRestream uses your OpenVibe account.</p><p><a class="sc-btn sc-primary" href="/auth/login?next=${encodeURIComponent(req.originalUrl)}">Sign in with OpenVibe</a></p>`,
+ body: `${kit.pageHead({ title: 'Sign in', icon: 'user', back: { href: '/', label: 'Home' } })}<p>OpenRestream uses your OpenVibe account.</p><p><a class="sc-btn sc-primary" href="/auth/login?next=${encodeURIComponent(req.originalUrl)}">Sign in with OpenVibe</a></p>`,
}, 401);
}, 401);
return false;
return false;
}
}
@@ -93,14 +95,14 @@ function createUiRouter({ rt, auth, limits = null, liveLink = null }) {
@@ -93,14 +95,14 @@ function createUiRouter({ rt, auth, limits = null, liveLink = null }) {
const given = String((req.body && req.body._csrf) || '');
const given = String((req.body && req.body._csrf) || '');
const expected = csrfFor(req.caller.token || '');
const expected = csrfFor(req.caller.token || '');
if (given.length === expected.length && crypto.timingSafeEqual(Buffer.from(given), Buffer.from(expected))) return true;
if (given.length === expected.length && crypto.timingSafeEqual(Buffer.from(given), Buffer.from(expected))) return true;
- page(req, res, { title: 'Expired form', body: '<h1>That form expired</h1><p>Go back, reload the page and try again.</p>' }, 403);
+ page(req, res, { title: 'Expired form', body: kit.pageHead({ title: 'That form expired', icon: 'help', lede: 'Go back, reload the page and try again.', back: { href: '/', label: 'Home' } }) }, 403);
return false;
return false;
}
}
async function ownDefinition(req, res, id) {
async function ownDefinition(req, res, id) {
const d = await store.definitions.get(id);
const d = await store.definitions.get(id);
if (!d || d.state === 'archived' || !auth.canAccess(req.caller, d.owner_subject)) {
if (!d || d.state === 'archived' || !auth.canAccess(req.caller, d.owner_subject)) {
- page(req, res, { title: 'Not found', body: '<h1>Stream not found</h1><p><a href="/streams">Your streams</a></p>' }, 404);
+ page(req, res, { title: 'Not found', body: kit.pageHead({ title: 'Stream not found', icon: 'help', back: { href: '/streams', label: 'Streams' } }) }, 404);
return null;
return null;
}
}
return d;
return d;
@@ -109,7 +111,7 @@ function createUiRouter({ rt, auth, limits = null, liveLink = null }) {
@@ -109,7 +111,7 @@ function createUiRouter({ rt, auth, limits = null, liveLink = null }) {
async function ownDestination(req, res, id) {
async function ownDestination(req, res, id) {
const dest = await store.outputs.destinationRow(id);
const dest = await store.outputs.destinationRow(id);
const d = dest ? await ownDefinition(req, res, dest.definition_id) : null;
const d = dest ? await ownDefinition(req, res, dest.definition_id) : null;
- if (!dest && !res.headersSent) page(req, res, { title: 'Not found', body: '<h1>Destination not found</h1>' }, 404);
+ if (!dest && !res.headersSent) page(req, res, { title: 'Not found', body: kit.pageHead({ title: 'Destination not found', icon: 'help', back: { href: '/', label: 'Home' } }) }, 404);
return d ? { dest, definition: d } : null;
return d ? { dest, definition: d } : null;
}
}
@@ -157,7 +159,7 @@ function createUiRouter({ rt, auth, limits = null, liveLink = null }) {
@@ -157,7 +159,7 @@ function createUiRouter({ rt, auth, limits = null, liveLink = null }) {
name: 'OpenRestream', url: `${config.baseUrl}/`, description: FRONT_PAGE_DESCRIPTION, category: 'MultimediaApplication',
name: 'OpenRestream', url: `${config.baseUrl}/`, description: FRONT_PAGE_DESCRIPTION, category: 'MultimediaApplication',
}), seo.jsonLd.faq(FRONT_FAQ)],
}), seo.jsonLd.faq(FRONT_FAQ)],
body: `${frontPage(config, f)}
body: `${frontPage(config, f)}
-${signedIn ? `<h2>Your streams</h2>${mine.length ? await streamTable(mine) : '<p class="muted">No streams yet.</p>'}<p><a href="/streams">Manage streams</a></p>` : '<p><a href="/auth/login?next=/streams">Sign in with OpenVibe</a> to manage your streams.</p>'}
+${signedIn ? kit.section({ title: 'Your streams', icon: 'live', actions: [kit.button({ href: '/streams', label: 'Manage streams', icon: 'gear', kind: 'quiet' })], body: mine.length ? await streamTable(mine) : cards.emptyStreams() }) : '<p><a href="/auth/login?next=/streams">Sign in with OpenVibe</a> to manage your streams.</p>'}
${frame.shipped({ service: 'openre', title: 'Recently shipped on OpenRestream' })}`,
${frame.shipped({ service: 'openre', title: 'Recently shipped on OpenRestream' })}`,
});
});
});
});
@@ -190,7 +192,7 @@ ${frame.shipped({ service: 'openre', title: 'Recently shipped on OpenRestream' }
@@ -190,7 +192,7 @@ ${frame.shipped({ service: 'openre', title: 'Recently shipped on OpenRestream' }
['Recording', 'Per stream: a VOD, clips only, or nothing, saved to your OpenVibe.Video'],
['Recording', 'Per stream: a VOD, clips only, or nothing, saved to your OpenVibe.Video'],
['Minimum', 'None: no follower, subscriber or eligibility threshold'],
['Minimum', 'None: no follower, subscriber or eligibility threshold'],
];
];
- return `<section class="sc-sec ore-tier" aria-labelledby="ore-tier-h"><h2 id="ore-tier-h">The free tier, in numbers</h2>
+ return `<section class="sc-sec ore-tier" aria-labelledby="ore-tier-h"><h2 id="ore-tier-h" class="ovk-titled">${kit.icon('list', { cls: 'ovk-heading-icon' })}The free tier, in numbers</h2>
<dl class="ore-tier-grid">${rows.map(([k, v]) => `<div><dt>${esc(k)}</dt><dd>${esc(v)}</dd></div>`).join('')}</dl></section>`;
<dl class="ore-tier-grid">${rows.map(([k, v]) => `<div><dt>${esc(k)}</dt><dd>${esc(v)}</dd></div>`).join('')}</dl></section>`;
}
}
@@ -216,19 +218,19 @@ ${frame.shipped({ service: 'openre', title: 'Recently shipped on OpenRestream' }
@@ -216,19 +218,19 @@ ${frame.shipped({ service: 'openre', title: 'Recently shipped on OpenRestream' }
<ul class="ore-dests">${dests}<li class="ore-dest ore-dest-own" style="--c:var(--accent,#3b82f6);--i:${PLATFORMS.length}"><label><input type="checkbox" class="ore-dest-in" checked><span class="ore-wire" aria-hidden="true"></span><span class="ore-dest-mark ore-dest-ov" aria-hidden="true">OV</span><span class="ore-dest-name">OpenVibe</span><span class="ore-switch" aria-hidden="true"></span></label></li></ul>
<ul class="ore-dests">${dests}<li class="ore-dest ore-dest-own" style="--c:var(--accent,#3b82f6);--i:${PLATFORMS.length}"><label><input type="checkbox" class="ore-dest-in" checked><span class="ore-wire" aria-hidden="true"></span><span class="ore-dest-mark ore-dest-ov" aria-hidden="true">OV</span><span class="ore-dest-name">OpenVibe</span><span class="ore-switch" aria-hidden="true"></span></label></li></ul>
<p class="ore-total" aria-live="polite">Live on <span class="ore-n"></span> places at once</p>
<p class="ore-total" aria-live="polite">Live on <span class="ore-n"></span> places at once</p>
</div>`;
</div>`;
- const hero = `<section class="ore-hero" aria-labelledby="ore-hero-h">
+ const hero = `<section class="ore-hero" aria-label="OpenRestream">
<div class="ore-hero-copy">
<div class="ore-hero-copy">
<p class="ore-eyebrow">Open restreaming · free · no follower minimum</p>
<p class="ore-eyebrow">Open restreaming · free · no follower minimum</p>
-<h1 id="ore-hero-h">Go live once.<br><span class="ore-accent">Stream everywhere.</span></h1>
-<ul class="ore-logos" aria-label="Streams to">${PLATFORMS.map((pl) => `<li style="--c:${pl.color}" title="${esc(pl.name)}">${markSvg(pl)}<span class="ore-sr">${esc(pl.name)}</span></li>`).join('')}</ul>
-<p class="ore-lede">Send one stream from OBS or your browser to Twitch, YouTube, Kick and any RTMP or SRT server at once. Your OpenVibe Live channel comes with it.</p>
+${kit.pageHead({ title: 'Go live once. Stream everywhere.', icon: 'live', lede: 'Send one stream from OBS or your browser to Twitch, YouTube, Kick and any RTMP or SRT server at once. Your OpenVibe Live channel comes with it.' })}
+<ul data-ovk-scroll="drift" class="ore-logos" aria-label="Streams to">${PLATFORMS.map((pl) => `<li style="--c:${pl.color}" title="${esc(pl.name)}">${markSvg(pl)}<span class="ore-sr">${esc(pl.name)}</span></li>`).join('')}</ul>
+
<div class="ore-actions"><a class="ore-btn ore-btn-primary" href="/streams">Start restreaming</a><a class="ore-btn" href="${esc(live)}">Go live from your browser</a></div>
<div class="ore-actions"><a class="ore-btn ore-btn-primary" href="/streams">Start restreaming</a><a class="ore-btn" href="${esc(live)}">Go live from your browser</a></div>
<p class="ore-note">Alpha: RTMP in; RTMP, RTMPS and SRT out. Going live from a browser happens on OpenVibe.Live for now (<a href="${esc(live)}/docs/go-live-in-your-browser">how it works</a>).</p>
<p class="ore-note">Alpha: RTMP in; RTMP, RTMPS and SRT out. Going live from a browser happens on OpenVibe.Live for now (<a href="${esc(live)}/docs/go-live-in-your-browser">how it works</a>).</p>
</div>
</div>
${flow}
${flow}
</section>
</section>
-${stats.length ? `<dl class="ore-stats ore-stats-strip">${stats.map(([v, d]) => `<div><dt>${n(v)}</dt><dd>${esc(d)}</dd></div>`).join('')}</dl>` : ''}`;
- return hero + showcase.features({
+${stats.length ? `<dl data-ovk-scroll class="ore-stats ore-stats-strip">${stats.map(([v, d]) => `<div><dt>${n(v)}</dt><dd>${esc(d)}</dd></div>`).join('')}</dl>` : ''}`;
+ const content = hero + showcase.features({
title: 'What OpenRestream does',
title: 'What OpenRestream does',
items: [
items: [
{ icon: 'ov:live', title: 'One stream, every platform', text: 'Twitch, YouTube, Kick and any RTMP, RTMPS or SRT server at once. Each destination has its own health, logs and retries.' },
{ icon: 'ov:live', title: 'One stream, every platform', text: 'Twitch, YouTube, Kick and any RTMP, RTMPS or SRT server at once. Each destination has its own health, logs and retries.' },
@@ -244,7 +246,7 @@ ${stats.length ? `<dl class="ore-stats ore-stats-strip">${stats.map(([v, d]) =>
@@ -244,7 +246,7 @@ ${stats.length ? `<dl class="ore-stats ore-stats-strip">${stats.map(([v, d]) =>
lede: 'Add each destination once, test it, and it goes live with you every time.',
lede: 'Add each destination once, test it, and it goes live with you every time.',
items: DESTINATIONS.map((d) => ({ title: d.name, text: d.text })),
items: DESTINATIONS.map((d) => ({ title: d.name, text: d.text })),
}) + showcase.steps({
}) + showcase.steps({
- title: 'Restream with OBS',
+ id: 'how-it-works', title: 'Restream with OBS',
items: [
items: [
{ title: 'Sign in', text: 'With your OpenVibe account.', href: '/auth/login?next=/streams' },
{ title: 'Sign in', text: 'With your OpenVibe account.', href: '/auth/login?next=/streams' },
{ title: 'Create a stream', text: 'Copy the RTMP server and key; the key is shown only once. OpenVibe Live is already ticked.' },
{ title: 'Create a stream', text: 'Copy the RTMP server and key; the key is shown only once. OpenVibe Live is already ticked.' },
@@ -255,17 +257,17 @@ ${stats.length ? `<dl class="ore-stats ore-stats-strip">${stats.map(([v, d]) =>
@@ -255,17 +257,17 @@ ${stats.length ? `<dl class="ore-stats ore-stats-strip">${stats.map(([v, d]) =>
title: 'OBS settings',
title: 'OBS settings',
lede: 'In OBS, open Settings → Stream, choose Custom, and paste the server and the key from your stream\'s page.',
lede: 'In OBS, open Settings → Stream, choose Custom, and paste the server and the key from your stream\'s page.',
samples: [{ label: 'Settings → Stream', code: `Service: Custom...\nServer: ${ingestServer(cfg)}\nStream Key: (from your stream's page, shown once)` }],
samples: [{ label: 'Settings → Stream', code: `Service: Custom...\nServer: ${ingestServer(cfg)}\nStream Key: (from your stream's page, shown once)` }],
- }) + `<section class="sc-sec ore-faq" aria-labelledby="ore-faq-h"><h2 id="ore-faq-h">Questions</h2>
+ }) + `<section class="sc-sec ore-faq" aria-labelledby="ore-faq-h"><h2 id="ore-faq-h" class="ovk-titled">${kit.icon('help', { cls: 'ovk-heading-icon' })}Questions</h2>
${FRONT_FAQ.map((f) => `<details><summary>${esc(f.q)}</summary><p>${esc(f.a)}</p></details>`).join('\n')}
${FRONT_FAQ.map((f) => `<details><summary>${esc(f.q)}</summary><p>${esc(f.a)}</p></details>`).join('\n')}
</section>`;
</section>`;
+ return content.replace(/<h2([^>]*)>(What OpenRestream does|Where it can stream|Restream with OBS|OBS settings)<\/h2>/g, (_, attrs, title) => `<h2${attrs} class="ovk-titled">${kit.icon('live', { cls: 'ovk-heading-icon' })}${title}</h2>`);
}
}
async function streamTable(list) {
async function streamTable(list) {
- return `<table><tr><th>Stream</th><th>State</th><th>Now</th><th>Linked to</th></tr>${(await Promise.all(list.map(async (d) => {
+ return kit.list(await Promise.all(list.map(async d => {
const open = (await store.sessions.openFor(d.id))[0];
const open = (await store.sessions.openFor(d.id))[0];
- const refs = d.external_refs.map(r => `${r.service}:${r.type}:${r.id}`).join(', ');
- return `<tr><td><a href="/streams/${esc(d.id)}">${esc(d.title)}</a></td><td>${pill(d.state)}</td><td>${open ? pill(open.state) : '<span class="muted">offline</span>'}</td><td class="mono">${esc(refs) || '—'}</td></tr>`;
- }))).join('')}</table>`;
+ return kit.row({ href: '/streams/' + d.id, mark: kit.icon('live'), title: d.title, sub: open ? 'Live now' : 'Ready for your next broadcast', meta: d.state, badge: open ? kit.badge(open.state, 'live') : '' });
+ })));
}
}
router.get('/streams', async (req, res) => {
router.get('/streams', async (req, res) => {
@@ -273,8 +275,8 @@ ${FRONT_FAQ.map((f) => `<details><summary>${esc(f.q)}</summary><p>${esc(f.a)}</p
@@ -273,8 +275,8 @@ ${FRONT_FAQ.map((f) => `<details><summary>${esc(f.q)}</summary><p>${esc(f.a)}</p
const list = await store.definitions.list({ owner_subject: req.caller.subject });
const list = await store.definitions.list({ owner_subject: req.caller.subject });
page(req, res, {
page(req, res, {
title: 'Streams', canonicalPath: '/streams',
title: 'Streams', canonicalPath: '/streams',
- body: `<h1>Streams</h1>${list.length ? await streamTable(list) : '<p class="muted">No streams yet.</p>'}
-<div class="card"><h2>New stream</h2>${form(req, '/streams', `
+ body: `${kit.pageHead({ title: 'Streams', icon: 'live', back: { href: '/', label: 'Home' }, lede: 'Set up once. Send your next stream everywhere.', actions: [{ href: '#new-stream', icon: 'plus', label: 'New stream' }] })}${list.length ? await streamTable(list) : cards.emptyStreams()}
+<div class="card" id="new-stream"><h2 class="ovk-titled">${kit.icon('plus', { cls: 'ovk-heading-icon' })}New stream</h2>${form(req, '/streams', `
<label>Title</label><input type="text" name="title" maxlength="140" required>
<label>Title</label><input type="text" name="title" maxlength="140" required>
${whereItGoes(null)}
${whereItGoes(null)}
<div class="ore-live-only"><div class="row"><div><label>Recording</label><select name="recording_mode"><option value="vod">Record a VOD</option><option value="clips">Clips only</option><option value="none">Do not record</option></select></div>
<div class="ore-live-only"><div class="row"><div><label>Recording</label><select name="recording_mode"><option value="vod">Record a VOD</option><option value="clips">Clips only</option><option value="none">Do not record</option></select></div>
@@ -315,7 +317,7 @@ ${option('videos', 'Record only, to my videos', `Nothing goes live. Each session
@@ -315,7 +317,7 @@ ${option('videos', 'Record only, to my videos', `Nothing goes live. Each session
res.set('Cache-Control', 'no-store');
res.set('Cache-Control', 'no-store');
page(req, res, {
page(req, res, {
title: heading, canonicalPath: `/streams/${definition.id}`,
title: heading, canonicalPath: `/streams/${definition.id}`,
- body: `<h1>${esc(heading)}</h1>
+ body: `${kit.pageHead({ title: heading, icon: 'key', back: { href: '/streams', label: 'Streams' } })}
<div class="card"><p><strong>Copy the key now.</strong> OpenRestream stores only a hash of it; this page is the only time it is shown.</p>
<div class="card"><p><strong>Copy the key now.</strong> OpenRestream stores only a hash of it; this page is the only time it is shown.</p>
<label>Server (OBS: Settings → Stream → Custom)</label><code class="secret">${esc(ep.rtmp ? ep.rtmp.url : '')}</code>
<label>Server (OBS: Settings → Stream → Custom)</label><code class="secret">${esc(ep.rtmp ? ep.rtmp.url : '')}</code>
<label>Stream key</label><code class="secret">${esc(key.key)}</code></div>
<label>Stream key</label><code class="secret">${esc(key.key)}</code></div>
@@ -336,6 +338,24 @@ ${liveNote(live)}${recordNote(definition)}
@@ -336,6 +338,24 @@ ${liveNote(live)}${recordNote(definition)}
await keyPage(req, res, definition, key, 'Stream created', live);
await keyPage(req, res, definition, key, 'Stream created', live);
});
});
+ router.get('/destinations', async (req, res) => {
+ if (!needUser(req, res)) return;
+ const definitions = await store.definitions.list({ owner_subject: req.caller.subject });
+ const rows = (await Promise.all(definitions.map(async d => (await store.outputs.destinations(d.id)).map(x =>
+ kit.row({ href: '/destinations/' + x.id, mark: cards.platform(x.platform), title: x.name || x.platform, sub: d.title, meta: x.enabled ? 'Enabled' : 'Disabled' })
+ )))).flat();
+ page(req, res, { title: 'Destinations', canonicalPath: '/destinations', body: kit.pageHead({ title: 'Destinations', icon: 'link', back: { href: '/', label: 'Home' }, lede: 'Every place your streams go.', actions: [{ href: '/streams', icon: 'plus', label: 'Add a destination' }] }) + (rows.length ? kit.list(rows) : kit.empty({ icon: 'link', title: 'Choose your destinations', text: 'Open a stream to connect Twitch, YouTube, Kick or your own server.', action: { href: '/streams', icon: 'plus', label: 'Choose a stream' } })) });
+ });
+ router.get('/go-live', async (req, res) => {
+ if (!needUser(req, res)) return;
+ const definitions = await store.definitions.list({ owner_subject: req.caller.subject });
+ page(req, res, { title: 'Go live', canonicalPath: '/go-live', body: kit.pageHead({ title: 'Go live', icon: 'live', back: { href: '/', label: 'Home' }, lede: 'Choose your encoder, check your destinations and start broadcasting.' }) + kit.section({ title: 'From your browser', icon: 'video', body: kit.empty({ icon: 'video', text: 'Use your camera or share your screen on OpenVibe Live.', action: { href: config.liveUrl + '/broadcast', icon: 'live', label: 'Open the studio' } }) }) + kit.section({ title: 'From OBS or an encoder', icon: 'live', body: definitions.length ? await streamTable(definitions) : cards.emptyStreams() }) });
+ });
+ router.get('/settings', async (req, res) => {
+ if (!needUser(req, res)) return;
+ page(req, res, { title: 'You', canonicalPath: '/settings', body: kit.pageHead({ title: 'You', icon: 'user', back: { href: '/', label: 'Home' }, lede: 'Your account and stream preferences.' }) + kit.section({ title: 'Stream settings', icon: 'gear', body: kit.empty({ icon: 'gear', text: 'Recording, playback and destination settings belong to each stream.', action: { href: '/streams', label: 'Manage streams', icon: 'gear' } }) }) + kit.section({ title: 'Your account', icon: 'user', body: kit.button({ href: 'https://openvibe.network/settings', label: 'Account settings', icon: 'gear', kind: 'quiet' }) + kit.button({ href: '/auth/logout', label: 'Sign out', icon: 'back', kind: 'quiet' }) }) });
+ });
+
router.get('/streams/:id', async (req, res) => {
router.get('/streams/:id', async (req, res) => {
if (!needUser(req, res)) return;
if (!needUser(req, res)) return;
const d = await ownDefinition(req, res, req.params.id);
const d = await ownDefinition(req, res, req.params.id);
@@ -349,28 +369,28 @@ ${liveNote(live)}${recordNote(definition)}
@@ -349,28 +369,28 @@ ${liveNote(live)}${recordNote(definition)}
const outFor = async (destId) => await outputs.find(o => o.destination_id === destId);
const outFor = async (destId) => await outputs.find(o => o.destination_id === destId);
page(req, res, {
page(req, res, {
title: d.title, canonicalPath: `/streams/${d.id}`,
title: d.title, canonicalPath: `/streams/${d.id}`,
- body: `<h1>${esc(d.title)} ${pill(d.state)}</h1>
+ body: `${kit.pageHead({ title: d.title, icon: 'live', back: { href: '/streams', label: 'Streams' }, actions: [{ href: '#stream-settings', label: 'Settings', icon: 'gear' }] })}${pill(d.state)}
<p class="muted mono">${esc(d.id)}${d.external_refs.length ? ` · linked to ${esc(d.external_refs.map(r => `${r.service}:${r.type}:${r.id}`).join(', '))}` : ''}</p>
<p class="muted mono">${esc(d.id)}${d.external_refs.length ? ` · linked to ${esc(d.external_refs.map(r => `${r.service}:${r.type}:${r.id}`).join(', '))}` : ''}</p>
${d.record_to ? recordNote(d) : liveStatus(d, req.query.live)}
${d.record_to ? recordNote(d) : liveStatus(d, req.query.live)}
-<div class="card"><h2>Ingest</h2>
+<div class="card"><h2 class="ovk-titled">${kit.icon('live', { cls: 'ovk-heading-icon' })}Ingest</h2>
${ep.rtmp ? `<label>RTMP server</label><code class="secret">${esc(ep.rtmp.url)}</code>` : '<p class="muted">RTMP is not enabled for this stream.</p>'}
${ep.rtmp ? `<label>RTMP server</label><code class="secret">${esc(ep.rtmp.url)}</code>` : '<p class="muted">RTMP is not enabled for this stream.</p>'}
-<table><tr><th>Key</th><th>Status</th><th>Created</th><th>Last used</th></tr>${keys.map(k => `<tr><td class="mono">ork_…${esc(k.hint)}</td><td>${pill(k.status)}${k.grace_until ? ` until ${esc(iso(k.grace_until))}` : ''}</td><td>${esc(iso(k.created_at))}</td><td>${esc(iso(k.last_used_at))}</td></tr>`).join('') || '<tr><td colspan="4">No usable key: rotate to get one.</td></tr>'}</table>
+<table data-ovk-scroll><tr><th>Key</th><th>Status</th><th>Created</th><th>Last used</th></tr>${keys.map(k => `<tr><td class="mono">ork_…${esc(k.hint)}</td><td>${pill(k.status)}${k.grace_until ? ` until ${esc(iso(k.grace_until))}` : ''}</td><td>${esc(iso(k.created_at))}</td><td>${esc(iso(k.last_used_at))}</td></tr>`).join('') || '<tr><td colspan="4">No usable key: rotate to get one.</td></tr>'}</table>
${form(req, `/streams/${d.id}/rotate`, `<div class="row"><div><label>Old key stays valid for (seconds, 0 = revoke now)</label><input type="number" name="grace_seconds" min="0" max="604800" value="0"></div>
${form(req, `/streams/${d.id}/rotate`, `<div class="row"><div><label>Old key stays valid for (seconds, 0 = revoke now)</label><input type="number" name="grace_seconds" min="0" max="604800" value="0"></div>
<div><label><input type="checkbox" name="end_sessions" value="1"> also disconnect a running session</label></div></div><button type="submit" class="danger">Rotate key</button>`, { confirm: 'Issue a new key and retire the current one?' })}
<div><label><input type="checkbox" name="end_sessions" value="1"> also disconnect a running session</label></div></div><button type="submit" class="danger">Rotate key</button>`, { confirm: 'Issue a new key and retire the current one?' })}
</div>
</div>
-<div class="card"><h2>Now</h2>${open ? `<p>${pill(open.state)} since ${esc(iso(open.live_at || open.created_at))} on ${esc(open.worker_kind)} generation ${esc(open.worker_generation)} · <a href="/sessions/${esc(open.id)}">session</a></p>
+<div class="card"><h2 class="ovk-titled">${kit.icon('live', { cls: 'ovk-heading-icon' })}Now</h2>${open ? `<p>${pill(open.state)} since ${esc(iso(open.live_at || open.created_at))} on ${esc(open.worker_kind)} generation ${esc(open.worker_generation)} · <a href="/sessions/${esc(open.id)}">session</a></p>
${form(req, `/sessions/${open.id}/end`, '<button type="submit" class="danger">End session</button>', { cls: 'inline', confirm: 'Disconnect the encoder?' })}` : '<p class="muted">Offline.</p>'}</div>
${form(req, `/sessions/${open.id}/end`, '<button type="submit" class="danger">End session</button>', { cls: 'inline', confirm: 'Disconnect the encoder?' })}` : '<p class="muted">Offline.</p>'}</div>
-<div class="card"><h2>Restream destinations</h2>${d.record_to ? '<p class="muted">Off while this stream records only to your videos.</p>' : ''}
-${dests.length ? `<table><tr><th>Destination</th><th>Output</th><th>Health</th><th>Actions</th></tr>${(await Promise.all(dests.map(async (x) => {
+<div class="card"><h2 class="ovk-titled">${kit.icon('link', { cls: 'ovk-heading-icon' })}Restream destinations</h2>${d.record_to ? '<p class="muted">Off while this stream records only to your videos.</p>' : ''}
+${dests.length ? `<table data-ovk-scroll><tr><th>Destination</th><th>Output</th><th>Health</th><th>Actions</th></tr>${(await Promise.all(dests.map(async (x) => {
const o = await outFor(x.id);
const o = await outFor(x.id);
return `<tr><td><strong>${esc(x.name || x.platform)}</strong> <span class="muted">${esc(x.platform)}</span><br><span class="mono">${esc(x.server_url)}</span><br><span class="muted">key ${esc(x.stream_key_hint || 'not set')}${x.enabled ? '' : ' · disabled'}${x.auto_start ? ' · auto-start' : ''}${x.hold_reason ? ` · held: ${esc(x.hold_reason)}` : ''}${x.cooldown_ms ? ` · cooling down until ${esc(x.cooldown_until)}` : ''}</span></td>
return `<tr><td><strong>${esc(x.name || x.platform)}</strong> <span class="muted">${esc(x.platform)}</span><br><span class="mono">${esc(x.server_url)}</span><br><span class="muted">key ${esc(x.stream_key_hint || 'not set')}${x.enabled ? '' : ' · disabled'}${x.auto_start ? ' · auto-start' : ''}${x.hold_reason ? ` · held: ${esc(x.hold_reason)}` : ''}${x.cooldown_ms ? ` · cooling down until ${esc(x.cooldown_until)}` : ''}</span></td>
<td>${o ? pill(o.state) : '<span class="muted">—</span>'}</td>
<td>${o ? pill(o.state) : '<span class="muted">—</span>'}</td>
<td>${o && o.progress ? `${esc(o.progress.fps ?? '?')} fps · ${esc(o.progress.bitrate_kbps ?? '?')} kbit/s · speed ${esc(o.progress.speed ?? '?')}` : ''}${o && o.last_error ? `<br><span class="muted">${esc(o.last_error)}</span>` : ''}${!o && x.last_error ? `<span class="muted">${esc(x.last_error)}</span>` : ''}</td>
<td>${o && o.progress ? `${esc(o.progress.fps ?? '?')} fps · ${esc(o.progress.bitrate_kbps ?? '?')} kbit/s · speed ${esc(o.progress.speed ?? '?')}` : ''}${o && o.last_error ? `<br><span class="muted">${esc(o.last_error)}</span>` : ''}${!o && x.last_error ? `<span class="muted">${esc(x.last_error)}</span>` : ''}</td>
<td>${form(req, `/destinations/${x.id}/test`, '<button type="submit" class="secondary">Test</button>', { cls: 'inline' })}${open && !d.record_to ? (o && ['pending', 'starting', 'live', 'error'].includes(o.state) ? form(req, `/destinations/${x.id}/stop`, '<button type="submit" class="secondary">Stop</button>', { cls: 'inline' }) : form(req, `/destinations/${x.id}/start`, '<button type="submit">Start</button>', { cls: 'inline' })) : ''}
<td>${form(req, `/destinations/${x.id}/test`, '<button type="submit" class="secondary">Test</button>', { cls: 'inline' })}${open && !d.record_to ? (o && ['pending', 'starting', 'live', 'error'].includes(o.state) ? form(req, `/destinations/${x.id}/stop`, '<button type="submit" class="secondary">Stop</button>', { cls: 'inline' }) : form(req, `/destinations/${x.id}/start`, '<button type="submit">Start</button>', { cls: 'inline' })) : ''}
<a href="/destinations/${esc(x.id)}">Edit</a>${form(req, `/destinations/${x.id}/delete`, '<button type="submit" class="danger">Delete</button>', { cls: 'inline', confirm: 'Delete this destination?' })}</td></tr>`;
<a href="/destinations/${esc(x.id)}">Edit</a>${form(req, `/destinations/${x.id}/delete`, '<button type="submit" class="danger">Delete</button>', { cls: 'inline', confirm: 'Delete this destination?' })}</td></tr>`;
- }))).join('')}</table>` : '<p class="muted">No destinations.</p>'}
+ }))).join('')}</table>` : kit.empty({ icon: 'link', text: 'Add the places this stream should go below.' })}
<h3>Add a destination</h3>${form(req, `/streams/${d.id}/destinations`, destinationFields({}))}</div>
<h3>Add a destination</h3>${form(req, `/streams/${d.id}/destinations`, destinationFields({}))}</div>
-<div class="card"><h2>Settings</h2>${form(req, `/streams/${d.id}`, `
+<div class="card"><h2 class="ovk-titled" id="stream-settings">${kit.icon('gear', { cls: 'ovk-heading-icon' })}Settings</h2>${form(req, `/streams/${d.id}`, `
<label>Title</label><input type="text" name="title" maxlength="140" value="${esc(d.title)}">
<label>Title</label><input type="text" name="title" maxlength="140" value="${esc(d.title)}">
${whereItGoes(d.record_to)}
${whereItGoes(d.record_to)}
<div class="row"><div class="ore-live-only"><label>Recording</label><select name="recording_mode">${['vod', 'clips', 'none'].map(m => `<option value="${m}"${d.recording_mode === m ? ' selected' : ''}>${m}</option>`).join('')}</select></div>
<div class="row"><div class="ore-live-only"><label>Recording</label><select name="recording_mode">${['vod', 'clips', 'none'].map(m => `<option value="${m}"${d.recording_mode === m ? ' selected' : ''}>${m}</option>`).join('')}</select></div>
@@ -379,7 +399,7 @@ ${whereItGoes(d.record_to)}
@@ -379,7 +399,7 @@ ${whereItGoes(d.record_to)}
<div><label>State</label><select name="state">${['active', 'disabled'].map(m => `<option${d.state === m ? ' selected' : ''}>${m}</option>`).join('')}</select></div></div>
<div><label>State</label><select name="state">${['active', 'disabled'].map(m => `<option${d.state === m ? ' selected' : ''}>${m}</option>`).join('')}</select></div></div>
<label class="ore-live-only"><input type="checkbox" name="mirror_to_live" value="1"${d.mirror_to_live ? ' checked' : ''}> Show it live on my OpenVibe Live channel too</label>
<label class="ore-live-only"><input type="checkbox" name="mirror_to_live" value="1"${d.mirror_to_live ? ' checked' : ''}> Show it live on my OpenVibe Live channel too</label>
<button type="submit">Save</button>`)}</div>
<button type="submit">Save</button>`)}</div>
-<h2>Sessions</h2>${sessionTable(sessions)}`,
+<h2 class="ovk-titled">${kit.icon('clock', { cls: 'ovk-heading-icon' })}Sessions</h2>${await sessionTable(sessions)}`,
});
});
});
});
@@ -417,9 +437,14 @@ ${whereItGoes(d.record_to)}
@@ -417,9 +437,14 @@ ${whereItGoes(d.record_to)}
return out;
return out;
}
}
- function sessionTable(list) {
- if (!list.length) return '<p class="muted">No sessions yet.</p>';
- return `<table><tr><th>Session</th><th>State</th><th>Started</th><th>Ended</th><th>Worker</th></tr>${list.map(s => `<tr><td><a class="mono" href="/sessions/${esc(s.id)}">${esc(s.id)}</a></td><td>${pill(s.state)}${s.failure_reason ? ` <span class="muted">${esc(s.failure_reason)}</span>` : ''}</td><td>${esc(iso(s.live_at || s.created_at))}</td><td>${esc(iso(s.ended_at))}</td><td>${esc(s.worker_kind)} #${esc(s.worker_generation)}</td></tr>`).join('')}</table>`;
+ async function sessionTable(list) {
+ if (!list.length) return cards.emptySessions();
+ return `<div class="ore-session-grid">${(await Promise.all(list.map(async s => {
+ const definition = await store.definitions.get(s.definition_id);
+ const outputs = await store.outputs.outputsOfSession(s.id);
+ const destinations = await Promise.all(outputs.map(o => store.outputs.getDestination(o.destination_id)));
+ return cards.session(s, definition, outputs, destinations);
+ }))).join('')}</div>`;
}
}
post('/streams/:id', async (req, res) => {
post('/streams/:id', async (req, res) => {
@@ -461,9 +486,9 @@ ${whereItGoes(d.record_to)}
@@ -461,9 +486,9 @@ ${whereItGoes(d.record_to)}
const logs = await store.outputs.logsOfDestination(x.id, 50);
const logs = await store.outputs.logsOfDestination(x.id, 50);
page(req, res, {
page(req, res, {
title: `Destination ${x.name || x.platform}`, canonicalPath: `/destinations/${x.id}`,
title: `Destination ${x.name || x.platform}`, canonicalPath: `/destinations/${x.id}`,
- body: `<h1>${esc(x.name || x.platform)}</h1><p><a href="/streams/${esc(own.definition.id)}">← ${esc(own.definition.title)}</a></p>
+ body: `${kit.pageHead({ title: x.name || x.platform, icon: 'link', back: { href: '/streams/' + own.definition.id, label: own.definition.title }, lede: 'Connection settings and delivery history.' })}
<div class="card">${form(req, `/destinations/${x.id}`, destinationFields(x))}</div>
<div class="card">${form(req, `/destinations/${x.id}`, destinationFields(x))}</div>
-<h2>Log</h2>${logs.length ? `<table><tr><th>When</th><th>Level</th><th>Message</th></tr>${logs.map(l => `<tr><td>${esc(iso(l.at))}</td><td>${pill(l.level === 'error' ? 'failed' : l.level === 'warn' ? 'error' : 'ok')}</td><td class="mono">${esc(l.message)}</td></tr>`).join('')}</table>` : '<p class="muted">Nothing logged yet.</p>'}`,
+<h2 class="ovk-titled">${kit.icon('list', { cls: 'ovk-heading-icon' })}Log</h2>${logs.length ? `<table data-ovk-scroll><tr><th>When</th><th>Level</th><th>Message</th></tr>${logs.map(l => `<tr><td>${esc(iso(l.at))}</td><td>${pill(l.level === 'error' ? 'failed' : l.level === 'warn' ? 'error' : 'ok')}</td><td class="mono">${esc(l.message)}</td></tr>`).join('')}</table>` : '<p class="muted">Nothing logged yet.</p>'}`,
});
});
});
});
@@ -488,7 +513,7 @@ ${whereItGoes(d.record_to)}
@@ -488,7 +513,7 @@ ${whereItGoes(d.record_to)}
await store.outputs.log(null, own.dest.id, result.ok ? 'info' : 'warn', `test ${result.ok ? 'passed' : 'failed'}: ${result.checks.map(c => `${c.check} ${c.ok ? 'ok' : 'FAIL'} (${c.detail})`).join('; ')}`);
await store.outputs.log(null, own.dest.id, result.ok ? 'info' : 'warn', `test ${result.ok ? 'passed' : 'failed'}: ${result.checks.map(c => `${c.check} ${c.ok ? 'ok' : 'FAIL'} (${c.detail})`).join('; ')}`);
page(req, res, {
page(req, res, {
title: 'Destination test',
title: 'Destination test',
- body: `<h1>Test ${result.ok ? pill('ok') : pill('failed')}</h1><table>${result.checks.map(c => `<tr><td>${esc(c.check)}</td><td>${c.ok ? pill('ok') : pill('failed')}</td><td>${esc(c.detail)}</td></tr>`).join('')}</table>
+ body: `${kit.pageHead({ title: 'Destination test', icon: 'check', back: { href: '/destinations', label: 'Destinations' } })}${result.ok ? pill('ok') : pill('failed')}<table data-ovk-scroll>${result.checks.map(c => `<tr><td>${esc(c.check)}</td><td>${c.ok ? pill('ok') : pill('failed')}</td><td>${esc(c.detail)}</td></tr>`).join('')}</table>
<p class="muted">A passing test means the ingest is reachable. Whether the platform accepts the key shows once an output runs.</p><p><a href="/streams/${esc(own.definition.id)}">Back</a></p>`,
<p class="muted">A passing test means the ingest is reachable. Whether the platform accepts the key shows once an output runs.</p><p><a href="/streams/${esc(own.definition.id)}">Back</a></p>`,
});
});
});
});
@@ -510,22 +535,22 @@ ${whereItGoes(d.record_to)}
@@ -510,22 +535,22 @@ ${whereItGoes(d.record_to)}
router.get('/sessions', async (req, res) => {
router.get('/sessions', async (req, res) => {
if (!needUser(req, res)) return;
if (!needUser(req, res)) return;
const all = req.caller.staff && req.query.all === '1';
const all = req.caller.staff && req.query.all === '1';
- page(req, res, { title: 'Sessions', canonicalPath: '/sessions', body: `<h1>Sessions</h1>${req.caller.staff ? `<p class="muted"><a href="/sessions${all ? '' : '?all=1'}">${all ? 'Only mine' : 'Everyone (staff)'}</a></p>` : ''}${sessionTable(await store.sessions.list({ owner_subject: all ? undefined : req.caller.subject, limit: 100 }))}` });
+ page(req, res, { title: 'Sessions', canonicalPath: '/sessions', body: `${kit.pageHead({ title: 'Sessions', icon: 'clock', back: { href: '/', label: 'Home' }, lede: 'Your live history, from first frame to final viewer.', actions: [{ href: '/go-live', label: 'Go live', icon: 'live' }] })}${req.caller.staff ? cards.scope(all) : ''}${await sessionTable(await store.sessions.list({ owner_subject: all ? undefined : req.caller.subject, limit: 100 }))}` });
});
});
router.get('/sessions/:id', async (req, res) => {
router.get('/sessions/:id', async (req, res) => {
if (!needUser(req, res)) return;
if (!needUser(req, res)) return;
const s = await store.sessions.get(req.params.id);
const s = await store.sessions.get(req.params.id);
const d = s ? await store.definitions.row(s.definition_id) : null;
const d = s ? await store.definitions.row(s.definition_id) : null;
- if (!s || !d || !auth.canAccess(req.caller, d.owner_subject)) return page(req, res, { title: 'Not found', body: '<h1>Session not found</h1>' }, 404);
+ if (!s || !d || !auth.canAccess(req.caller, d.owner_subject)) return page(req, res, { title: 'Not found', body: kit.pageHead({ title: 'Session not found', icon: 'help', back: { href: '/', label: 'Home' } }) }, 404);
const outputs = await store.outputs.outputsOfSession(s.id);
const outputs = await store.outputs.outputsOfSession(s.id);
const rec = await store.recordings.bySession(s.id);
const rec = await store.recordings.bySession(s.id);
const pb = await store.sessions.playback(s);
const pb = await store.sessions.playback(s);
const open = ['starting', 'live', 'ending'].includes(s.state);
const open = ['starting', 'live', 'ending'].includes(s.state);
return page(req, res, {
return page(req, res, {
title: `Session ${s.id}`, canonicalPath: `/sessions/${s.id}`,
title: `Session ${s.id}`, canonicalPath: `/sessions/${s.id}`,
- body: `<h1>Session ${pill(s.state)}</h1><p class="mono">${esc(s.id)}</p><p><a href="/streams/${esc(d.id)}">← ${esc(d.title)}</a></p>
-<div class="card"><table>
+ body: `${kit.pageHead({ title: d.title || 'Session', icon: 'clock', back: { href: '/sessions', label: 'Sessions' }, lede: 'Session details, output health and lifecycle.' })}${pill(s.state)}
+<div class="card"><table data-ovk-scroll>
<tr><th>Protocol</th><td>${esc(s.protocol)}</td></tr>
<tr><th>Protocol</th><td>${esc(s.protocol)}</td></tr>
<tr><th>Worker</th><td>${esc(s.worker_kind)} generation ${esc(s.worker_generation)} <span class="mono muted">${esc(s.worker_id)}</span></td></tr>
<tr><th>Worker</th><td>${esc(s.worker_kind)} generation ${esc(s.worker_generation)} <span class="mono muted">${esc(s.worker_id)}</span></td></tr>
<tr><th>Live since</th><td>${esc(iso(s.live_at))}</td></tr><tr><th>Ended</th><td>${esc(iso(s.ended_at))} ${esc(s.end_reason || s.failure_reason || '')}</td></tr>
<tr><th>Live since</th><td>${esc(iso(s.live_at))}</td></tr><tr><th>Ended</th><td>${esc(iso(s.ended_at))} ${esc(s.end_reason || s.failure_reason || '')}</td></tr>
@@ -533,15 +558,15 @@ ${whereItGoes(d.record_to)}
@@ -533,15 +558,15 @@ ${whereItGoes(d.record_to)}
<tr><th>Recording</th><td>${rec ? `${pill(rec.state)} ${rec.media_vod_id ? `Media ${esc(rec.media_app)} VOD ${esc(rec.media_vod_id)}` : ''} ${esc(rec.last_error || '')}${rec.media_app === config.media.videosAppId ? `<br>Private, in <a href="${esc(config.videoUrl)}/me/videos">your videos on OpenVibe.Video</a> once it is ready.` : ''}` : '<span class="muted">none</span>'}</td></tr>
<tr><th>Recording</th><td>${rec ? `${pill(rec.state)} ${rec.media_vod_id ? `Media ${esc(rec.media_app)} VOD ${esc(rec.media_vod_id)}` : ''} ${esc(rec.last_error || '')}${rec.media_app === config.media.videosAppId ? `<br>Private, in <a href="${esc(config.videoUrl)}/me/videos">your videos on OpenVibe.Video</a> once it is ready.` : ''}` : '<span class="muted">none</span>'}</td></tr>
<tr><th>Playback</th><td>${pb && pb.flv && s.state === 'live' ? `<a class="mono" href="${esc(pb.flv.public_url)}">${esc(pb.flv.public_url)}</a> (HTTP-FLV)` : '—'}</td></tr>
<tr><th>Playback</th><td>${pb && pb.flv && s.state === 'live' ? `<a class="mono" href="${esc(pb.flv.public_url)}">${esc(pb.flv.public_url)}</a> (HTTP-FLV)` : '—'}</td></tr>
</table>${open ? form(req, `/sessions/${s.id}/end`, '<button type="submit" class="danger">End session</button>', { confirm: 'Disconnect the encoder?' }) : ''}</div>
</table>${open ? form(req, `/sessions/${s.id}/end`, '<button type="submit" class="danger">End session</button>', { confirm: 'Disconnect the encoder?' }) : ''}</div>
-<h2>Outputs</h2>${outputs.length ? `<table><tr><th>Output</th><th>State</th><th>Health</th><th>Restarts</th></tr>${(await outputs.map(o => `<tr><td class="mono">${esc(o.id)}<br><span class="muted">${esc(o.destination_id)}</span></td><td>${pill(o.state)}</td><td>${o.progress ? `${esc(o.progress.fps ?? '?')} fps · ${esc(o.progress.bitrate_kbps ?? '?')} kbit/s` : ''} ${esc(o.last_error || '')}</td><td>${esc(o.restart_attempts)}/${esc(o.max_restart_attempts)}</td></tr>`)).join('')}</table>` : '<p class="muted">No outputs.</p>'}
-<h2>Lifecycle</h2><table>${(await store.sessions.transitions(s.id)).map(t => `<tr><td>${esc(iso(t.at))}</td><td>${esc(t.from_state || '·')} → ${pill(t.to_state)}</td><td>${esc(t.reason || '')}</td><td class="mono muted">${esc(t.actor || '')}</td></tr>`).join('')}</table>`,
+<h2 class="ovk-titled">${kit.icon('link', { cls: 'ovk-heading-icon' })}Outputs</h2>${outputs.length ? `<table data-ovk-scroll><tr><th>Output</th><th>State</th><th>Health</th><th>Restarts</th></tr>${(await outputs.map(o => `<tr><td class="mono">${esc(o.id)}<br><span class="muted">${esc(o.destination_id)}</span></td><td>${pill(o.state)}</td><td>${o.progress ? `${esc(o.progress.fps ?? '?')} fps · ${esc(o.progress.bitrate_kbps ?? '?')} kbit/s` : ''} ${esc(o.last_error || '')}</td><td>${esc(o.restart_attempts)}/${esc(o.max_restart_attempts)}</td></tr>`)).join('')}</table>` : kit.empty({ icon: 'link', text: 'Delivery health appears here when this session sends to a destination.' })}
+<h2 class="ovk-titled">${kit.icon('clock', { cls: 'ovk-heading-icon' })}Lifecycle</h2><table data-ovk-scroll>${(await store.sessions.transitions(s.id)).map(t => `<tr><td>${esc(iso(t.at))}</td><td>${esc(t.from_state || '·')} → ${pill(t.to_state)}</td><td>${esc(t.reason || '')}</td><td class="mono muted">${esc(t.actor || '')}</td></tr>`).join('')}</table>`,
});
});
});
});
post('/sessions/:id/end', async (req, res) => {
post('/sessions/:id/end', async (req, res) => {
const s = await store.sessions.get(req.params.id);
const s = await store.sessions.get(req.params.id);
const d = s ? await store.definitions.row(s.definition_id) : null;
const d = s ? await store.definitions.row(s.definition_id) : null;
- if (!s || !d || !auth.canAccess(req.caller, d.owner_subject)) return page(req, res, { title: 'Not found', body: '<h1>Session not found</h1>' }, 404);
+ if (!s || !d || !auth.canAccess(req.caller, d.owner_subject)) return page(req, res, { title: 'Not found', body: kit.pageHead({ title: 'Session not found', icon: 'help', back: { href: '/', label: 'Home' } }) }, 404);
await store.sessions.requestEnd(s.id, req.caller.subject);
await store.sessions.requestEnd(s.id, req.caller.subject);
return res.redirect(303, `/sessions/${s.id}`);
return res.redirect(303, `/sessions/${s.id}`);
});
});
new file mode 100644
new file mode 100644
@@ -0,0 +1,38 @@
@@ -0,0 +1,38 @@
+'use strict';
+const assert = require('assert');
+const { bootApi, request, userToken, suite, OWNER, OTHER } = require('./helpers');
+const cards = require('../server/ui/cards');
+const t = suite('app-shell');
+t('session scope and new tabs retain owner boundaries', async () => {
+ const api = await bootApi();
+ try {
+ const mine = await api.rt.store.definitions.create({ owner_subject: OWNER, title: 'My broadcast' });
+ await api.rt.store.definitions.create({ owner_subject: OTHER, title: 'Private other broadcast' });
+ const cookie = `ov_token=${userToken()}`;
+ for (const path of ['/streams', '/destinations', '/settings', '/go-live', '/sessions?all=1']) {
+ const r = await request(api.base, 'GET', path, { cookie });
+ assert.strictEqual(r.status, 200, path);
+ assert.ok(r.text.includes('ovk-page-head'), path);
+ assert.ok(r.text.includes('"appBar":true'), path);
+ assert.ok(!r.text.includes('Private other broadcast'), path);
+ }
+ const ordinary = await request(api.base, 'GET', '/sessions?all=1', { cookie });
+ assert.ok(!ordinary.text.includes('Session owner'));
+ assert.ok(ordinary.text.includes('Your restream sessions show here: each time you go live, where it went and how it did'));
+ const staff = await request(api.base, 'GET', '/sessions?all=1', { cookie: `ov_token=${userToken({ role: 'admin' })}` });
+ assert.ok(staff.text.includes('Session owner') && staff.text.includes('Everyone'));
+ const anonymous = await request(api.base, 'GET', '/destinations');
+ assert.ok(anonymous.status === 401 || anonymous.status === 302);
+ assert.ok(mine.definition.id);
+ } finally { await api.close(); }
+});
+t('session cards escape content and distinguish missing measurements', () => {
+ const h = cards.session({ id: 's_test', state: 'ended', created_at: 1700000000000, ended_at: 1700003600000 }, { title: '<script>bad</script>' }, [{ state: 'error', last_error: 'lost' }], [{ platform: 'twitch' }]);
+ assert.ok(h.includes('<script>') && !h.includes('<script>bad'));
+ assert.ok(h.includes('1h 0m') && h.includes('Needs attention'));
+ // No made-up number: an ended card has no Viewers figure, and its sheet says the destinations report none.
+ assert.ok(!h.includes('<dt>Viewers</dt>') && h.includes('Viewer totals are not reported by these destinations.'));
+ assert.ok(h.includes('data-ovk-sheet') && h.includes('<dialog') && h.includes('/sessions/s_test'));
+ assert.ok(h.includes('Twitch') && h.includes('<svg'));
+});
+void t.run();
@@ -108,7 +108,8 @@ t('the front page leads with open restreaming, links to Live for browser go-live
@@ -108,7 +108,8 @@ t('the front page leads with open restreaming, links to Live for browser go-live
try {
try {
const r = await request(api.base, 'GET', '/');
const r = await request(api.base, 'GET', '/');
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);
- assert.match(r.text, /<h1 id="ore-hero-h">Go live once\.<br><span class="ore-accent">Stream everywhere\.<\/span><\/h1>/);
+ assert.match(r.text, /<h1>Go live once\. Stream everywhere\.<\/h1>/);
+ assert.match(r.text, /ovk-page-head/);
// The hero's signal flow (11-07): every platform with its own switch, the count by CSS, no script needed.
// The hero's signal flow (11-07): every platform with its own switch, the count by CSS, no script needed.
const flow = (/<div class="ore-flow"[\s\S]*?<\/ul>/.exec(r.text) || [''])[0];
const flow = (/<div class="ore-flow"[\s\S]*?<\/ul>/.exec(r.text) || [''])[0];
for (const name of ['Twitch', 'YouTube', 'Kick', 'X', 'Facebook', 'TikTok', 'LinkedIn', 'OpenVibe']) assert.match(flow, new RegExp('<span class="ore-dest-name">' + name + '</span>'), name);
for (const name of ['Twitch', 'YouTube', 'Kick', 'X', 'Facebook', 'TikTok', 'LinkedIn', 'OpenVibe']) assert.match(flow, new RegExp('<span class="ore-dest-name">' + name + '</span>'), name);
@@ -116,7 +117,7 @@ t('the front page leads with open restreaming, links to Live for browser go-live
@@ -116,7 +117,7 @@ t('the front page leads with open restreaming, links to Live for browser go-live
assert.doesNotMatch(flow, /<script/);
assert.doesNotMatch(flow, /<script/);
// The free tier's real limits, as the service runs them (market outcome 1): the cap from its own config.
// The free tier's real limits, as the service runs them (market outcome 1): the cap from its own config.
const tier = (/<section class="sc-sec ore-tier"[\s\S]*?<\/section>/.exec(r.text) || [''])[0];
const tier = (/<section class="sc-sec ore-tier"[\s\S]*?<\/section>/.exec(r.text) || [''])[0];
- assert.match(tier, /<h2 id="ore-tier-h">The free tier, in numbers<\/h2>/);
+ assert.match(tier, /<h2 id="ore-tier-h"[^>]*>[\s\S]*?The free tier, in numbers<\/h2>/);
assert.match(tier, /<dt>Destinations<\/dt><dd>Up to 10 a stream, each with its own health, logs and retries<\/dd>/);
assert.match(tier, /<dt>Destinations<\/dt><dd>Up to 10 a stream, each with its own health, logs and retries<\/dd>/);
assert.match(tier, /<dt>Quality<\/dt><dd>What you send: a stream from OBS is copied, never re-encoded<\/dd>/);
assert.match(tier, /<dt>Quality<\/dt><dd>What you send: a stream from OBS is copied, never re-encoded<\/dd>/);
assert.match(tier, /<dt>Out<\/dt><dd>RTMP, RTMPS and SRT<\/dd>/);
assert.match(tier, /<dt>Out<\/dt><dd>RTMP, RTMPS and SRT<\/dd>/);