Add in-player SyncPlus overlay via served-index.html script injection

An IStartupFilter-registered middleware intercepts /web/index.html
responses and injects a script tag for /SyncPlus/Stats/Client.js --
response-level injection rather than the write-to-disk approach other
plugins use, since index.html is root-owned in the linuxserver image
while Jellyfin runs unprivileged. The overlay floats its own fixed
pill/panel (no dependency on jellyfin-web's minified OSD DOM), shows
per-member drift for the caller's own group via the new non-admin
/SyncPlus/Stats/Mine endpoint, and reuses the existing one-shot
/SyncPlus/Stats/Sync action.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 13:55:51 +02:00
co-authored by Claude Fable 5
parent 8dadef4184
commit 268a6ed2d0
4 changed files with 383 additions and 61 deletions
+42
View File
@@ -361,6 +361,48 @@ modes the reverted features did.
real drifted second device (only one browser session available this real drifted second device (only one browser session available this
round, same recurring constraint as everything else that needs two real round, same recurring constraint as everything else that needs two real
logins). logins).
- **In-player overlay added 2026-07-09.** User-requested (correctly pushing
back on an earlier "not possible without patching jellyfin-web" claim --
plugins like InPlayerEpisodePreview prove the pattern exists): the stats
panel + sync button now also appear *inside the video player page*, not
just on the standalone stats page.
- Mechanism: an `IStartupFilter` registered from plugin DI adds
`SyncPlusIndexInjectionMiddleware` to the front of the pipeline, which
intercepts responses for `/web/` and `/web/index.html` and injects a
`<script src="/SyncPlus/Stats/Client.js" defer>` tag before `</body>`
**in the served response only** -- deliberately NOT the
write-to-index.html-on-disk approach other plugins default to, because
in the linuxserver image (dev and the real k8s deployment alike)
index.html is root-owned while Jellyfin runs unprivileged, so disk
writes fail with permission errors there (the known issue
InPlayerEpisodePreview's README warns about). Response interception has
no permission problem and leaves nothing behind on uninstall. The
middleware also strips `Accept-Encoding` on those requests (so
downstream compression can't garble the `</body>` marker) and drops
`ETag`/`Last-Modified` on modified responses (so browsers can't
cache-revalidate back to an uninjected copy).
- `GET /SyncPlus/Stats/Client.js` serves the overlay script
(`[AllowAnonymous]` -- script tags fetch without auth; the script itself
only calls auth-gated endpoints via `window.ApiClient.accessToken()`,
available because the script runs inside the real SPA).
- `GET /SyncPlus/Stats/Mine` -- new non-admin endpoint returning only the
*caller's own group's* stats (regular viewers aren't admins, and the
admin-gated all-groups endpoint would leak other users' sessions).
Returns `{"Group": null}`-shaped response when not in a group (nulls
are omitted by Jellyfin's JSON serializer, so the body is literally
`{}` -- the overlay JS handles `undefined` fine).
- Overlay design: deliberately does NOT hook into jellyfin-web's OSD
button DOM (minified class names churn across releases) -- it floats
its own fixed-position "SyncPlus" pill + panel, shown only while a
`<video>` element exists on the page. Survives web client updates at
the cost of not looking native.
- **Server-side verified 2026-07-09**: script tag confirmed present in
served `/web/index.html` and `/web/` (including with
`Accept-Encoding: gzip, br` requests), `Client.js` serves as
`application/javascript` and passes `node --check`, `Mine` returns 200
with token / 401 without. **Browser-side behavior (overlay appearing
during playback, panel polling, sync button) handed to the user to
test** -- not yet verified as of this note.
## Open questions (revisit before/at the relevant phase, not now) ## Open questions (revisit before/at the relevant phase, not now)
@@ -2,6 +2,7 @@ using Jellyfin.Plugin.SyncPlus.SyncPlay;
using MediaBrowser.Controller; using MediaBrowser.Controller;
using MediaBrowser.Controller.Plugins; using MediaBrowser.Controller.Plugins;
using MediaBrowser.Controller.SyncPlay; using MediaBrowser.Controller.SyncPlay;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using ConcreteSyncPlayManager = Emby.Server.Implementations.SyncPlay.SyncPlayManager; using ConcreteSyncPlayManager = Emby.Server.Implementations.SyncPlay.SyncPlayManager;
@@ -20,5 +21,10 @@ public class PluginServiceRegistrator : IPluginServiceRegistrator
// RegisterServices runs after core's, so this registration wins for resolution. // RegisterServices runs after core's, so this registration wins for resolution.
serviceCollection.AddSingleton<ConcreteSyncPlayManager>(); serviceCollection.AddSingleton<ConcreteSyncPlayManager>();
serviceCollection.AddSingleton<ISyncPlayManager, SyncPlusSyncPlayManager>(); serviceCollection.AddSingleton<ISyncPlayManager, SyncPlusSyncPlayManager>();
// Injects the in-player overlay script tag into served index.html -- see
// SyncPlusIndexInjection.cs for why this is a startup filter and why it
// intercepts responses instead of writing to the file on disk.
serviceCollection.AddSingleton<IStartupFilter, SyncPlusIndexInjectionStartupFilter>();
} }
} }
@@ -0,0 +1,119 @@
using System;
using System.IO;
using System.Text;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.SyncPlus.SyncPlay;
/// <summary>
/// Registers <see cref="SyncPlusIndexInjectionMiddleware"/> at the front of the request
/// pipeline. An <see cref="IStartupFilter"/> registered in DI is the one seam a plugin
/// has for adding middleware -- plugins can't touch Jellyfin's own <c>Startup.Configure</c>,
/// but startup filters are resolved from the final service provider when the pipeline is
/// built, and plugin <c>RegisterServices</c> runs early enough to land there. Same
/// technique the "File Transformation" plugin ecosystem uses.
/// </summary>
public class SyncPlusIndexInjectionStartupFilter : IStartupFilter
{
/// <inheritdoc />
public Action<IApplicationBuilder> Configure(Action<IApplicationBuilder> next)
=> app =>
{
app.UseMiddleware<SyncPlusIndexInjectionMiddleware>();
next(app);
};
}
/// <summary>
/// Injects a <c>&lt;script&gt;</c> tag for the plugin's in-player overlay
/// (<see cref="SyncPlusStatsController.GetClientScript"/>) into the *served*
/// <c>index.html</c> response, on the fly. Deliberately does not write to the file on
/// disk: in the linuxserver image (dev and the real k8s deployment alike) index.html is
/// owned by root while Jellyfin runs as an unprivileged user, so the disk-write approach
/// other plugins default to (e.g. InPlayerEpisodePreview) fails with permission errors
/// there. Intercepting the response instead has no permission problem and leaves nothing
/// behind if the plugin is removed.
/// </summary>
public class SyncPlusIndexInjectionMiddleware
{
private const string ScriptTag = "<script src=\"/SyncPlus/Stats/Client.js\" defer></script>";
private readonly RequestDelegate _next;
private readonly ILogger<SyncPlusIndexInjectionMiddleware> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="SyncPlusIndexInjectionMiddleware"/> class.
/// </summary>
/// <param name="next">The next middleware in the pipeline.</param>
/// <param name="logger">Instance of the <see cref="ILogger{SyncPlusIndexInjectionMiddleware}"/> interface.</param>
public SyncPlusIndexInjectionMiddleware(RequestDelegate next, ILogger<SyncPlusIndexInjectionMiddleware> logger)
{
_next = next;
_logger = logger;
}
/// <summary>
/// Intercepts index.html responses and injects the overlay script tag.
/// </summary>
/// <param name="context">The HTTP context.</param>
/// <returns>A task.</returns>
public async Task InvokeAsync(HttpContext context)
{
var path = context.Request.Path.Value ?? string.Empty;
var isIndex = path.Equals("/web/", StringComparison.OrdinalIgnoreCase)
|| path.Equals("/web/index.html", StringComparison.OrdinalIgnoreCase);
if (!isIndex)
{
await _next(context).ConfigureAwait(false);
return;
}
// Downstream middleware would otherwise gzip/brotli the body before it gets back
// to us, making the </body> marker unfindable. Dropping Accept-Encoding for this
// one request keeps the body plain; index.html is ~5KB, the loss is irrelevant.
context.Request.Headers.Remove("Accept-Encoding");
var originalBody = context.Response.Body;
using var buffer = new MemoryStream();
context.Response.Body = buffer;
try
{
await _next(context).ConfigureAwait(false);
var isHtml = context.Response.ContentType?.Contains("text/html", StringComparison.OrdinalIgnoreCase) ?? false;
if (context.Response.StatusCode == StatusCodes.Status200OK && isHtml)
{
var html = Encoding.UTF8.GetString(buffer.ToArray());
var injected = html.Replace("</body>", ScriptTag + "</body>", StringComparison.OrdinalIgnoreCase);
var bytes = Encoding.UTF8.GetBytes(injected);
if (injected.Length == html.Length)
{
_logger.LogWarning("SyncPlus: served index.html contains no </body> tag, overlay script not injected");
}
// The stale validators describe the unmodified file; leaving them would let
// a browser cache-revalidate its way back to an uninjected copy.
context.Response.Headers.Remove("ETag");
context.Response.Headers.Remove("Last-Modified");
context.Response.ContentLength = bytes.Length;
await originalBody.WriteAsync(bytes).ConfigureAwait(false);
}
else
{
buffer.Position = 0;
await buffer.CopyToAsync(originalBody).ConfigureAwait(false);
}
}
finally
{
context.Response.Body = originalBody;
}
}
}
@@ -65,6 +65,22 @@ public class SyncPlusStatsController : ControllerBase
return Content(StatsPageHtml, "text/html"); return Content(StatsPageHtml, "text/html");
} }
/// <summary>
/// Serves the in-player overlay script. A script tag pointing here gets injected
/// into the served <c>index.html</c> by <see cref="SyncPlusIndexInjectionMiddleware"/>,
/// so this runs inside the real jellyfin-web SPA with access to <c>window.ApiClient</c>
/// -- same reason the standalone stats page can read the login token. Anonymous
/// because script tags are fetched without auth headers; the script itself only
/// calls auth-gated endpoints.
/// </summary>
/// <returns>The client-side overlay script.</returns>
[HttpGet("Client.js")]
[AllowAnonymous]
public ContentResult GetClientScript()
{
return Content(ClientScriptJs, "application/javascript");
}
/// <summary> /// <summary>
/// Gets a live snapshot of every active SyncPlay group's members and their drift /// Gets a live snapshot of every active SyncPlay group's members and their drift
/// relative to whoever in the group is furthest behind. /// relative to whoever in the group is furthest behind.
@@ -74,13 +90,49 @@ public class SyncPlusStatsController : ControllerBase
[Authorize(Policy = Policies.RequiresElevation)] [Authorize(Policy = Policies.RequiresElevation)]
public ActionResult<IReadOnlyList<GroupStatsDto>> GetStats() public ActionResult<IReadOnlyList<GroupStatsDto>> GetStats()
{ {
var sessionsByDevice = _sessionManager.Sessions var sessionsByDevice = GetSessionsByDevice();
var result = new List<GroupStatsDto>();
foreach (var (groupId, deviceIds) in _membershipStore.GetAllGroups())
{
result.Add(BuildGroupStats(groupId, deviceIds, sessionsByDevice));
}
return result;
}
/// <summary>
/// Gets the live stats for the calling device's own SyncPlay group only. Unlike
/// <see cref="GetStats"/> this is open to any authenticated user, because it only
/// ever exposes the group the caller is themselves a member of -- it's what the
/// in-player overlay (served by <see cref="GetClientScript"/>) polls, and regular
/// viewers aren't admins.
/// </summary>
/// <returns>The caller's group stats, with <c>Group</c> null when not in a group.</returns>
[HttpGet("Mine")]
[Authorize]
public ActionResult<MyGroupStatsDto> GetMyStats()
{
var deviceId = User.Claims.FirstOrDefault(c => c.Type == DeviceIdClaimType)?.Value;
var groupId = string.IsNullOrEmpty(deviceId) ? null : _membershipStore.GetGroupForDevice(deviceId);
if (groupId is null || !_membershipStore.GetAllGroups().TryGetValue(groupId.Value, out var deviceIds))
{
return new MyGroupStatsDto { Group = null };
}
return new MyGroupStatsDto { Group = BuildGroupStats(groupId.Value, deviceIds, GetSessionsByDevice()) };
}
private Dictionary<string, SessionInfo> GetSessionsByDevice()
=> _sessionManager.Sessions
.Where(s => !string.IsNullOrEmpty(s.DeviceId)) .Where(s => !string.IsNullOrEmpty(s.DeviceId))
.GroupBy(s => s.DeviceId) .GroupBy(s => s.DeviceId)
.ToDictionary(g => g.Key, g => g.First()); .ToDictionary(g => g.Key, g => g.First());
var result = new List<GroupStatsDto>(); private static GroupStatsDto BuildGroupStats(
foreach (var (groupId, deviceIds) in _membershipStore.GetAllGroups()) Guid groupId,
IReadOnlyCollection<string> deviceIds,
Dictionary<string, SessionInfo> sessionsByDevice)
{ {
var members = new List<MemberStatsDto>(); var members = new List<MemberStatsDto>();
long? laggardTicks = null; long? laggardTicks = null;
@@ -134,14 +186,11 @@ public class SyncPlusStatsController : ControllerBase
}); });
} }
result.Add(new GroupStatsDto return new GroupStatsDto
{ {
GroupId = groupId, GroupId = groupId,
Members = members Members = members
}); };
}
return result;
} }
/// <summary> /// <summary>
@@ -385,6 +434,112 @@ public class SyncPlusStatsController : ControllerBase
</html> </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.
private const string ClientScriptJs = """
(function () {
'use strict';
if (window.__syncPlusInjected) { return; }
window.__syncPlusInjected = true;
function token() {
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 = root.querySelector('#syncPlusPanel');
var rows = root.querySelector('#syncPlusRows');
var result = root.querySelector('#syncPlusResult');
var pollHandle = null;
function driftColor(ms) {
if (ms === null || ms === undefined) { return '#999'; }
if (ms >= 3000) { return '#ff6b6b'; }
if (ms >= 1000) { return '#ffb86b'; }
return '#6bd68b';
}
function render(group) {
if (!group) {
rows.textContent = 'Not in a SyncPlay group.';
return;
}
var html = '';
(group.Members || []).forEach(function (m) {
if (!m.Connected) { return; }
var drift = m.DriftMs === null || m.DriftMs === undefined ? '-' : m.DriftMs + ' ms';
html += '<div style="display:flex;justify-content:space-between;gap:12px;padding:2px 0;">' +
'<span>' + (m.UserName || '?') + ' (' + (m.PlayMethod || '-') + (m.IsPaused ? ', paused' : '') + ')</span>' +
'<span style="color:' + driftColor(m.DriftMs) + ';">' + drift + '</span></div>';
});
rows.innerHTML = html || 'No connected members.';
}
function refresh() {
var t = token();
if (!t) { return; }
fetch('/SyncPlus/Stats/Mine', { headers: { 'X-Emby-Token': t } })
.then(function (r) { if (!r.ok) { throw new Error('HTTP ' + r.status); } return r.json(); })
.then(function (d) { render(d.Group); })
.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); }
});
root.querySelector('#syncPlusSyncBtn').addEventListener('click', function () {
var t = token();
if (!t) { result.textContent = 'Not logged in.'; return; }
result.textContent = 'Syncing...';
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; });
});
// Only show while something is actually playing in the web player.
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';
}
}, 1000);
})();
""";
/// <summary>
/// Result of <see cref="GetMyStats"/>.
/// </summary>
public class MyGroupStatsDto
{
/// <summary>
/// Gets or sets the caller's group stats, or null when the caller isn't in a
/// SyncPlay group.
/// </summary>
public GroupStatsDto? Group { get; set; }
}
/// <summary> /// <summary>
/// Result of a <see cref="SyncMe"/> call. /// Result of a <see cref="SyncMe"/> call.
/// </summary> /// </summary>