Add a mesh scope to the Streams card
Local stays the default and the cheap path; mesh asks each partner for its own sessions over SSH, live rather than cached, because a stream is true for minutes and a cached one would be confidently wrong about the only thing the card exists to say.
This commit is contained in:
@@ -243,12 +243,20 @@ $_vv_doc_vars = array_merge(vv_conf_vars(), ['SCRIPTS_DIR' => SCRIPTS_DIR]);
|
||||
<div id="vv-transcode-body">Loading...</div>
|
||||
</div>
|
||||
|
||||
<!-- Scope control lives in the header rather than in the body because the body is replaced on
|
||||
every poll, and a control that is re-rendered underneath a click is a control that loses
|
||||
one. Hidden until a partner is known — on a single-host install the two buttons would be
|
||||
two names for the same view, which is how the Media Stack tab handles it too. -->
|
||||
<div class="vv-card" id="vv-streams" style="grid-column:span 4;">
|
||||
<h3>
|
||||
<span style="display:flex;align-items:center;gap:5px;">
|
||||
<span class="vv-ico"><svg width="11" height="12" viewBox="0 0 11 12" fill="none" stroke="#aaa" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"><polygon points="1.5,1 1.5,11 10,6"/></svg></span>
|
||||
Streams
|
||||
</span>
|
||||
<span id="vv-streams-scope" style="display:none;gap:4px;margin-left:auto;">
|
||||
<button class="vv-strm-tab on" data-strmscope="local">This host</button>
|
||||
<button class="vv-strm-tab" data-strmscope="mesh">Mesh</button>
|
||||
</span>
|
||||
</h3>
|
||||
<div id="vv-streams-body">Loading...</div>
|
||||
</div>
|
||||
@@ -986,6 +994,12 @@ function vvPollMonitor(live) {
|
||||
const pt = d.partner ?? {};
|
||||
const ptHosts = pt.hosts ?? [];
|
||||
const ptRemote = d.remote_hosts ?? {};
|
||||
|
||||
// The Streams scope control is offered only once there is a partner to combine with. On a
|
||||
// single-host install "This host" and "Mesh" are two names for the same view, and the
|
||||
// Media Stack tab hides its equivalent for the same reason.
|
||||
const _strmScopeEl = document.getElementById('vv-streams-scope');
|
||||
if (_strmScopeEl) _strmScopeEl.style.display = Object.keys(ptRemote).length ? 'flex' : 'none';
|
||||
const ptStatus = pt.enabled
|
||||
? `<span style="color:#4caf50;">enabled</span> · sync every ${pt.sync_min}min`
|
||||
: `<span style="color:#555;">disabled</span>`;
|
||||
@@ -2407,7 +2421,8 @@ function vvRenderStreams() {
|
||||
|
||||
if (sessions.length === 0) {
|
||||
el.innerHTML = `<div class="vv-stream-servers"><div class="vv-stream-left">${badges}</div></div>`
|
||||
+ '<p class="vv-stream-empty">Nothing playing</p>';
|
||||
+ `<p class="vv-stream-empty">Nothing playing${vvStreamScope === 'mesh' ? ' anywhere in the mesh' : ''}</p>`
|
||||
+ vvStreamNodeNotes();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2437,7 +2452,12 @@ function vvRenderStreams() {
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;font-size:11px;margin-bottom:2px;">
|
||||
<span style="color:${s.paused ? '#fdd835' : '#aaa'};white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;">
|
||||
<span style="color:${iconColor};">${icon}</span> ${vvEscHtml(s.title)}</span>
|
||||
<span style="color:#444;font-size:10px;margin-left:6px;flex-shrink:0;">${vvEscHtml(s.server)}</span>
|
||||
<span style="color:#444;font-size:10px;margin-left:6px;flex-shrink:0;">${vvEscHtml(s.server)}${
|
||||
// Only in mesh scope: in local scope every row is this host, and the badge would be the
|
||||
// same word on every line.
|
||||
vvStreamScope === 'mesh' && s._hostName
|
||||
? `<span class="vv-strm-node">${vvEscHtml(s._hostName)}</span>` : ''
|
||||
}</span>
|
||||
</div>
|
||||
<div style="display:flex;justify-content:space-between;font-size:10px;color:#555;margin-bottom:3px;">
|
||||
<span>${vvEscHtml(s.user)}</span>
|
||||
@@ -2468,22 +2488,36 @@ function vvRenderStreams() {
|
||||
<div class="vv-stream-left">${badges}${mtypeSection}</div>
|
||||
<div class="vv-stream-right">${deviceSection}${resSection}${codecSection}</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:10px;">${sCols.map(vvStreamCol).join('')}</div>${overflow}`;
|
||||
<div style="display:flex;gap:10px;">${sCols.map(vvStreamCol).join('')}</div>${overflow}${vvStreamNodeNotes()}`;
|
||||
}
|
||||
|
||||
// Local by default, and it stays the cheap path — one call per media server on this box. Mesh adds
|
||||
// a bounded SSH hop per partner, paid only while the operator is looking at it.
|
||||
let vvStreamScope = 'local';
|
||||
let vvStreamNodes = [];
|
||||
|
||||
function vvPollStreams() {
|
||||
return fetch('/plugins/varaverk/api/media.php')
|
||||
const scope = vvStreamScope;
|
||||
return fetch('/plugins/varaverk/api/media.php' + (scope === 'mesh' ? '?scope=mesh' : ''))
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
// A reply that arrives after the operator switched scope describes the other view. Dropped
|
||||
// rather than rendered: mesh rows landing in a local view is the kind of wrongness nobody
|
||||
// reads as a bug, they just believe it.
|
||||
if (scope !== vvStreamScope) return;
|
||||
|
||||
vvLastSessions = d.sessions ?? [];
|
||||
vvLastStreamNames = d.server_names ?? [];
|
||||
vvStreamServerCount = d.server_count ?? 0;
|
||||
vvStreamNodes = d.nodes ?? [];
|
||||
vvLastStreamPollAt = Math.floor(Date.now() / 1000);
|
||||
|
||||
const el = document.getElementById('vv-streams-body');
|
||||
if (vvStreamServerCount === 0) {
|
||||
el.innerHTML = '<p class="vv-stream-empty">No media servers detected.<br>'
|
||||
+ '<span>Add EMBY_API_KEY / JELLYFIN_API_KEY / PLEX_TOKEN to master.conf to configure.</span></p>';
|
||||
el.innerHTML = scope === 'mesh'
|
||||
? '<p class="vv-stream-empty">No media servers anywhere in the mesh.</p>' + vvStreamNodeNotes()
|
||||
: '<p class="vv-stream-empty">No media servers detected.<br>'
|
||||
+ '<span>Add EMBY_API_KEY / JELLYFIN_API_KEY / PLEX_TOKEN to master.conf to configure.</span></p>';
|
||||
return;
|
||||
}
|
||||
vvRenderStreams();
|
||||
@@ -2491,6 +2525,34 @@ function vvPollStreams() {
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
// An unreachable partner is stated, never folded into the totals as zero. "Nobody is watching
|
||||
// there" and "that node did not answer" are opposite facts and look identical in a count.
|
||||
function vvStreamNodeNotes() {
|
||||
if (vvStreamScope !== 'mesh') return '';
|
||||
const bad = vvStreamNodes.filter(n => !n.local && !n.reachable);
|
||||
if (!bad.length) return '';
|
||||
return bad.map(n =>
|
||||
`<div style="font-size:10px;color:#a05a2c;margin-top:4px;">${vvEscHtml(n.name)} — unreachable, not counted</div>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
// Delegated off the card, so it survives the body being replaced on every poll.
|
||||
document.getElementById('vv-streams')?.addEventListener('click', ev => {
|
||||
const btn = ev.target.closest('[data-strmscope]');
|
||||
if (!btn) return;
|
||||
const next = btn.dataset.strmscope;
|
||||
if (next === vvStreamScope) return;
|
||||
|
||||
vvStreamScope = next;
|
||||
document.querySelectorAll('#vv-streams [data-strmscope]').forEach(b =>
|
||||
b.classList.toggle('on', b.dataset.strmscope === next));
|
||||
|
||||
// Switching is a request to see the other view now, not at the next tick.
|
||||
const el = document.getElementById('vv-streams-body');
|
||||
if (el) el.innerHTML = '<p class="vv-stream-empty">Loading…</p>';
|
||||
vvPollStreams();
|
||||
});
|
||||
|
||||
// Guarded like the others — this one reaches out to every configured media server, so a wedged
|
||||
// Emby is exactly the case where unguarded ticks would stack.
|
||||
vvPollRunner(vvPollStreams, 12000);
|
||||
|
||||
Reference in New Issue
Block a user