openvibers/openvibe.live Commits

Phone studio: no self is-live card in the way, the self-view keeps playing, Delete beside Replay (#706)

OP OpenVibers committed 5 days ago · e275bbbcb6 · 6 files, +119 −11

From a streamer's report (11-08):
- Her own go-live card sat over the picture in the phone studio's narrow chat, its Watch
  button and copy link crushing "is live" to a letter per line. A live card about the
  page you are on (your own studio, the channel you are watching) is now a quiet line
  ("You went live"), and in any chat under 240px the card's buttons go under its text.
- The self-view stayed paused under the browser's play button after the phone paused it;
  the studio plays it again when it finds it paused.
- Deleting the recording of a test stream was not obvious (a bare trash icon on the
  dashboard). Your last streams on the studio now has Delete beside Replay, says when a
  recording is still saving, and lists the stream just ended a few seconds after it
  ends; the dashboard's button says Delete.

Changes

ddiff --git a/public/css/features/broadcast.css b/public/css/features/broadcast.css
iindex eb3f2aa6..f4785504 100644
---- a/public/css/features/broadcast.css
++++ b/public/css/features/broadcast.css
@@@ -3064,6 +3064,16 @@ body.bc-golive-dock-on #page-broadcast { padding-bottom: calc(104px + var(--ovna
background: color-mix(in srgb, var(--accent) 14%, transparent); border: 1px solid color-mix(in srgb, var(--accent) 35%, transparent);
}
.bc-last-replay:hover, .bc-last-replay:focus-visible { background: color-mix(in srgb, var(--accent) 24%, transparent); outline: none; }
++.bc-last-acts { flex: none; display: flex; align-items: center; gap: 6px; }
++.bc-last-delete {
++ display: inline-flex; align-items: center; gap: 6px; min-height: 36px; padding: 0 12px; border-radius: 999px; cursor: pointer;
++ font: inherit; font-size: 0.82rem; font-weight: 700; color: var(--text-secondary);
++ background: transparent; border: 1px solid var(--border);
++}
++.bc-last-delete:hover, .bc-last-delete:focus-visible { color: #fecaca; border-color: color-mix(in srgb, #ef4444 55%, transparent); background: color-mix(in srgb, #ef4444 12%, transparent); outline: none; }
++.bc-last-delete:disabled { opacity: 0.6; cursor: default; }
++@media (pointer: coarse) { .bc-last-replay, .bc-last-delete { min-height: 44px; } }
++@media (max-width: 480px) { .bc-last-item { flex-wrap: wrap; } .bc-last-acts { width: 100%; justify-content: flex-end; } }
/* The phone bar fades up instead of appearing (and it no longer shows before the page knows the streams). */
.bcs-golive:not([hidden]) { animation: bcs-dock-in var(--ov-dur-2, 0.2s) var(--ov-ease-out, ease-out); }
.bcs-golive-t { margin-left: 8px; }
ddiff --git a/public/js/broadcast-studio.js b/public/js/broadcast-studio.js
iindex 65aee885..615ba8a8 100644
---- a/public/js/broadcast-studio.js
++++ b/public/js/broadcast-studio.js
@@@ -884,7 +884,12 @@
// A camera gone because the stream ended resets the switches; one gone for a recovery keeps them.
if (!has && !streamState()) { st.micOff = false; st.camOff = false; st.torch = false; st.dismissedFor = null; }
}
-- if (wanted()) { if (!st.root) mount(); refresh(); }
++ if (wanted()) {
++ if (!st.root) mount();
++ refresh();
++ const pv = $('bc-video-preview');
++ if (pv && pv.paused && pv.srcObject && pv.style.display !== 'none') pv.play().catch(() => {});
++ }
else { if (st.root) unmount(); applyTracks(); syncDesktopButtons(); }
syncGoLiveDock();
syncLivePill();
ddiff --git a/public/js/broadcast.js b/public/js/broadcast.js
iindex e5d07b82..970ef8b9 100644
---- a/public/js/broadcast.js
++++ b/public/js/broadcast.js
@@@ -1272,11 +1272,33 @@ function _renderEndedStream(s) {
const thumb = s.vod_thumbnail_url
? `<img src="${esc(s.vod_thumbnail_url)}" alt="" loading="lazy" onerror="this.remove()">`
: '<i class="fa-solid fa-video" aria-hidden="true"></i>';
-- const replay = s.vod_id
-- ? `<a class="bc-last-replay" href="${esc(videoHref('vod', s.vod_id))}"><i class="fa-solid fa-play"></i> Replay</a>`
++ const acts = s.vod_id
++ ? `<span class="bc-last-acts"><a class="bc-last-replay" href="${esc(videoHref('vod', s.vod_id))}"><i class="fa-solid fa-play"></i> Replay</a><button type="button" class="bc-last-delete" data-vod="${esc(String(s.vod_id))}" data-title="${esc(s.title || 'Untitled')}" onclick="bcDeleteRecording(this)" aria-label="Delete the recording of ${esc(s.title || 'this stream')}"><i class="fa-solid fa-trash-can" aria-hidden="true"></i> Delete</button></span>`
: '';
++ // A stream ended in the last hour with no recording yet is still being saved by OpenVibe.Media.
++ const t = Date.parse(String(s.ended_at || '').replace(' ', 'T') + (String(s.ended_at || '').includes('Z') ? '' : 'Z'));
++ const saving = !s.vod_id && Number.isFinite(t) && Date.now() - t < 60 * 60 * 1000 ? ' · saving the recording…' : '';
return `<div class="bc-last-item"><span class="bc-last-thumb">${thumb}</span>
-- <span class="bc-last-meta"><b>${esc(s.title || 'Untitled')}</b><small>${esc(_bcAgo(s.ended_at || s.started_at))}${len ? ` · ${esc(len)}` : ''}</small></span>${replay}</div>`;
++ <span class="bc-last-meta"><b>${esc(s.title || 'Untitled')}</b><small>${esc(_bcAgo(s.ended_at || s.started_at))}${len ? ` · ${esc(len)}` : ''}${saving}</small></span>${acts}</div>`;
++}
++
++/** Delete a recording from "Your last streams" (DELETE /api/vods/:id, the same call as the dashboard's). */
++async function bcDeleteRecording(btn) {
++ const id = btn && btn.dataset.vod;
++ if (!id) return;
++ if (!confirm(`Delete the recording of "${btn.dataset.title || 'this stream'}"? It is removed from your channel and OpenVibe.Video for good.`)) return;
++ btn.disabled = true;
++ try {
++ await api(`/vods/${encodeURIComponent(id)}`, { method: 'DELETE' });
++ const item = btn.closest('.bc-last-item');
++ if (item) item.remove();
++ toast('Recording deleted', 'success');
++ } catch (e) { btn.disabled = false; toast(e.message || 'Could not delete the recording', 'error'); }
++}
++window.bcDeleteRecording = bcDeleteRecording;
++/** After a stream ends, list it again once the server has ended it, then once its recording is saved. */
++function _bcRefreshEndedSoon() {
++ for (const ms of [8000, 45000]) setTimeout(() => { try { if (document.querySelector('#page-broadcast.active')) loadExistingStreams(); } catch { /* left the page */ } }, ms);
}
/** Switch the top-level Go Live tab ('active' | 'mine'). */
@@@ -1999,7 +2021,7 @@ async function endSetupStream() {
hideBroadcastTabs();
clearGlobalDisplayTimers();
if (!isStreaming()) setNavLiveIndicator(false);
-- showStreamManager(); loadExistingStreams(endingStreamId); toast('Stream ended', 'info');
++ showStreamManager(); loadExistingStreams(endingStreamId); _bcRefreshEndedSoon(); toast('Stream ended. Its recording shows under Your last streams, where you can keep or delete it.', 'info');
}
/* Device enumeration functions → broadcast-devices.js */
@@@ -2175,7 +2197,7 @@ async function stopBroadcast() {
hideBroadcastTabs();
clearGlobalDisplayTimers();
if (!isStreaming()) setNavLiveIndicator(false);
-- showStreamManager(); loadExistingStreams(activeId); toast('Stream ended', 'info');
++ showStreamManager(); loadExistingStreams(activeId); _bcRefreshEndedSoon(); toast('Stream ended. Its recording shows under Your last streams, where you can keep or delete it.', 'info');
// Scroll create section into view so user lands on "Create New Stream"
const createSec = document.getElementById('bc-create-section');
if (createSec) createSec.scrollIntoView({ behavior: 'smooth', block: 'start' });
ddiff --git a/public/js/chat.js b/public/js/chat.js
iindex e6795f97..d0b43f1b 100644
---- a/public/js/chat.js
++++ b/public/js/chat.js
@@@ -2838,6 +2838,8 @@ function _chatEvCss() {
.chat-ev-copy:hover{background:color-mix(in srgb,var(--text-primary,#fff) 8%,transparent);color:var(--text-primary,#e6edf7)}
@media (pointer:coarse){.chat-ev-cta{min-height:44px;padding:0 14px}.chat-ev-copy{width:44px;height:44px}}
@container (max-width:360px){.chat-ev-cta{padding:0;width:36px;justify-content:center;font-size:0}.chat-ev-cta i{font-size:.8rem}}
++@container (max-width:240px){.chat-ev{grid-template-columns:auto minmax(0,1fr)}.chat-ev-acts{grid-column:2;justify-self:start}}
++.chat-ev--here{grid-template-columns:auto minmax(0,1fr);padding:6px 10px}.chat-ev--here .chat-ev-icon{width:22px;height:22px}.chat-ev--here .chat-ev-icon::before{width:8px;height:8px}
@container (max-width:360px){@media (pointer:coarse){.chat-ev-cta{width:44px}}}
@media (prefers-reduced-motion:reduce){.chat-ev.chat-msg-new{animation:none}.chat-ev--live .chat-ev-icon::before{animation:none}}`;
document.head.appendChild(st);
@@@ -2848,6 +2850,17 @@ function _chatEvTime(at) {
const opts = chatSettings.timestampFormat === '24h' ? { hour: '2-digit', minute: '2-digit', hour12: false } : { hour: '2-digit', minute: '2-digit' };
return { date: t, text: t.toLocaleTimeString([], opts) };
}
++/** Is a live card about what this page already is? { mine } on the streamer's own broadcast page, {} on that channel. */
++function _chatEvIsHere(ev) {
++ const name = String(ev.name || '').toLowerCase();
++ if (!name) return null;
++ const me = typeof currentUser !== 'undefined' && currentUser ? String(currentUser.username || '').toLowerCase() : '';
++ const studio = !!document.querySelector('#page-broadcast.active') || /^\/@[^/]+\/studio\b/.test(location.pathname);
++ if (studio && me && me === name) return { mine: true };
++ const m = /^\/@([^/?#]+)/.exec(location.pathname);
++ if (m && decodeURIComponent(m[1]).toLowerCase() === name && !studio) return { mine: false };
++ return null;
++}
function renderChatEventCard(ev, opts = {}) {
const { messages: container } = getChatEl();
if (!container || !ev) return;
@@@ -2866,8 +2879,12 @@ function renderChatEventCard(ev, opts = {}) {
return;
}
}
++ // A live card that points at what this person is already on: their own studio (they are the stream) or the
++ // channel page they are watching. A quiet line, no Watch button (in the phone studio's narrow chat the button
++ // and the copy link crushed "is live" to a letter per line, moosely 11-08).
++ const here = ev.kind === 'live' && _chatEvIsHere(ev);
const el = document.createElement('div');
-- el.className = `chat-msg system chat-ev chat-ev--${ev.kind}`;
++ el.className = `chat-msg system chat-ev chat-ev--${ev.kind}${here ? ' chat-ev--here' : ''}`;
if (!_loadingHistory) el.classList.add('chat-msg-new');
if (ev.id != null) el.dataset.msgId = String(ev.id);
el.dataset.evKey = ev.key;
@@@ -2885,8 +2902,10 @@ function renderChatEventCard(ev, opts = {}) {
line.className = 'chat-ev-line';
const who = document.createElement('b');
who.textContent = ev.name;
-- if (ev.kind === 'live') {
-- line.append(who, ' is live');
++ if (here && here.mine) {
++ line.append('You went live');
++ } else if (ev.kind === 'live') {
++ line.append(who, here ? ' went live' : ' is live');
} else {
line.append(who, ' on The Rundown');
const title = document.createElement('span'); title.className = 'chat-ev-title'; title.textContent = `“${ev.title}”`;
@@@ -2924,7 +2943,8 @@ function renderChatEventCard(ev, opts = {}) {
try { await navigator.clipboard.writeText(ev.url); toast('Link copied', 'success'); } catch { toast('Could not copy the link', 'error'); }
});
acts.append(cta, copy);
-- el.append(icon, body, acts);
++ if (here) el.append(icon, body);
++ else el.append(icon, body, acts);
container.appendChild(el);
if (!_loadingHistory) {
if (typeof _chatTrim === 'function') _chatTrim(container, MAIN_CHAT_MAX_MESSAGES);
ddiff --git a/public/js/dashboard.js b/public/js/dashboard.js
iindex dfe5f455..e2305907 100644
---- a/public/js/dashboard.js
++++ b/public/js/dashboard.js
@@@ -503,7 +503,7 @@ async function loadDashVods() {
? `<button class="btn btn-small btn-success" onclick="publishVod('${v.id}')"><i class="fa-solid fa-eye"></i> Publish</button>`
: `<button class="btn btn-small btn-outline" onclick="unpublishVod('${v.id}')"><i class="fa-solid fa-eye-slash"></i> Private</button>`}
<button class="btn btn-small" onclick="location.href=videoHref('vod', '${v.id}')" title="Watch on OpenVibe.Video"><i class="fa-solid fa-play"></i></button>
-- <button class="btn btn-small btn-danger" onclick="deleteVod('${v.id}')"><i class="fa-solid fa-trash"></i></button>
++ <button class="btn btn-small btn-danger" onclick="deleteVod('${v.id}')" aria-label="Delete ${esc(v.title || 'this video')}"><i class="fa-solid fa-trash-can" aria-hidden="true"></i> Delete</button>
</div>
</div>
</div>
ddiff --git a/test/studio-live-card-and-recording.test.js b/test/studio-live-card-and-recording.test.js
nnew file mode 100644
iindex 00000000..aa849ca1
---- /dev/null
++++ b/test/studio-live-card-and-recording.test.js
@@@ -0,0 +1,51 @@
++'use strict';
++// moosely's phone studio (11-08): her own "is live" chat card sat over the picture, its Watch button and copy link
++// crushing "is live" to a letter per line; the self-view stayed paused under the browser's play button after the phone
++// paused it; and deleting the recording of a test stream "wasn't immediately obvious". What this proves:
++// - a live card about the page you are on (your own studio, or the channel you are watching) is a quiet line with no
++// Watch button ("You went live" for the streamer); anywhere else it keeps its buttons;
++// - in a chat narrower than 240px the card's buttons go under its text instead of beside it;
++// - the phone studio starts its self-view again when it finds it paused;
++// - "Your last streams" offers Delete beside Replay (DELETE /api/vods/:id, after a confirm), says when a recording is
++// still saving, and lists the stream just ended again a few seconds after it ends; the dashboard's delete is labelled.
++// node test/studio-live-card-and-recording.test.js
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const read = (f) => fs.readFileSync(path.join(__dirname, '..', 'public', f), 'utf8');
++
++const chat = read('js/chat.js');
++const isHereSrc = chat.slice(chat.indexOf('function _chatEvIsHere('), chat.indexOf('function renderChatEventCard('));
++assert.ok(isHereSrc.length > 50, '_chatEvIsHere is in chat.js');
++function isHere({ path: p, studioActive = false, me = 'moosely' }, name) {
++ const document = { querySelector: (q) => (q === '#page-broadcast.active' && studioActive ? {} : null) };
++ const location = { pathname: p };
++ const currentUser = me ? { username: me } : null;
++ return new Function('document', 'location', 'currentUser', `${isHereSrc}; return _chatEvIsHere;`)(document, location, currentUser)({ name });
++}
++assert.deepStrictEqual(isHere({ path: '/@moosely/studio' }, 'moosely'), { mine: true }, 'the streamer in her own studio');
++assert.deepStrictEqual(isHere({ path: '/broadcast', studioActive: true }, 'Moosely'), { mine: true }, 'the broadcast desk, any case');
++assert.deepStrictEqual(isHere({ path: '/@moosely', me: 'someone' }, 'moosely'), { mine: false }, 'a viewer on that channel');
++assert.strictEqual(isHere({ path: '/@goosely', me: 'someone' }, 'moosely'), null, 'another channel keeps the full card');
++assert.strictEqual(isHere({ path: '/', me: 'someone' }, 'moosely'), null, 'the home or global chat keeps the full card');
++assert.strictEqual(isHere({ path: '/@moosely/studio' }, 'goosely'), null, 'someone else going live, seen from my studio');
++
++const card = chat.slice(chat.indexOf('function renderChatEventCard('), chat.indexOf('/* ── Deploy notices'));
++assert.ok(/const here = ev\.kind === 'live' && _chatEvIsHere\(ev\)/.test(card), 'the card asks where it is');
++assert.ok(card.includes("line.append('You went live')"), 'the streamer reads "You went live"');
++assert.ok(/if \(here\) el\.append\(icon, body\);\s*else el\.append\(icon, body, acts\);/.test(card), 'no Watch button or copy link here');
++const css = chat.slice(chat.indexOf('function _chatEvCss('), chat.indexOf('function _chatEvTime('));
++assert.ok(/@container \(max-width:240px\)\{\.chat-ev\{grid-template-columns:auto minmax\(0,1fr\)\}\.chat-ev-acts\{grid-column:2/.test(css), 'narrow: buttons under the text');
++
++const studio = read('js/broadcast-studio.js');
++assert.ok(/if \(pv && pv\.paused && pv\.srcObject && pv\.style\.display !== 'none'\) pv\.play\(\)\.catch\(\(\) => \{\}\);/.test(studio), 'a paused self-view plays again');
++
++const bc = read('js/broadcast.js');
++const ended = bc.slice(bc.indexOf('function _renderEndedStream('), bc.indexOf('/** Delete a recording from'));
++assert.ok(/class="bc-last-delete"/.test(ended) && /Replay<\/a><button/.test(ended), 'Delete sits beside Replay');
++assert.ok(/saving the recording…/.test(ended), 'a recording still being saved says so');
++assert.ok(/await api\(`\/vods\/\$\{encodeURIComponent\(id\)\}`, \{ method: 'DELETE' \}\)/.test(bc) && /if \(!confirm\(/.test(bc.slice(bc.indexOf('async function bcDeleteRecording('))), 'it asks, then deletes');
++assert.strictEqual((bc.match(/_bcRefreshEndedSoon\(\); toast\('Stream ended\. Its recording shows under Your last streams/g) || []).length, 2, 'both ways of ending list the stream again soon');
++assert.ok(/\.bc-last-delete \{/.test(read('css/features/broadcast.css')), 'it is styled');
++assert.ok(/onclick="deleteVod\('\$\{v\.id\}'\)" aria-label="Delete [^"]+"><i class="fa-solid fa-trash-can" aria-hidden="true"><\/i> Delete<\/button>/.test(read('js/dashboard.js')), 'the dashboard says Delete');
++console.log('studio: no self "is live" card in the way, the self-view plays, and a recording is one Delete away');