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:
2026-07-10 14:01:49 +02:00
co-authored by Claude Fable 5
parent 268a6ed2d0
commit 64a30bfa64
@@ -434,10 +434,15 @@ public class SyncPlusStatsController : ControllerBase
</html>
""";
// The in-player overlay. Deliberately does NOT depend on jellyfin-web's OSD button
// DOM (class names in the minified bundle change between releases); it only checks
// for the presence of a <video> element and floats its own fixed-position UI on top.
// That makes it survive web client updates at the cost of not looking native.
// The in-player UI. Entry point is jellyfin-web's own player settings (cog) menu:
// we watch for the action sheet it opens and append two native-looking menu items.
// This intentionally depends on a small, verified slice of the minified client's DOM
// (.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 = """
(function () {
'use strict';
@@ -448,23 +453,40 @@ public class SyncPlusStatsController : ControllerBase
try { return window.ApiClient ? window.ApiClient.accessToken() : null; } catch (e) { return null; }
}
var root = document.createElement('div');
root.id = 'syncPlusOverlay';
root.style.cssText = 'position:fixed;top:70px;left:12px;z-index:99999;display:none;font-family:sans-serif;font-size:13px;color:#ddd;';
root.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 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;">' +
'<div id="syncPlusRows" style="margin-bottom:8px;">Not in a SyncPlay group.</div>' +
'<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 id="syncPlusResult" style="margin-top:6px;color:#999;font-size:12px;"></div>' +
'</div>';
document.body.appendChild(root);
var panel = document.createElement('div');
panel.id = 'syncPlusPanel';
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;';
panel.innerHTML =
'<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">' +
'<span style="color:#999;">SyncPlay stats</span>' +
'<button id="syncPlusClose" style="background:none;border:none;color:#999;cursor:pointer;font-size:14px;padding:0 2px;">&#10005;</button>' +
'</div>' +
'<div id="syncPlusRows">Not in a SyncPlay group.</div>';
document.body.appendChild(panel);
var panel = root.querySelector('#syncPlusPanel');
var rows = root.querySelector('#syncPlusRows');
var result = root.querySelector('#syncPlusResult');
var toast = document.createElement('div');
toast.id = 'syncPlusToast';
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;
function closePanel() {
panel.style.display = 'none';
if (pollHandle) { clearInterval(pollHandle); pollHandle = null; }
}
panel.querySelector('#syncPlusClose').addEventListener('click', closePanel);
function driftColor(ms) {
if (ms === null || ms === undefined) { return '#999'; }
if (ms >= 3000) { return '#ff6b6b'; }
@@ -497,32 +519,65 @@ public class SyncPlusStatsController : ControllerBase
.catch(function (e) { rows.textContent = 'Stats error: ' + e.message; });
}
root.querySelector('#syncPlusToggle').addEventListener('click', function () {
var open = panel.style.display !== 'none';
panel.style.display = open ? 'none' : 'block';
if (pollHandle) { clearInterval(pollHandle); pollHandle = null; }
if (!open) { refresh(); pollHandle = setInterval(refresh, 1000); }
});
function togglePanel() {
if (panel.style.display !== 'none') { closePanel(); return; }
panel.style.display = 'block';
refresh();
pollHandle = setInterval(refresh, 1000);
}
root.querySelector('#syncPlusSyncBtn').addEventListener('click', function () {
function syncMe() {
var t = token();
if (!t) { result.textContent = 'Not logged in.'; return; }
result.textContent = 'Syncing...';
if (!t) { showToast('Not logged in.'); return; }
fetch('/SyncPlus/Stats/Sync', { method: 'POST', headers: { 'X-Emby-Token': t } })
.then(function (r) { return r.json(); })
.then(function (d) { result.textContent = d.Message || 'Done.'; })
.catch(function (e) { result.textContent = 'Error: ' + e.message; });
});
.then(function (d) { showToast(d.Message || 'Done.'); })
.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 () {
var video = document.querySelector('video');
var show = !!video;
root.style.display = show ? 'block' : 'none';
if (!show && pollHandle) {
clearInterval(pollHandle);
pollHandle = null;
panel.style.display = 'none';
if (!document.querySelector('video') && panel.style.display !== 'none') {
closePanel();
}
}, 1000);
})();