openvibers/openvibe.live Commits
Phone studio: no self is-live card in the way, the self-view keeps playing, Delete beside Replay (#706)
Changes
@@@ -3064,6 +3064,16 @@ body.bc-golive-dock-on #page-broadcast { padding-bottom: calc(104px + var(--ovnabackground: 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; }@@@ -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();@@@ -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' });@@@ -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);@@@ -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>nnew file mode 100644@@@ -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');
@@ -3064,6 +3064,16 @@ body.bc-golive-dock-on #page-broadcast { padding-bottom: calc(104px + var(--ovna
@@ -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);
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-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). */
/* 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:not([hidden]) { animation: bcs-dock-in var(--ov-dur-2, 0.2s) var(--ov-ease-out, ease-out); }
.bcs-golive-t { margin-left: 8px; }
.bcs-golive-t { margin-left: 8px; }
@@ -884,7 +884,12 @@
@@ -884,7 +884,12 @@
// A camera gone because the stream ended resets the switches; one gone for a recovery keeps them.
// 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 (!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(); }
else { if (st.root) unmount(); applyTracks(); syncDesktopButtons(); }
syncGoLiveDock();
syncGoLiveDock();
syncLivePill();
syncLivePill();
@@ -1272,11 +1272,33 @@ function _renderEndedStream(s) {
@@ -1272,11 +1272,33 @@ function _renderEndedStream(s) {
const thumb = s.vod_thumbnail_url
const thumb = s.vod_thumbnail_url
? `<img src="${esc(s.vod_thumbnail_url)}" alt="" loading="lazy" onerror="this.remove()">`
? `<img src="${esc(s.vod_thumbnail_url)}" alt="" loading="lazy" onerror="this.remove()">`
: '<i class="fa-solid fa-video" aria-hidden="true"></i>';
: '<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>
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'). */
/** Switch the top-level Go Live tab ('active' | 'mine'). */
@@ -1999,7 +2021,7 @@ async function endSetupStream() {
@@ -1999,7 +2021,7 @@ async function endSetupStream() {
hideBroadcastTabs();
hideBroadcastTabs();
clearGlobalDisplayTimers();
clearGlobalDisplayTimers();
if (!isStreaming()) setNavLiveIndicator(false);
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 */
/* Device enumeration functions → broadcast-devices.js */
@@ -2175,7 +2197,7 @@ async function stopBroadcast() {
@@ -2175,7 +2197,7 @@ async function stopBroadcast() {
hideBroadcastTabs();
hideBroadcastTabs();
clearGlobalDisplayTimers();
clearGlobalDisplayTimers();
if (!isStreaming()) setNavLiveIndicator(false);
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"
// Scroll create section into view so user lands on "Create New Stream"
const createSec = document.getElementById('bc-create-section');
const createSec = document.getElementById('bc-create-section');
if (createSec) createSec.scrollIntoView({ behavior: 'smooth', block: 'start' });
if (createSec) createSec.scrollIntoView({ behavior: 'smooth', block: 'start' });
@@ -2838,6 +2838,8 @@ function _chatEvCss() {
@@ -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)}
.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}}
@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: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}}}
@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}}`;
@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);
document.head.appendChild(st);
@@ -2848,6 +2850,17 @@ function _chatEvTime(at) {
@@ -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' };
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) };
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 = {}) {
function renderChatEventCard(ev, opts = {}) {
const { messages: container } = getChatEl();
const { messages: container } = getChatEl();
if (!container || !ev) return;
if (!container || !ev) return;
@@ -2866,8 +2879,12 @@ function renderChatEventCard(ev, opts = {}) {
@@ -2866,8 +2879,12 @@ function renderChatEventCard(ev, opts = {}) {
return;
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');
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 (!_loadingHistory) el.classList.add('chat-msg-new');
if (ev.id != null) el.dataset.msgId = String(ev.id);
if (ev.id != null) el.dataset.msgId = String(ev.id);
el.dataset.evKey = ev.key;
el.dataset.evKey = ev.key;
@@ -2885,8 +2902,10 @@ function renderChatEventCard(ev, opts = {}) {
@@ -2885,8 +2902,10 @@ function renderChatEventCard(ev, opts = {}) {
line.className = 'chat-ev-line';
line.className = 'chat-ev-line';
const who = document.createElement('b');
const who = document.createElement('b');
who.textContent = ev.name;
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 {
} else {
line.append(who, ' on The Rundown');
line.append(who, ' on The Rundown');
const title = document.createElement('span'); title.className = 'chat-ev-title'; title.textContent = `“${ev.title}”`;
const title = document.createElement('span'); title.className = 'chat-ev-title'; title.textContent = `“${ev.title}”`;
@@ -2924,7 +2943,8 @@ function renderChatEventCard(ev, opts = {}) {
@@ -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'); }
try { await navigator.clipboard.writeText(ev.url); toast('Link copied', 'success'); } catch { toast('Could not copy the link', 'error'); }
});
});
acts.append(cta, copy);
acts.append(cta, copy);
- el.append(icon, body, acts);
+ if (here) el.append(icon, body);
+ else el.append(icon, body, acts);
container.appendChild(el);
container.appendChild(el);
if (!_loadingHistory) {
if (!_loadingHistory) {
if (typeof _chatTrim === 'function') _chatTrim(container, MAIN_CHAT_MAX_MESSAGES);
if (typeof _chatTrim === 'function') _chatTrim(container, MAIN_CHAT_MAX_MESSAGES);
@@ -503,7 +503,7 @@ async function loadDashVods() {
@@ -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-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 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" 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>
</div>
</div>
</div>
new file mode 100644
new file mode 100644
@@ -0,0 +1,51 @@
@@ -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');