openvibers/openvibe.live Commits
openvibe-shared v4.15.0 (was v4.14.0), and a channel's footer is compact only while it is live (#387)
Changes
@@@ -177,5 +177,5 @@ Minimum `.env`: `BASE_URL`, `JWT_SECRET`, `OV_NETWORK_URL`, `OV_NETWORK_INTERNAL<!-- versions:start -->- openvibe-contracts: v0.214.0- openvibe-sdk: v0.65.0--- openvibe-shared: v4.14.0++- openvibe-shared: v4.15.0<!-- versions:end -->@@@ -7,7 +7,7 @@"domain": "openvibe.live (www, ingest, whip)","contracts": "openvibe-contracts v0.214.0","sdk": "openvibe-sdk v0.65.0",-- "shared": "openvibe-shared v4.14.0",++ "shared": "openvibe-shared v4.15.0","capabilities": ["live.chat_context.read","live.chat_effects.write",@@@ -32,5 +32,5 @@"per-actor limits on API writes; account export and deletion contributions; follows on OpenVibe.Network","channel ↔ OpenVibe.Bot robot binding, its dashboard field and the Bot panel iframe on the channel page behind LIVE_BOT_EMBED (off by default; on in production)"],-- "updated": "2026-10-26"++ "updated": "2026-10-27"}@@@ -25,7 +25,7 @@"onnxruntime-node": "^1.20.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.214.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.65.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.14.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.15.0","pg": "^8.23.1","sanitize-html": "^2.17.7","sdp-transform": "^3.0.0",@@@ -2688,9 +2688,9 @@}},"node_modules/openvibe-shared": {-- "version": "4.14.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.14.0",-- "integrity": "sha512-ZDGfX/fDOhpAfEvT+TEmj4Uz5spyE+gjqdEfY+iE623MLFQq6SFCj+qhdDxWsDS3X0UESAcf445OxSevbPOV9g==",++ "version": "4.15.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.15.0",++ "integrity": "sha512-v5iz3ZlLSuT3uYwHjBkR4ryeAxVA2ejmrrYSDAkaaXeYfTb4mIPjwoiM7V3tT4jA+Q2SJu1B+XUL5FkOt34ghg==","license": "MIT","dependencies": {"jsonwebtoken": "^9.0.0"@@@ -44,7 +44,7 @@"onnxruntime-node": "^1.20.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.214.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.65.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.14.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.15.0","pg": "^8.23.1","sanitize-html": "^2.17.7","sdp-transform": "^3.0.0",@@@ -797,6 +797,7 @@ async function loadChannelPage(username, managedStreamRef = null, legacySessionI// ── LIVE STATE ──document.getElementById('ch-live-area').style.display = '';document.getElementById('ch-offline-area').style.display = 'none';++ _channelFooter(true);_placeBelowFold();// Populate streamer info bar (below video). Avatar image (letter fallback)@@@ -877,6 +878,7 @@ async function loadChannelPage(username, managedStreamRef = null, legacySessionI}document.getElementById('ch-live-area').style.display = 'none';document.getElementById('ch-offline-area').style.display = '';++ _channelFooter(false);_placeBelowFold();// Populate offline header@@@ -2302,6 +2304,22 @@ async function loadChannelAiTimeline(username) {}}++// The footer while the stream plays is the quiet one-line footer; an offline channel gets the whole footer, where someone++// looks for where to go next (owner, 2026-10-27). The shared footer's claim (openvibe-shared 4.15): it holds while the++// stream is live and is released when the stream ends or the page is left (public/js/footer-live.js keeps channels full).++let _footerRelease = null;++function _channelFooter(live) {++ const F = window.OpenVibeFooter;++ if (!F || typeof F.claim !== 'function') return;++ if (live && !_footerRelease) {++ _footerRelease = F.claim('compact', 'channel-live');++ if (window.ov) ov.scope().onDispose(() => _channelFooter(false));++ } else if (!live && _footerRelease) {++ _footerRelease();++ _footerRelease = null;++ }++}++// The channel's OpenVibe.Bot robot panel (roadmap T15 R9). `bot_embed` is only in the channel JSON with// LIVE_BOT_EMBED on, and public/js/bot-embed.js (feature "botEmbed") only loads for a bound channel, so with// the flag off this does nothing. The frame goes when the route ends, closing its socket to Bot.@@@ -5,9 +5,11 @@* openvibe-shared/footer.js, which every OpenVibe property loads. This file supplies only the* links that belong to Live, and decides which variant a page should use.*-- * Variant policy: a streamer's channel, a player and the broadcast desk are places people are-- * *doing* something, so they get the one-line compact footer. Browse and landing pages, where-- * someone may be looking for where to go next, get the full one.++ * Variant policy: a player and the broadcast desk are places people are *doing* something, so they++ * get the one-line compact footer. Browse and landing pages, where someone may be looking for where++ * to go next, get the full one. A streamer's channel is full while offline and compact while its++ * stream plays: the channel page claims compact for as long as the stream is live (app-channel.js++ * _channelFooter, openvibe-shared 4.15 claim()); with an older footer it stays compact.*/(function () {'use strict';@@@ -39,7 +41,7 @@function variantForPage() {const p = location.pathname;if (p === '/' || p === '') return 'full';-- if (/^\/@/.test(p)) return 'compact'; // streamer channels++ if (/^\/@/.test(p)) return window.OpenVibeFooter && typeof OpenVibeFooter.claim === 'function' ? 'full' : 'compact'; // channels: full, compact while liveconst seg = p.split('/').filter(Boolean)[0] || '';if (COMPACT_PAGES.includes(seg)) return 'compact';if (seg === 'vod' || seg === 'clip' || seg === 'recap') return 'compact';@@@ -23,7 +23,7 @@ const BUDGETS = {htmlBrotliKB: 30,jsFiles: 26, // measured 23 (incl. route scripts for "/")jsRawKB: 1150, // measured ~1000-- jsBrotliKB: 265, // measured 263.3 (2026-10-23: openvibe-shared 4.9.0, status text twins in every theme); 262.5 on 10-17 (openvibe-shared 4.4.0, every site in the launcher)++ jsBrotliKB: 266.5, // measured 265.3 (2026-10-27: openvibe-shared 4.15.0, the compact footer opens into the whole one, and the pulse line); 263.3 on 10-23 (openvibe-shared 4.9.0, status text twins in every theme); 262.5 on 10-17 (openvibe-shared 4.4.0, every site in the launcher)cssRawKB: 270, // measured ~243 since the first-paint subset (style.core.css) replaced style.css (~717 before)cssBrotliKB: 55, // measured ~47.5 (was ~130)};nnew file mode 100644@@@ -0,0 +1,42 @@++/**++ * A channel's footer: compact while its stream plays, full while it is offline (owner, 2026-10-27: "you can use compact++ * footer on the live streamer channel when they are live only like openvibe.live/@goosely but it should be full mode++ * when not live"). The channel page claims compact from the shared footer (openvibe-shared 4.15 claim()) when it shows the++ * live state and releases it when it shows the offline state or the page is left; the page's own variant for a channel++ * is full. With an older shared footer (no claim) a channel stays compact, as before.++ *++ * node test/footer-channel-claim.test.js++ */++'use strict';++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const vm = require('vm');++++const read = (f) => fs.readFileSync(path.join(__dirname, '..', f), 'utf8');++const app = read('public/js/app-channel.js');++const fn = app.slice(app.indexOf('function _channelFooter('), app.indexOf('\n}\n', app.indexOf('function _channelFooter(')) + 3);++++// The claim, run against a fake shared footer.++const calls = [];++const disposers = [];++const ctx = vm.createContext({++ window: { OpenVibeFooter: { claim: (v, k) => { calls.push(['claim', v, k]); return () => calls.push(['release', k]); } }, ov: true },++ ov: { scope: () => ({ onDispose: (f) => disposers.push(f) }) },++});++vm.runInContext(`let _footerRelease = null;\n${fn}\nthis._channelFooter = _channelFooter;`, ctx);++ctx._channelFooter(true);++ctx._channelFooter(true);++assert.deepStrictEqual(calls, [['claim', 'compact', 'channel-live']], 'one claim while live, however often the live state is drawn');++ctx._channelFooter(false);++assert.deepStrictEqual(calls.at(-1), ['release', 'channel-live'], 'released when the channel goes offline');++ctx._channelFooter(true);++disposers.forEach((d) => d());++assert.deepStrictEqual(calls.at(-1), ['release', 'channel-live'], 'released when the page is left');++++// Both states draw it; the channel's own variant is full only when the footer can be claimed.++assert.ok(/\/\/ ── LIVE STATE ──[\s\S]{0,300}_channelFooter\(true\);/.test(app), 'the live state claims');++assert.ok(/getElementById\('ch-offline-area'\)\.style\.display = '';\n\s*_channelFooter\(false\);/.test(app), 'the offline state releases');++assert.ok(read('public/js/footer-live.js').includes("if (/^\\/@/.test(p)) return window.OpenVibeFooter && typeof OpenVibeFooter.claim === 'function' ? 'full' : 'compact';"));++++console.log('channel footer: compact while live, full while offline');
@@ -177,5 +177,5 @@ Minimum `.env`: `BASE_URL`, `JWT_SECRET`, `OV_NETWORK_URL`, `OV_NETWORK_INTERNAL
@@ -177,5 +177,5 @@ Minimum `.env`: `BASE_URL`, `JWT_SECRET`, `OV_NETWORK_URL`, `OV_NETWORK_INTERNAL
<!-- versions:start -->
<!-- versions:start -->
- openvibe-contracts: v0.214.0
- openvibe-contracts: v0.214.0
- openvibe-sdk: v0.65.0
- openvibe-sdk: v0.65.0
-- openvibe-shared: v4.14.0
+- openvibe-shared: v4.15.0
<!-- versions:end -->
<!-- versions:end -->
@@ -7,7 +7,7 @@
@@ -7,7 +7,7 @@
"domain": "openvibe.live (www, ingest, whip)",
"domain": "openvibe.live (www, ingest, whip)",
"contracts": "openvibe-contracts v0.214.0",
"contracts": "openvibe-contracts v0.214.0",
"sdk": "openvibe-sdk v0.65.0",
"sdk": "openvibe-sdk v0.65.0",
- "shared": "openvibe-shared v4.14.0",
+ "shared": "openvibe-shared v4.15.0",
"capabilities": [
"capabilities": [
"live.chat_context.read",
"live.chat_context.read",
"live.chat_effects.write",
"live.chat_effects.write",
@@ -32,5 +32,5 @@
@@ -32,5 +32,5 @@
"per-actor limits on API writes; account export and deletion contributions; follows on OpenVibe.Network",
"per-actor limits on API writes; account export and deletion contributions; follows on OpenVibe.Network",
"channel ↔ OpenVibe.Bot robot binding, its dashboard field and the Bot panel iframe on the channel page behind LIVE_BOT_EMBED (off by default; on in production)"
"channel ↔ OpenVibe.Bot robot binding, its dashboard field and the Bot panel iframe on the channel page behind LIVE_BOT_EMBED (off by default; on in production)"
],
],
- "updated": "2026-10-26"
+ "updated": "2026-10-27"
}
}
@@ -25,7 +25,7 @@
@@ -25,7 +25,7 @@
"onnxruntime-node": "^1.20.0",
"onnxruntime-node": "^1.20.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.214.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.214.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.65.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.65.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.14.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.15.0",
"pg": "^8.23.1",
"pg": "^8.23.1",
"sanitize-html": "^2.17.7",
"sanitize-html": "^2.17.7",
"sdp-transform": "^3.0.0",
"sdp-transform": "^3.0.0",
@@ -2688,9 +2688,9 @@
@@ -2688,9 +2688,9 @@
}
}
},
},
"node_modules/openvibe-shared": {
"node_modules/openvibe-shared": {
- "version": "4.14.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.14.0",
- "integrity": "sha512-ZDGfX/fDOhpAfEvT+TEmj4Uz5spyE+gjqdEfY+iE623MLFQq6SFCj+qhdDxWsDS3X0UESAcf445OxSevbPOV9g==",
+ "version": "4.15.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.15.0",
+ "integrity": "sha512-v5iz3ZlLSuT3uYwHjBkR4ryeAxVA2ejmrrYSDAkaaXeYfTb4mIPjwoiM7V3tT4jA+Q2SJu1B+XUL5FkOt34ghg==",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0"
"jsonwebtoken": "^9.0.0"
@@ -44,7 +44,7 @@
@@ -44,7 +44,7 @@
"onnxruntime-node": "^1.20.0",
"onnxruntime-node": "^1.20.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.214.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.214.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.65.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.65.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.14.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.15.0",
"pg": "^8.23.1",
"pg": "^8.23.1",
"sanitize-html": "^2.17.7",
"sanitize-html": "^2.17.7",
"sdp-transform": "^3.0.0",
"sdp-transform": "^3.0.0",
@@ -797,6 +797,7 @@ async function loadChannelPage(username, managedStreamRef = null, legacySessionI
@@ -797,6 +797,7 @@ async function loadChannelPage(username, managedStreamRef = null, legacySessionI
// ── LIVE STATE ──
// ── LIVE STATE ──
document.getElementById('ch-live-area').style.display = '';
document.getElementById('ch-live-area').style.display = '';
document.getElementById('ch-offline-area').style.display = 'none';
document.getElementById('ch-offline-area').style.display = 'none';
+ _channelFooter(true);
_placeBelowFold();
_placeBelowFold();
// Populate streamer info bar (below video). Avatar image (letter fallback)
// Populate streamer info bar (below video). Avatar image (letter fallback)
@@ -877,6 +878,7 @@ async function loadChannelPage(username, managedStreamRef = null, legacySessionI
@@ -877,6 +878,7 @@ async function loadChannelPage(username, managedStreamRef = null, legacySessionI
}
}
document.getElementById('ch-live-area').style.display = 'none';
document.getElementById('ch-live-area').style.display = 'none';
document.getElementById('ch-offline-area').style.display = '';
document.getElementById('ch-offline-area').style.display = '';
+ _channelFooter(false);
_placeBelowFold();
_placeBelowFold();
// Populate offline header
// Populate offline header
@@ -2302,6 +2304,22 @@ async function loadChannelAiTimeline(username) {
@@ -2302,6 +2304,22 @@ async function loadChannelAiTimeline(username) {
}
}
}
}
+// The footer while the stream plays is the quiet one-line footer; an offline channel gets the whole footer, where someone
+// looks for where to go next (owner, 2026-10-27). The shared footer's claim (openvibe-shared 4.15): it holds while the
+// stream is live and is released when the stream ends or the page is left (public/js/footer-live.js keeps channels full).
+let _footerRelease = null;
+function _channelFooter(live) {
+ const F = window.OpenVibeFooter;
+ if (!F || typeof F.claim !== 'function') return;
+ if (live && !_footerRelease) {
+ _footerRelease = F.claim('compact', 'channel-live');
+ if (window.ov) ov.scope().onDispose(() => _channelFooter(false));
+ } else if (!live && _footerRelease) {
+ _footerRelease();
+ _footerRelease = null;
+ }
+}
+
// The channel's OpenVibe.Bot robot panel (roadmap T15 R9). `bot_embed` is only in the channel JSON with
// The channel's OpenVibe.Bot robot panel (roadmap T15 R9). `bot_embed` is only in the channel JSON with
// LIVE_BOT_EMBED on, and public/js/bot-embed.js (feature "botEmbed") only loads for a bound channel, so with
// LIVE_BOT_EMBED on, and public/js/bot-embed.js (feature "botEmbed") only loads for a bound channel, so with
// the flag off this does nothing. The frame goes when the route ends, closing its socket to Bot.
// the flag off this does nothing. The frame goes when the route ends, closing its socket to Bot.
@@ -5,9 +5,11 @@
@@ -5,9 +5,11 @@
* openvibe-shared/footer.js, which every OpenVibe property loads. This file supplies only the
* openvibe-shared/footer.js, which every OpenVibe property loads. This file supplies only the
* links that belong to Live, and decides which variant a page should use.
* links that belong to Live, and decides which variant a page should use.
*
*
- * Variant policy: a streamer's channel, a player and the broadcast desk are places people are
- * *doing* something, so they get the one-line compact footer. Browse and landing pages, where
- * someone may be looking for where to go next, get the full one.
+ * Variant policy: a player and the broadcast desk are places people are *doing* something, so they
+ * get the one-line compact footer. Browse and landing pages, where someone may be looking for where
+ * to go next, get the full one. A streamer's channel is full while offline and compact while its
+ * stream plays: the channel page claims compact for as long as the stream is live (app-channel.js
+ * _channelFooter, openvibe-shared 4.15 claim()); with an older footer it stays compact.
*/
*/
(function () {
(function () {
'use strict';
'use strict';
@@ -39,7 +41,7 @@
@@ -39,7 +41,7 @@
function variantForPage() {
function variantForPage() {
const p = location.pathname;
const p = location.pathname;
if (p === '/' || p === '') return 'full';
if (p === '/' || p === '') return 'full';
- if (/^\/@/.test(p)) return 'compact'; // streamer channels
+ if (/^\/@/.test(p)) return window.OpenVibeFooter && typeof OpenVibeFooter.claim === 'function' ? 'full' : 'compact'; // channels: full, compact while live
const seg = p.split('/').filter(Boolean)[0] || '';
const seg = p.split('/').filter(Boolean)[0] || '';
if (COMPACT_PAGES.includes(seg)) return 'compact';
if (COMPACT_PAGES.includes(seg)) return 'compact';
if (seg === 'vod' || seg === 'clip' || seg === 'recap') return 'compact';
if (seg === 'vod' || seg === 'clip' || seg === 'recap') return 'compact';
@@ -23,7 +23,7 @@ const BUDGETS = {
@@ -23,7 +23,7 @@ const BUDGETS = {
htmlBrotliKB: 30,
htmlBrotliKB: 30,
jsFiles: 26, // measured 23 (incl. route scripts for "/")
jsFiles: 26, // measured 23 (incl. route scripts for "/")
jsRawKB: 1150, // measured ~1000
jsRawKB: 1150, // measured ~1000
- jsBrotliKB: 265, // measured 263.3 (2026-10-23: openvibe-shared 4.9.0, status text twins in every theme); 262.5 on 10-17 (openvibe-shared 4.4.0, every site in the launcher)
+ jsBrotliKB: 266.5, // measured 265.3 (2026-10-27: openvibe-shared 4.15.0, the compact footer opens into the whole one, and the pulse line); 263.3 on 10-23 (openvibe-shared 4.9.0, status text twins in every theme); 262.5 on 10-17 (openvibe-shared 4.4.0, every site in the launcher)
cssRawKB: 270, // measured ~243 since the first-paint subset (style.core.css) replaced style.css (~717 before)
cssRawKB: 270, // measured ~243 since the first-paint subset (style.core.css) replaced style.css (~717 before)
cssBrotliKB: 55, // measured ~47.5 (was ~130)
cssBrotliKB: 55, // measured ~47.5 (was ~130)
};
};
new file mode 100644
new file mode 100644
@@ -0,0 +1,42 @@
@@ -0,0 +1,42 @@
+/**
+ * A channel's footer: compact while its stream plays, full while it is offline (owner, 2026-10-27: "you can use compact
+ * footer on the live streamer channel when they are live only like openvibe.live/@goosely but it should be full mode
+ * when not live"). The channel page claims compact from the shared footer (openvibe-shared 4.15 claim()) when it shows the
+ * live state and releases it when it shows the offline state or the page is left; the page's own variant for a channel
+ * is full. With an older shared footer (no claim) a channel stays compact, as before.
+ *
+ * node test/footer-channel-claim.test.js
+ */
+'use strict';
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const vm = require('vm');
+
+const read = (f) => fs.readFileSync(path.join(__dirname, '..', f), 'utf8');
+const app = read('public/js/app-channel.js');
+const fn = app.slice(app.indexOf('function _channelFooter('), app.indexOf('\n}\n', app.indexOf('function _channelFooter(')) + 3);
+
+// The claim, run against a fake shared footer.
+const calls = [];
+const disposers = [];
+const ctx = vm.createContext({
+ window: { OpenVibeFooter: { claim: (v, k) => { calls.push(['claim', v, k]); return () => calls.push(['release', k]); } }, ov: true },
+ ov: { scope: () => ({ onDispose: (f) => disposers.push(f) }) },
+});
+vm.runInContext(`let _footerRelease = null;\n${fn}\nthis._channelFooter = _channelFooter;`, ctx);
+ctx._channelFooter(true);
+ctx._channelFooter(true);
+assert.deepStrictEqual(calls, [['claim', 'compact', 'channel-live']], 'one claim while live, however often the live state is drawn');
+ctx._channelFooter(false);
+assert.deepStrictEqual(calls.at(-1), ['release', 'channel-live'], 'released when the channel goes offline');
+ctx._channelFooter(true);
+disposers.forEach((d) => d());
+assert.deepStrictEqual(calls.at(-1), ['release', 'channel-live'], 'released when the page is left');
+
+// Both states draw it; the channel's own variant is full only when the footer can be claimed.
+assert.ok(/\/\/ ── LIVE STATE ──[\s\S]{0,300}_channelFooter\(true\);/.test(app), 'the live state claims');
+assert.ok(/getElementById\('ch-offline-area'\)\.style\.display = '';\n\s*_channelFooter\(false\);/.test(app), 'the offline state releases');
+assert.ok(read('public/js/footer-live.js').includes("if (/^\\/@/.test(p)) return window.OpenVibeFooter && typeof OpenVibeFooter.claim === 'function' ? 'full' : 'compact';"));
+
+console.log('channel footer: compact while live, full while offline');