From 227fcd7fdd8e6d90bd2303c6a43780b05556b777 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Wed, 17 Jun 2026 20:49:01 +0200 Subject: [PATCH] Dropdown menu --- Jellyfin.Plugin.JellyLMS/Web/remote-button.js | 261 ++++++++++++++++-- 1 file changed, 243 insertions(+), 18 deletions(-) diff --git a/Jellyfin.Plugin.JellyLMS/Web/remote-button.js b/Jellyfin.Plugin.JellyLMS/Web/remote-button.js index 20b8350..6981e15 100644 --- a/Jellyfin.Plugin.JellyLMS/Web/remote-button.js +++ b/Jellyfin.Plugin.JellyLMS/Web/remote-button.js @@ -1,5 +1,6 @@ (function () { var hasAccess = false; + var panel = null; function getAuthToken() { try { @@ -11,17 +12,233 @@ } } - function checkAccess() { + function api(path, options) { var token = getAuthToken(); - if (!token) { - return Promise.resolve(false); + var opts = Object.assign({ headers: {} }, options); + if (token) { + opts.headers['X-Emby-Token'] = token; } - return fetch('/JellyLms/RemoteControl/Access', { headers: { 'X-Emby-Token': token } }) - .then(function (resp) { return resp.ok; }) + if (opts.body && typeof opts.body === 'object') { + opts.body = JSON.stringify(opts.body); + opts.headers['Content-Type'] = 'application/json'; + } + + return fetch('/JellyLms' + path, opts); + } + + function checkAccess() { + return api('/RemoteControl/Access') + .then(function (r) { return r.ok; }) .catch(function () { return false; }); } + // ---------- panel ---------- + + function createPanel(anchorBtn) { + var p = document.createElement('div'); + p.id = 'jellylms-panel'; + p.style.cssText = [ + 'position:fixed', + 'top:' + (anchorBtn.getBoundingClientRect().bottom + 4) + 'px', + 'right:' + (window.innerWidth - anchorBtn.getBoundingClientRect().right) + 'px', + 'width:300px', + 'max-height:70vh', + 'overflow-y:auto', + 'background:#1c1c1c', + 'border:1px solid #333', + 'border-radius:4px', + 'box-shadow:0 4px 24px rgba(0,0,0,.7)', + 'z-index:999999', + 'font-family:inherit', + 'font-size:14px', + 'color:#ddd', + 'padding:12px', + ].join(';'); + return p; + } + + function el(tag, css, html) { + var e = document.createElement(tag); + if (css) { e.style.cssText = css; } + if (html != null) { e.innerHTML = html; } + return e; + } + + function renderError(p, msg) { + p.innerHTML = '
' + msg + '
'; + } + + function renderPlayers(p, players, groups) { + p.innerHTML = ''; + + var masterMacs = {}; + groups.forEach(function (g) { + masterMacs[g.masterMac] = g; + (g.slaveMacs || []).forEach(function (s) { masterMacs[s] = g; }); + }); + + // ---- Players ---- + var hdr = el('div', 'font-size:11px;text-transform:uppercase;letter-spacing:.08em;color:#888;margin-bottom:6px', 'Players'); + p.appendChild(hdr); + + players.forEach(function (player) { + var row = el('div', 'display:flex;align-items:center;gap:8px;margin-bottom:10px'); + + var dot = el('span', + 'width:8px;height:8px;border-radius:50%;flex-shrink:0;background:' + + (player.isConnected ? '#4caf50' : '#555')); + row.appendChild(dot); + + var name = el('span', 'flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap', player.name); + row.appendChild(name); + + var pwrBtn = el('button', + 'background:none;border:1px solid #555;border-radius:3px;color:#ddd;' + + 'padding:2px 7px;cursor:pointer;font-size:12px;flex-shrink:0', + player.isPoweredOn ? 'Off' : 'On'); + pwrBtn.title = player.isPoweredOn ? 'Power off' : 'Power on'; + pwrBtn.addEventListener('click', function () { + var endpoint = player.isPoweredOn ? '/Players/' + player.mac + '/PowerOff' : '/Players/' + player.mac + '/PowerOn'; + api(endpoint, { method: 'POST' }).then(function () { refresh(p); }); + }); + row.appendChild(pwrBtn); + + p.appendChild(row); + + if (player.isConnected) { + var volRow = el('div', 'display:flex;align-items:center;gap:8px;margin-bottom:10px;padding-left:16px'); + var volIcon = el('span', 'color:#888;font-size:16px;font-family:\'Material Icons\';line-height:1', 'volume_up'); + volRow.appendChild(volIcon); + + var slider = el('input'); + slider.type = 'range'; + slider.min = 0; + slider.max = 100; + slider.value = player.volume || 0; + slider.style.cssText = 'flex:1;accent-color:#00a4dc'; + slider.addEventListener('change', function () { + api('/Players/' + player.mac + '/Volume', { method: 'POST', body: { volume: parseInt(slider.value, 10) } }); + }); + volRow.appendChild(slider); + + var volVal = el('span', 'color:#888;font-size:12px;width:28px;text-align:right', player.volume + '%'); + slider.addEventListener('input', function () { volVal.textContent = slider.value + '%'; }); + volRow.appendChild(volVal); + + p.appendChild(volRow); + } + }); + + // ---- Sync Groups ---- + if (groups.length > 0) { + var sep = el('div', 'border-top:1px solid #333;margin:8px 0'); + p.appendChild(sep); + + var ghdr = el('div', 'font-size:11px;text-transform:uppercase;letter-spacing:.08em;color:#888;margin-bottom:6px', 'Sync Groups'); + p.appendChild(ghdr); + + groups.forEach(function (g) { + var master = players.find(function (pl) { return pl.mac === g.masterMac; }); + var slaves = (g.slaveMacs || []).map(function (m) { + return players.find(function (pl) { return pl.mac === m; }); + }).filter(Boolean); + + var names = [master ? master.name : g.masterMac] + .concat(slaves.map(function (s) { return s.name; })) + .join(' + '); + + var grow = el('div', 'display:flex;align-items:center;gap:8px;margin-bottom:8px'); + var glabel = el('span', 'flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:13px', names); + grow.appendChild(glabel); + + var dissolveBtn = el('button', + 'background:none;border:1px solid #555;border-radius:3px;color:#f88;' + + 'padding:2px 7px;cursor:pointer;font-size:12px;flex-shrink:0', + 'Unsync'); + dissolveBtn.addEventListener('click', function () { + api('/SyncGroups/' + g.masterMac, { method: 'DELETE' }).then(function () { refresh(p); }); + }); + grow.appendChild(dissolveBtn); + p.appendChild(grow); + }); + } + + // ---- Create Sync Group ---- + var unsynced = players.filter(function (pl) { + return !masterMacs[pl.mac] && pl.isConnected; + }); + + if (unsynced.length >= 2) { + var sep2 = el('div', 'border-top:1px solid #333;margin:8px 0'); + p.appendChild(sep2); + + var shdr = el('div', 'font-size:11px;text-transform:uppercase;letter-spacing:.08em;color:#888;margin-bottom:6px', 'Create Sync Group'); + p.appendChild(shdr); + + var checkboxes = []; + unsynced.forEach(function (pl) { + var crow = el('div', 'display:flex;align-items:center;gap:8px;margin-bottom:6px'); + var cb = document.createElement('input'); + cb.type = 'checkbox'; + cb.style.accentColor = '#00a4dc'; + cb.dataset.mac = pl.mac; + checkboxes.push(cb); + crow.appendChild(cb); + crow.appendChild(el('span', 'flex:1', pl.name)); + p.appendChild(crow); + }); + + var syncBtn = el('button', + 'margin-top:6px;width:100%;background:#00a4dc;border:none;border-radius:3px;' + + 'color:#fff;padding:5px 0;cursor:pointer;font-size:13px', + 'Sync Selected'); + syncBtn.addEventListener('click', function () { + var selected = checkboxes.filter(function (cb) { return cb.checked; }); + if (selected.length < 2) { return; } + var macs = selected.map(function (cb) { return cb.dataset.mac; }); + api('/SyncGroups', { + method: 'POST', + body: { masterMac: macs[0], slaveMacs: macs.slice(1) } + }).then(function () { refresh(p); }); + }); + p.appendChild(syncBtn); + } + } + + function refresh(p) { + p.innerHTML = '
Loading…
'; + Promise.all([ + api('/Players').then(function (r) { return r.json(); }), + api('/SyncGroups').then(function (r) { return r.json(); }) + ]).then(function (results) { + renderPlayers(p, results[0], results[1]); + }).catch(function () { + renderError(p, 'Failed to load players.'); + }); + } + + function togglePanel(btn) { + if (panel) { + panel.remove(); + panel = null; + return; + } + + panel = createPanel(btn); + document.body.appendChild(panel); + refresh(panel, btn); + } + + function closePanel() { + if (panel) { + panel.remove(); + panel = null; + } + } + + // ---------- header button ---------- + function addButton(headerRight) { if (headerRight.querySelector('.headerLmsRemoteButton')) { return; @@ -33,8 +250,9 @@ btn.className = 'headerLmsRemoteButton headerButton headerButtonRight paper-icon-button-light'; btn.title = 'Multi-room Remote'; btn.innerHTML = ''; - btn.addEventListener('click', function () { - window.location.href = '/JellyLms/RemoteControl'; + btn.addEventListener('click', function (e) { + e.stopPropagation(); + togglePanel(btn); }); var castButton = headerRight.querySelector('.headerCastButton'); @@ -46,27 +264,34 @@ } function tryInject() { - if (!hasAccess) { - return; - } - + if (!hasAccess) { return; } var headerRight = document.querySelector('.headerRight'); - if (headerRight) { - addButton(headerRight); - } + if (headerRight) { addButton(headerRight); } } + // ---------- bootstrap ---------- + function init() { checkAccess().then(function (ok) { hasAccess = ok; - if (!ok) { - return; - } + if (!ok) { return; } tryInject(); - var observer = new MutationObserver(tryInject); + var observer = new MutationObserver(function () { + // If our panel was removed by SPA navigation, clean up the reference + if (panel && !document.body.contains(panel)) { + panel = null; + } + tryInject(); + }); observer.observe(document.body, { childList: true, subtree: true }); + + document.addEventListener('click', function (e) { + if (panel && !panel.contains(e.target)) { + closePanel(); + } + }); }); }