openvibers/openvibe.live Commits
Live player: one clock (no buffer-length time beside the uptime at the live edge; rewound it reads how far behind live), a calmer LIVE / Go live button and plain viewer and uptime stats (#285)
Changes
@@@ -512,10 +512,10 @@}}.viewer-badge, .uptime-badge {-- font-size: 0.75rem; color: rgba(255,255,255,0.85);-- display: flex; align-items: center; gap: 4px;-- padding: 2px 8px; border-radius: var(--radius-sm);-- background: rgba(255,255,255,0.08);++ font-size: 0.78rem; color: rgba(255,255,255,0.9);++ display: flex; align-items: center; gap: 5px;++ padding: 0 6px;++ font-variant-numeric: tabular-nums;}.dvr-progress-wrap:hover .dvr-progress-bar {height: 6px;@@@ -2844,9 +2844,8 @@ a.pulse-card:hover { border-color: var(--accent); transform: translateY(-2px); }border: 2px solid #fff; box-shadow: 0 1px 3px rgba(0,0,0,0.4);}--/* Badges */--.viewer-badge i { color: var(--live-red); }--.uptime-badge i { color: var(--accent); }++/* Viewers and uptime: plain stats on the bar, not coloured pills (owner, 2026-10-20: "this gui should be improved"). */++.viewer-badge i, .uptime-badge i { color: rgba(255,255,255,0.65); }/* ── DVR Progress Bar (Live Stream Seeking) ─────────────────── */.dvr-progress-wrap {@@@ -2881,25 +2880,28 @@ a.pulse-card:hover { border-color: var(--accent); transform: translateY(-2px); }/* DVR in non-live (rewound) mode: use accent color instead of red */.dvr-time-display {-- font-size: 0.75rem; color: rgba(255,255,255,0.85);-- font-family: var(--font-mono);++ font-size: 0.78rem; color: rgba(255,255,255,0.9);++ font-variant-numeric: tabular-nums;white-space: nowrap; padding: 0 4px;-- min-width: 82px; text-align: center;}++.dvr-time-display[hidden] { display: none !important; }++/* LIVE: a red dot while at the live edge; behind it, a grey dot and "Go live" (a click jumps back). */.dvr-live-btn {-- background: #e53e3e !important; color: #fff !important;-- font-size: 0.7rem !important; font-weight: 700;-- padding: 3px 10px !important; border-radius: 4px !important;-- gap: 4px; letter-spacing: 0.5px;-- animation: livePulse 1.5s infinite;--}--.dvr-live-btn:hover { background: #c53030 !important; }--.dvr-live-btn.dvr-behind {-- background: rgba(255,255,255,0.15) !important;-- border: 1px solid rgba(255,255,255,0.3);-- animation: none;++ color: #fff !important;++ font-size: 0.72rem !important; font-weight: 700;++ letter-spacing: 0.06em; text-transform: uppercase;++ padding: 4px 10px !important; border-radius: 999px !important;++ gap: 7px;++}++.dvr-live-btn .live-dot {++ display: inline-block; flex-shrink: 0;++ width: 8px; height: 8px; border-radius: 50%;++ background: var(--live-red, #e53e3e);++ box-shadow: 0 0 0 3px rgba(229,62,62,0.28);}++.dvr-live-btn.dvr-behind { background: rgba(255,255,255,0.14) !important; }++.dvr-live-btn.dvr-behind .live-dot { background: rgba(255,255,255,0.6); box-shadow: none; animation: none; }/* Progress bar (for VOD/clip players) */.vc-progress-wrap {@@@ -40,11 +40,9 @@</button><input type="range" id="volume-slider" min="0" max="100" value="75" class="volume-slider" aria-label="Volume"></div>-- <span class="dvr-time-display" id="dvr-time-display" style="display:none">0:00 / 0:00</span>++ <span class="dvr-time-display" id="dvr-time-display" style="display:none" hidden></span><div class="vc-spacer"></div>-- <button class="vc-btn dvr-live-btn" id="dvr-live-btn" title="Jump to Live" style="display:none">-- <i class="fa-solid fa-tower-broadcast"></i> LIVE-- </button>++ <button class="vc-btn dvr-live-btn" id="dvr-live-btn" title="Jump to live" style="display:none"><span class="live-dot"></span>LIVE</button><span class="viewer-badge" id="viewer-count-badge"><i class="fa-solid fa-eye"></i> <span id="vc-viewers">0</span></span>@@@ -2391,20 +2391,22 @@ function updateDVRUI() {const container = document.getElementById('video-container');if (dvrState.isLive) {-- // At live edge — progress bar is full (100%)++ // At the live edge the bar shows no time of its own: the uptime beside it is the one clock. This read the DVR++ // buffer's length, which trails the stream by a segment or more ("1:30:00 [LIVE]" next to "1:30:17").if (fill) fill.style.width = '100%';-- const elapsed = dvrState.duration > 0-- ? dvrState.duration-- : Math.max(0, Math.floor((Date.now() - dvrState.streamStartTime) / 1000));-- if (timeEl) timeEl.textContent = `${_dvrFmtTime(elapsed)} [LIVE]`;++ if (timeEl) { timeEl.hidden = true; timeEl.textContent = ''; }if (container) container.classList.remove('dvr-rewound');} else {-- // Rewound — show position within the DVR buffer++ // Rewound: how far behind live, with the position in the buffer as its titleconst video = document.getElementById('video-element');if (video && dvrState.duration > 0 && !dvrState.seeking) {const pct = (video.currentTime / dvrState.duration) * 100;if (fill) fill.style.width = Math.min(pct, 100) + '%';-- if (timeEl) timeEl.textContent = `${_dvrFmtTime(video.currentTime)} / ${_dvrFmtTime(dvrState.duration)}`;++ if (timeEl) {++ timeEl.hidden = false;++ timeEl.textContent = `\u2212${_dvrFmtTime(dvrState.duration - video.currentTime)}`;++ timeEl.title = `${_dvrFmtTime(video.currentTime)} of ${_dvrFmtTime(dvrState.duration)} recorded; behind live`;++ }}if (container) container.classList.add('dvr-rewound');}@@@ -2531,7 +2533,7 @@ function switchToDVR(targetTime) {// Show "Jump to Live" button in alert styleif (liveBtn) {liveBtn.classList.add('dvr-behind');-- liveBtn.innerHTML = '<i class="fa-solid fa-forward"></i> Back to LIVE';++ liveBtn.innerHTML = '<span class="live-dot"></span>Go live';}// Ensure the video element is visible (JSMPEG normally hides it)@@@ -2615,7 +2617,7 @@ function jumpToLive() {// Reset live buttonif (liveBtn) {liveBtn.classList.remove('dvr-behind');-- liveBtn.innerHTML = '<i class="fa-solid fa-tower-broadcast"></i> LIVE';++ liveBtn.innerHTML = '<span class="live-dot"></span>LIVE';}updateDVRUI();nnew file mode 100644@@@ -0,0 +1,73 @@++/**++ * The live player's bar has one clock (owner, 2026-10-20: "why is there two spots for this and why is one inaccurate").++ * At the live edge the DVR time showed the recorded buffer's length ("1:30:00 [LIVE]"), which trails the stream by a++ * segment or more, next to the uptime ("1:30:17"). Now the bar shows no time of its own at the live edge; rewound, it++ * shows how far behind live you are. The LIVE button is a dot and a word, grey with "Go live" when behind.++ *++ * node test/player-live-clock.test.js++ */++'use strict';++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const vm = require('vm');++++const root = path.join(__dirname, '..');++const read = (p) => fs.readFileSync(path.join(root, p), 'utf8');++const src = read('public/js/stream-player.js');++++/** The source of a top-level function declaration, braces matched (after its parameter list). */++function extract(name) {++ const m = new RegExp(`(?:async )?function ${name}\\(`).exec(src);++ assert.ok(m, `${name}() must exist`);++ let i = m.index + m[0].length;++ for (let parens = 1; parens; i++) { if (src[i] === '(') parens++; else if (src[i] === ')') parens--; }++ i = src.indexOf('{', i);++ for (let depth = 0; i < src.length; i++) {++ if (src[i] === '{') depth++;++ else if (src[i] === '}' && --depth === 0) return src.slice(m.index, i + 1);++ }++ throw new Error(`unbalanced ${name}`);++}++++const els = {++ 'dvr-progress-fill': { style: {} },++ 'dvr-time-display': { hidden: false, textContent: '0:00 / 0:00', title: '' },++ 'video-container': { classList: { set: new Set(), add(c) { this.set.add(c); }, remove(c) { this.set.delete(c); } } },++ 'video-element': { currentTime: 0 },++};++const ctx = { document: { getElementById: (id) => els[id] || null }, Date, Math, isNaN, isFinite, String };++vm.createContext(ctx);++vm.runInContext(`${extract('_dvrFmtTime')}\n${extract('updateDVRUI')}\nthis.run = updateDVRUI;`, ctx);++++// At the live edge: no time of its own (the buffer is 1:30:00 while the stream is 1:30:17 old).++ctx.dvrState = { active: true, isLive: true, duration: 5400, streamStartTime: Date.now() - 5417000, seeking: false };++vm.runInContext('var dvrState = this.dvrState;', ctx);++ctx.run();++const time = els['dvr-time-display'];++assert.strictEqual(time.hidden, true, 'no second clock at the live edge');++assert.strictEqual(time.textContent, '');++assert.ok(!src.includes('} [LIVE]`'), 'the "[LIVE]" time is gone');++++// Rewound 42 s: how far behind live, with the position as the title.++vm.runInContext('dvrState.isLive = false;', ctx);++els['video-element'].currentTime = 5358;++ctx.run();++assert.strictEqual(time.hidden, false);++assert.strictEqual(time.textContent, '−0:42');++assert.match(time.title, /^1:29:18 of 1:30:00 recorded; behind live$/);++assert.ok(els['video-container'].classList.set.has('dvr-rewound'));++++// The LIVE button: a dot and a word; grey "Go live" when behind.++assert.ok(src.includes(`liveBtn.innerHTML = '<span class="live-dot"></span>Go live';`));++assert.ok(src.includes(`liveBtn.innerHTML = '<span class="live-dot"></span>LIVE';`));++const fragment = read('public/fragments/channel.html');++assert.ok(fragment.includes('<button class="vc-btn dvr-live-btn" id="dvr-live-btn" title="Jump to live" style="display:none"><span class="live-dot"></span>LIVE</button>'));++assert.ok(fragment.includes('<span class="dvr-time-display" id="dvr-time-display" style="display:none" hidden></span>'));++const css = read('public/css/style.css');++assert.match(css, /\.dvr-time-display\[hidden\] \{ display: none !important; \}/);++assert.match(css, /\.dvr-live-btn\.dvr-behind \.live-dot \{[^}]*animation: none;/);++assert.ok(!/\.dvr-live-btn \{[^}]*livePulse/.test(css), 'no pulsing red box');++assert.match(read('public/css/features/channel.css'), /\.viewer-badge, \.uptime-badge \{[^}]*font-variant-numeric: tabular-nums;/);++++console.log('player live clock: one clock at the live edge, "behind live" when rewound — all checks passed');
@@ -512,10 +512,10 @@
@@ -512,10 +512,10 @@
}
}
}
}
.viewer-badge, .uptime-badge {
.viewer-badge, .uptime-badge {
- font-size: 0.75rem; color: rgba(255,255,255,0.85);
- display: flex; align-items: center; gap: 4px;
- padding: 2px 8px; border-radius: var(--radius-sm);
- background: rgba(255,255,255,0.08);
+ font-size: 0.78rem; color: rgba(255,255,255,0.9);
+ display: flex; align-items: center; gap: 5px;
+ padding: 0 6px;
+ font-variant-numeric: tabular-nums;
}
}
.dvr-progress-wrap:hover .dvr-progress-bar {
.dvr-progress-wrap:hover .dvr-progress-bar {
height: 6px;
height: 6px;
@@ -2844,9 +2844,8 @@ a.pulse-card:hover { border-color: var(--accent); transform: translateY(-2px); }
@@ -2844,9 +2844,8 @@ a.pulse-card:hover { border-color: var(--accent); transform: translateY(-2px); }
border: 2px solid #fff; box-shadow: 0 1px 3px rgba(0,0,0,0.4);
border: 2px solid #fff; box-shadow: 0 1px 3px rgba(0,0,0,0.4);
}
}
-/* Badges */
-.viewer-badge i { color: var(--live-red); }
-.uptime-badge i { color: var(--accent); }
+/* Viewers and uptime: plain stats on the bar, not coloured pills (owner, 2026-10-20: "this gui should be improved"). */
+.viewer-badge i, .uptime-badge i { color: rgba(255,255,255,0.65); }
/* ── DVR Progress Bar (Live Stream Seeking) ─────────────────── */
/* ── DVR Progress Bar (Live Stream Seeking) ─────────────────── */
.dvr-progress-wrap {
.dvr-progress-wrap {
@@ -2881,25 +2880,28 @@ a.pulse-card:hover { border-color: var(--accent); transform: translateY(-2px); }
@@ -2881,25 +2880,28 @@ a.pulse-card:hover { border-color: var(--accent); transform: translateY(-2px); }
/* DVR in non-live (rewound) mode: use accent color instead of red */
/* DVR in non-live (rewound) mode: use accent color instead of red */
.dvr-time-display {
.dvr-time-display {
- font-size: 0.75rem; color: rgba(255,255,255,0.85);
- font-family: var(--font-mono);
+ font-size: 0.78rem; color: rgba(255,255,255,0.9);
+ font-variant-numeric: tabular-nums;
white-space: nowrap; padding: 0 4px;
white-space: nowrap; padding: 0 4px;
- min-width: 82px; text-align: center;
}
}
+.dvr-time-display[hidden] { display: none !important; }
+/* LIVE: a red dot while at the live edge; behind it, a grey dot and "Go live" (a click jumps back). */
.dvr-live-btn {
.dvr-live-btn {
- background: #e53e3e !important; color: #fff !important;
- font-size: 0.7rem !important; font-weight: 700;
- padding: 3px 10px !important; border-radius: 4px !important;
- gap: 4px; letter-spacing: 0.5px;
- animation: livePulse 1.5s infinite;
-}
-.dvr-live-btn:hover { background: #c53030 !important; }
-.dvr-live-btn.dvr-behind {
- background: rgba(255,255,255,0.15) !important;
- border: 1px solid rgba(255,255,255,0.3);
- animation: none;
+ color: #fff !important;
+ font-size: 0.72rem !important; font-weight: 700;
+ letter-spacing: 0.06em; text-transform: uppercase;
+ padding: 4px 10px !important; border-radius: 999px !important;
+ gap: 7px;
+}
+.dvr-live-btn .live-dot {
+ display: inline-block; flex-shrink: 0;
+ width: 8px; height: 8px; border-radius: 50%;
+ background: var(--live-red, #e53e3e);
+ box-shadow: 0 0 0 3px rgba(229,62,62,0.28);
}
}
+.dvr-live-btn.dvr-behind { background: rgba(255,255,255,0.14) !important; }
+.dvr-live-btn.dvr-behind .live-dot { background: rgba(255,255,255,0.6); box-shadow: none; animation: none; }
/* Progress bar (for VOD/clip players) */
/* Progress bar (for VOD/clip players) */
.vc-progress-wrap {
.vc-progress-wrap {
@@ -40,11 +40,9 @@
@@ -40,11 +40,9 @@
</button>
</button>
<input type="range" id="volume-slider" min="0" max="100" value="75" class="volume-slider" aria-label="Volume">
<input type="range" id="volume-slider" min="0" max="100" value="75" class="volume-slider" aria-label="Volume">
</div>
</div>
- <span class="dvr-time-display" id="dvr-time-display" style="display:none">0:00 / 0:00</span>
+ <span class="dvr-time-display" id="dvr-time-display" style="display:none" hidden></span>
<div class="vc-spacer"></div>
<div class="vc-spacer"></div>
- <button class="vc-btn dvr-live-btn" id="dvr-live-btn" title="Jump to Live" style="display:none">
- <i class="fa-solid fa-tower-broadcast"></i> LIVE
- </button>
+ <button class="vc-btn dvr-live-btn" id="dvr-live-btn" title="Jump to live" style="display:none"><span class="live-dot"></span>LIVE</button>
<span class="viewer-badge" id="viewer-count-badge">
<span class="viewer-badge" id="viewer-count-badge">
<i class="fa-solid fa-eye"></i> <span id="vc-viewers">0</span>
<i class="fa-solid fa-eye"></i> <span id="vc-viewers">0</span>
</span>
</span>
@@ -2391,20 +2391,22 @@ function updateDVRUI() {
@@ -2391,20 +2391,22 @@ function updateDVRUI() {
const container = document.getElementById('video-container');
const container = document.getElementById('video-container');
if (dvrState.isLive) {
if (dvrState.isLive) {
- // At live edge — progress bar is full (100%)
+ // At the live edge the bar shows no time of its own: the uptime beside it is the one clock. This read the DVR
+ // buffer's length, which trails the stream by a segment or more ("1:30:00 [LIVE]" next to "1:30:17").
if (fill) fill.style.width = '100%';
if (fill) fill.style.width = '100%';
- const elapsed = dvrState.duration > 0
- ? dvrState.duration
- : Math.max(0, Math.floor((Date.now() - dvrState.streamStartTime) / 1000));
- if (timeEl) timeEl.textContent = `${_dvrFmtTime(elapsed)} [LIVE]`;
+ if (timeEl) { timeEl.hidden = true; timeEl.textContent = ''; }
if (container) container.classList.remove('dvr-rewound');
if (container) container.classList.remove('dvr-rewound');
} else {
} else {
- // Rewound — show position within the DVR buffer
+ // Rewound: how far behind live, with the position in the buffer as its title
const video = document.getElementById('video-element');
const video = document.getElementById('video-element');
if (video && dvrState.duration > 0 && !dvrState.seeking) {
if (video && dvrState.duration > 0 && !dvrState.seeking) {
const pct = (video.currentTime / dvrState.duration) * 100;
const pct = (video.currentTime / dvrState.duration) * 100;
if (fill) fill.style.width = Math.min(pct, 100) + '%';
if (fill) fill.style.width = Math.min(pct, 100) + '%';
- if (timeEl) timeEl.textContent = `${_dvrFmtTime(video.currentTime)} / ${_dvrFmtTime(dvrState.duration)}`;
+ if (timeEl) {
+ timeEl.hidden = false;
+ timeEl.textContent = `\u2212${_dvrFmtTime(dvrState.duration - video.currentTime)}`;
+ timeEl.title = `${_dvrFmtTime(video.currentTime)} of ${_dvrFmtTime(dvrState.duration)} recorded; behind live`;
+ }
}
}
if (container) container.classList.add('dvr-rewound');
if (container) container.classList.add('dvr-rewound');
}
}
@@ -2531,7 +2533,7 @@ function switchToDVR(targetTime) {
@@ -2531,7 +2533,7 @@ function switchToDVR(targetTime) {
// Show "Jump to Live" button in alert style
// Show "Jump to Live" button in alert style
if (liveBtn) {
if (liveBtn) {
liveBtn.classList.add('dvr-behind');
liveBtn.classList.add('dvr-behind');
- liveBtn.innerHTML = '<i class="fa-solid fa-forward"></i> Back to LIVE';
+ liveBtn.innerHTML = '<span class="live-dot"></span>Go live';
}
}
// Ensure the video element is visible (JSMPEG normally hides it)
// Ensure the video element is visible (JSMPEG normally hides it)
@@ -2615,7 +2617,7 @@ function jumpToLive() {
@@ -2615,7 +2617,7 @@ function jumpToLive() {
// Reset live button
// Reset live button
if (liveBtn) {
if (liveBtn) {
liveBtn.classList.remove('dvr-behind');
liveBtn.classList.remove('dvr-behind');
- liveBtn.innerHTML = '<i class="fa-solid fa-tower-broadcast"></i> LIVE';
+ liveBtn.innerHTML = '<span class="live-dot"></span>LIVE';
}
}
updateDVRUI();
updateDVRUI();
new file mode 100644
new file mode 100644
@@ -0,0 +1,73 @@
@@ -0,0 +1,73 @@
+/**
+ * The live player's bar has one clock (owner, 2026-10-20: "why is there two spots for this and why is one inaccurate").
+ * At the live edge the DVR time showed the recorded buffer's length ("1:30:00 [LIVE]"), which trails the stream by a
+ * segment or more, next to the uptime ("1:30:17"). Now the bar shows no time of its own at the live edge; rewound, it
+ * shows how far behind live you are. The LIVE button is a dot and a word, grey with "Go live" when behind.
+ *
+ * node test/player-live-clock.test.js
+ */
+'use strict';
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const vm = require('vm');
+
+const root = path.join(__dirname, '..');
+const read = (p) => fs.readFileSync(path.join(root, p), 'utf8');
+const src = read('public/js/stream-player.js');
+
+/** The source of a top-level function declaration, braces matched (after its parameter list). */
+function extract(name) {
+ const m = new RegExp(`(?:async )?function ${name}\\(`).exec(src);
+ assert.ok(m, `${name}() must exist`);
+ let i = m.index + m[0].length;
+ for (let parens = 1; parens; i++) { if (src[i] === '(') parens++; else if (src[i] === ')') parens--; }
+ i = src.indexOf('{', i);
+ for (let depth = 0; i < src.length; i++) {
+ if (src[i] === '{') depth++;
+ else if (src[i] === '}' && --depth === 0) return src.slice(m.index, i + 1);
+ }
+ throw new Error(`unbalanced ${name}`);
+}
+
+const els = {
+ 'dvr-progress-fill': { style: {} },
+ 'dvr-time-display': { hidden: false, textContent: '0:00 / 0:00', title: '' },
+ 'video-container': { classList: { set: new Set(), add(c) { this.set.add(c); }, remove(c) { this.set.delete(c); } } },
+ 'video-element': { currentTime: 0 },
+};
+const ctx = { document: { getElementById: (id) => els[id] || null }, Date, Math, isNaN, isFinite, String };
+vm.createContext(ctx);
+vm.runInContext(`${extract('_dvrFmtTime')}\n${extract('updateDVRUI')}\nthis.run = updateDVRUI;`, ctx);
+
+// At the live edge: no time of its own (the buffer is 1:30:00 while the stream is 1:30:17 old).
+ctx.dvrState = { active: true, isLive: true, duration: 5400, streamStartTime: Date.now() - 5417000, seeking: false };
+vm.runInContext('var dvrState = this.dvrState;', ctx);
+ctx.run();
+const time = els['dvr-time-display'];
+assert.strictEqual(time.hidden, true, 'no second clock at the live edge');
+assert.strictEqual(time.textContent, '');
+assert.ok(!src.includes('} [LIVE]`'), 'the "[LIVE]" time is gone');
+
+// Rewound 42 s: how far behind live, with the position as the title.
+vm.runInContext('dvrState.isLive = false;', ctx);
+els['video-element'].currentTime = 5358;
+ctx.run();
+assert.strictEqual(time.hidden, false);
+assert.strictEqual(time.textContent, '−0:42');
+assert.match(time.title, /^1:29:18 of 1:30:00 recorded; behind live$/);
+assert.ok(els['video-container'].classList.set.has('dvr-rewound'));
+
+// The LIVE button: a dot and a word; grey "Go live" when behind.
+assert.ok(src.includes(`liveBtn.innerHTML = '<span class="live-dot"></span>Go live';`));
+assert.ok(src.includes(`liveBtn.innerHTML = '<span class="live-dot"></span>LIVE';`));
+const fragment = read('public/fragments/channel.html');
+assert.ok(fragment.includes('<button class="vc-btn dvr-live-btn" id="dvr-live-btn" title="Jump to live" style="display:none"><span class="live-dot"></span>LIVE</button>'));
+assert.ok(fragment.includes('<span class="dvr-time-display" id="dvr-time-display" style="display:none" hidden></span>'));
+const css = read('public/css/style.css');
+assert.match(css, /\.dvr-time-display\[hidden\] \{ display: none !important; \}/);
+assert.match(css, /\.dvr-live-btn\.dvr-behind \.live-dot \{[^}]*animation: none;/);
+assert.ok(!/\.dvr-live-btn \{[^}]*livePulse/.test(css), 'no pulsing red box');
+assert.match(read('public/css/features/channel.css'), /\.viewer-badge, \.uptime-badge \{[^}]*font-variant-numeric: tabular-nums;/);
+
+console.log('player live clock: one clock at the live edge, "behind live" when rewound — all checks passed');