Move in-player entry point into the player settings cog menu
Replaces the floating SyncPlus pill with two items injected into the cog action sheet: "Sync me to group" (immediate one-shot sync, result shown as a transient toast) and "SyncPlay stats" (toggles the floating drift panel, now with its own close button). A MutationObserver appends the items when the sheet appears within 1.5s of a .btnVideoOsdSettings click; markup mirrors the native actionSheetMenuItem structure verified in the bundled 10.11.6 client, and the sheet's own delegated handler auto-closes it on our items like any native entry. Worst-case failure on a future jellyfin-web rename is the items silently not appearing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -434,10 +434,15 @@ public class SyncPlusStatsController : ControllerBase
|
|||||||
</html>
|
</html>
|
||||||
""";
|
""";
|
||||||
|
|
||||||
// The in-player overlay. Deliberately does NOT depend on jellyfin-web's OSD button
|
// The in-player UI. Entry point is jellyfin-web's own player settings (cog) menu:
|
||||||
// DOM (class names in the minified bundle change between releases); it only checks
|
// we watch for the action sheet it opens and append two native-looking menu items.
|
||||||
// for the presence of a <video> element and floats its own fixed-position UI on top.
|
// This intentionally depends on a small, verified slice of the minified client's DOM
|
||||||
// That makes it survive web client updates at the cost of not looking native.
|
// (.btnVideoOsdSettings, .actionSheet, .actionSheetScroller, .actionSheetMenuItem --
|
||||||
|
// all confirmed present in the bundled 10.11.6 web client, including the sheet's own
|
||||||
|
// delegated click handler that auto-closes it on any .actionSheetMenuItem click,
|
||||||
|
// which gives injected items native close behavior for free). If a future
|
||||||
|
// jellyfin-web rename breaks these, the failure mode is just "menu items don't
|
||||||
|
// appear" -- nothing errors, and the standalone stats page still works.
|
||||||
private const string ClientScriptJs = """
|
private const string ClientScriptJs = """
|
||||||
(function () {
|
(function () {
|
||||||
'use strict';
|
'use strict';
|
||||||
@@ -448,23 +453,40 @@ public class SyncPlusStatsController : ControllerBase
|
|||||||
try { return window.ApiClient ? window.ApiClient.accessToken() : null; } catch (e) { return null; }
|
try { return window.ApiClient ? window.ApiClient.accessToken() : null; } catch (e) { return null; }
|
||||||
}
|
}
|
||||||
|
|
||||||
var root = document.createElement('div');
|
var panel = document.createElement('div');
|
||||||
root.id = 'syncPlusOverlay';
|
panel.id = 'syncPlusPanel';
|
||||||
root.style.cssText = 'position:fixed;top:70px;left:12px;z-index:99999;display:none;font-family:sans-serif;font-size:13px;color:#ddd;';
|
panel.style.cssText = 'position:fixed;top:70px;left:12px;z-index:99999;display:none;font-family:sans-serif;font-size:13px;color:#ddd;background:rgba(0,0,0,.75);border:1px solid rgba(255,255,255,.15);border-radius:6px;padding:10px 12px;min-width:260px;';
|
||||||
root.innerHTML =
|
panel.innerHTML =
|
||||||
'<button id="syncPlusToggle" style="background:rgba(0,0,0,.55);color:#bbb;border:1px solid rgba(255,255,255,.25);border-radius:4px;padding:4px 10px;cursor:pointer;font-size:12px;">SyncPlus</button>' +
|
'<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">' +
|
||||||
'<div id="syncPlusPanel" style="display:none;margin-top:6px;background:rgba(0,0,0,.75);border:1px solid rgba(255,255,255,.15);border-radius:6px;padding:10px 12px;min-width:260px;">' +
|
'<span style="color:#999;">SyncPlay stats</span>' +
|
||||||
'<div id="syncPlusRows" style="margin-bottom:8px;">Not in a SyncPlay group.</div>' +
|
'<button id="syncPlusClose" style="background:none;border:none;color:#999;cursor:pointer;font-size:14px;padding:0 2px;">✕</button>' +
|
||||||
'<button id="syncPlusSyncBtn" style="background:#00a4dc;color:#fff;border:none;border-radius:3px;padding:5px 12px;cursor:pointer;font-size:12px;">Sync me to group</button>' +
|
'</div>' +
|
||||||
'<div id="syncPlusResult" style="margin-top:6px;color:#999;font-size:12px;"></div>' +
|
'<div id="syncPlusRows">Not in a SyncPlay group.</div>';
|
||||||
'</div>';
|
document.body.appendChild(panel);
|
||||||
document.body.appendChild(root);
|
|
||||||
|
|
||||||
var panel = root.querySelector('#syncPlusPanel');
|
var toast = document.createElement('div');
|
||||||
var rows = root.querySelector('#syncPlusRows');
|
toast.id = 'syncPlusToast';
|
||||||
var result = root.querySelector('#syncPlusResult');
|
toast.style.cssText = 'position:fixed;bottom:90px;left:50%;transform:translateX(-50%);z-index:99999;display:none;font-family:sans-serif;font-size:13px;color:#eee;background:rgba(0,0,0,.8);border-radius:6px;padding:8px 16px;';
|
||||||
|
document.body.appendChild(toast);
|
||||||
|
var toastHandle = null;
|
||||||
|
|
||||||
|
function showToast(text) {
|
||||||
|
toast.textContent = text;
|
||||||
|
toast.style.display = 'block';
|
||||||
|
if (toastHandle) { clearTimeout(toastHandle); }
|
||||||
|
toastHandle = setTimeout(function () { toast.style.display = 'none'; }, 4000);
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows = panel.querySelector('#syncPlusRows');
|
||||||
var pollHandle = null;
|
var pollHandle = null;
|
||||||
|
|
||||||
|
function closePanel() {
|
||||||
|
panel.style.display = 'none';
|
||||||
|
if (pollHandle) { clearInterval(pollHandle); pollHandle = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
panel.querySelector('#syncPlusClose').addEventListener('click', closePanel);
|
||||||
|
|
||||||
function driftColor(ms) {
|
function driftColor(ms) {
|
||||||
if (ms === null || ms === undefined) { return '#999'; }
|
if (ms === null || ms === undefined) { return '#999'; }
|
||||||
if (ms >= 3000) { return '#ff6b6b'; }
|
if (ms >= 3000) { return '#ff6b6b'; }
|
||||||
@@ -497,32 +519,65 @@ public class SyncPlusStatsController : ControllerBase
|
|||||||
.catch(function (e) { rows.textContent = 'Stats error: ' + e.message; });
|
.catch(function (e) { rows.textContent = 'Stats error: ' + e.message; });
|
||||||
}
|
}
|
||||||
|
|
||||||
root.querySelector('#syncPlusToggle').addEventListener('click', function () {
|
function togglePanel() {
|
||||||
var open = panel.style.display !== 'none';
|
if (panel.style.display !== 'none') { closePanel(); return; }
|
||||||
panel.style.display = open ? 'none' : 'block';
|
panel.style.display = 'block';
|
||||||
if (pollHandle) { clearInterval(pollHandle); pollHandle = null; }
|
refresh();
|
||||||
if (!open) { refresh(); pollHandle = setInterval(refresh, 1000); }
|
pollHandle = setInterval(refresh, 1000);
|
||||||
});
|
}
|
||||||
|
|
||||||
root.querySelector('#syncPlusSyncBtn').addEventListener('click', function () {
|
function syncMe() {
|
||||||
var t = token();
|
var t = token();
|
||||||
if (!t) { result.textContent = 'Not logged in.'; return; }
|
if (!t) { showToast('Not logged in.'); return; }
|
||||||
result.textContent = 'Syncing...';
|
|
||||||
fetch('/SyncPlus/Stats/Sync', { method: 'POST', headers: { 'X-Emby-Token': t } })
|
fetch('/SyncPlus/Stats/Sync', { method: 'POST', headers: { 'X-Emby-Token': t } })
|
||||||
.then(function (r) { return r.json(); })
|
.then(function (r) { return r.json(); })
|
||||||
.then(function (d) { result.textContent = d.Message || 'Done.'; })
|
.then(function (d) { showToast(d.Message || 'Done.'); })
|
||||||
.catch(function (e) { result.textContent = 'Error: ' + e.message; });
|
.catch(function (e) { showToast('Sync error: ' + e.message); });
|
||||||
});
|
}
|
||||||
|
|
||||||
// Only show while something is actually playing in the web player.
|
// The cog menu is an action sheet built fresh on every open, so watch for it appearing
|
||||||
|
// shortly after a click on the OSD settings button and append our items. The sheet's
|
||||||
|
// own delegated click handler closes it on any .actionSheetMenuItem click, so injected
|
||||||
|
// items get native close behavior without us touching its code.
|
||||||
|
var lastCogClick = 0;
|
||||||
|
document.addEventListener('click', function (e) {
|
||||||
|
if (e.target && e.target.closest && e.target.closest('.btnVideoOsdSettings')) {
|
||||||
|
lastCogClick = Date.now();
|
||||||
|
}
|
||||||
|
}, true);
|
||||||
|
|
||||||
|
function makeItem(id, text, handler) {
|
||||||
|
var btn = document.createElement('button');
|
||||||
|
btn.setAttribute('is', 'emby-button');
|
||||||
|
btn.setAttribute('type', 'button');
|
||||||
|
btn.setAttribute('data-id', id);
|
||||||
|
btn.className = 'listItem listItem-button actionSheetMenuItem';
|
||||||
|
btn.innerHTML = '<div class="listItemBody actionsheetListItemBody"><div class="listItemBodyText actionSheetItemText">' + text + '</div></div>';
|
||||||
|
btn.addEventListener('click', handler);
|
||||||
|
return btn;
|
||||||
|
}
|
||||||
|
|
||||||
|
new MutationObserver(function (mutations) {
|
||||||
|
if (Date.now() - lastCogClick > 1500) { return; }
|
||||||
|
for (var i = 0; i < mutations.length; i++) {
|
||||||
|
var added = mutations[i].addedNodes;
|
||||||
|
for (var j = 0; j < added.length; j++) {
|
||||||
|
var node = added[j];
|
||||||
|
if (!(node instanceof HTMLElement)) { continue; }
|
||||||
|
var sheet = node.classList && node.classList.contains('actionSheet') ? node : node.querySelector && node.querySelector('.actionSheet');
|
||||||
|
if (!sheet || sheet.querySelector('[data-id="syncplus-sync"]')) { continue; }
|
||||||
|
var scroller = sheet.querySelector('.actionSheetScroller');
|
||||||
|
if (!scroller) { continue; }
|
||||||
|
scroller.appendChild(makeItem('syncplus-sync', 'Sync me to group', syncMe));
|
||||||
|
scroller.appendChild(makeItem('syncplus-stats', 'SyncPlay stats', togglePanel));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}).observe(document.body, { childList: true, subtree: true });
|
||||||
|
|
||||||
|
// Close the panel when playback ends -- the cog (and the group context) is gone.
|
||||||
setInterval(function () {
|
setInterval(function () {
|
||||||
var video = document.querySelector('video');
|
if (!document.querySelector('video') && panel.style.display !== 'none') {
|
||||||
var show = !!video;
|
closePanel();
|
||||||
root.style.display = show ? 'block' : 'none';
|
|
||||||
if (!show && pollHandle) {
|
|
||||||
clearInterval(pollHandle);
|
|
||||||
pollHandle = null;
|
|
||||||
panel.style.display = 'none';
|
|
||||||
}
|
}
|
||||||
}, 1000);
|
}, 1000);
|
||||||
})();
|
})();
|
||||||
|
|||||||
Reference in New Issue
Block a user