From 268a6ed2d01730039429317a2578646079c48209 Mon Sep 17 00:00:00 2001 From: seer Date: Fri, 10 Jul 2026 13:55:51 +0200 Subject: [PATCH] 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 --- PLAN.md | 42 +++ .../PluginServiceRegistrator.cs | 6 + .../SyncPlay/SyncPlusIndexInjection.cs | 119 ++++++++ .../SyncPlay/SyncPlusStatsController.cs | 277 ++++++++++++++---- 4 files changed, 383 insertions(+), 61 deletions(-) create mode 100644 src/JellyfinSyncPlus/SyncPlay/SyncPlusIndexInjection.cs diff --git a/PLAN.md b/PLAN.md index 071fed4..5f0e4ef 100644 --- a/PLAN.md +++ b/PLAN.md @@ -361,6 +361,48 @@ modes the reverted features did. real drifted second device (only one browser session available this round, same recurring constraint as everything else that needs two real 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 + `"; + + private readonly RequestDelegate _next; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The next middleware in the pipeline. + /// Instance of the interface. + public SyncPlusIndexInjectionMiddleware(RequestDelegate next, ILogger logger) + { + _next = next; + _logger = logger; + } + + /// + /// Intercepts index.html responses and injects the overlay script tag. + /// + /// The HTTP context. + /// A task. + 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 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("", ScriptTag + "", StringComparison.OrdinalIgnoreCase); + var bytes = Encoding.UTF8.GetBytes(injected); + + if (injected.Length == html.Length) + { + _logger.LogWarning("SyncPlus: served index.html contains no 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; + } + } +} diff --git a/src/JellyfinSyncPlus/SyncPlay/SyncPlusStatsController.cs b/src/JellyfinSyncPlus/SyncPlay/SyncPlusStatsController.cs index 486c05a..c6207db 100644 --- a/src/JellyfinSyncPlus/SyncPlay/SyncPlusStatsController.cs +++ b/src/JellyfinSyncPlus/SyncPlay/SyncPlusStatsController.cs @@ -65,6 +65,22 @@ public class SyncPlusStatsController : ControllerBase return Content(StatsPageHtml, "text/html"); } + /// + /// Serves the in-player overlay script. A script tag pointing here gets injected + /// into the served index.html by , + /// so this runs inside the real jellyfin-web SPA with access to window.ApiClient + /// -- 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. + /// + /// The client-side overlay script. + [HttpGet("Client.js")] + [AllowAnonymous] + public ContentResult GetClientScript() + { + return Content(ClientScriptJs, "application/javascript"); + } + /// /// Gets a live snapshot of every active SyncPlay group's members and their drift /// relative to whoever in the group is furthest behind. @@ -74,76 +90,109 @@ public class SyncPlusStatsController : ControllerBase [Authorize(Policy = Policies.RequiresElevation)] public ActionResult> GetStats() { - var sessionsByDevice = _sessionManager.Sessions - .Where(s => !string.IsNullOrEmpty(s.DeviceId)) - .GroupBy(s => s.DeviceId) - .ToDictionary(g => g.Key, g => g.First()); + var sessionsByDevice = GetSessionsByDevice(); var result = new List(); foreach (var (groupId, deviceIds) in _membershipStore.GetAllGroups()) { - var members = new List(); - long? laggardTicks = null; - - foreach (var deviceId in deviceIds) - { - if (!sessionsByDevice.TryGetValue(deviceId, out var session)) - { - continue; - } - - var positionTicks = session.PlayState?.PositionTicks; - if (positionTicks is not null && !(session.PlayState?.IsPaused ?? false)) - { - laggardTicks = laggardTicks is null ? positionTicks : Math.Min(laggardTicks.Value, positionTicks.Value); - } - } - - foreach (var deviceId in deviceIds) - { - if (!sessionsByDevice.TryGetValue(deviceId, out var session)) - { - members.Add(new MemberStatsDto { DeviceId = deviceId, Connected = false }); - continue; - } - - var positionTicks = session.PlayState?.PositionTicks; - var transcoding = session.TranscodingInfo; - - members.Add(new MemberStatsDto - { - DeviceId = deviceId, - Connected = true, - DeviceName = session.DeviceName, - UserName = session.UserName, - NowPlaying = session.NowPlayingItem?.Name, - PositionTicks = positionTicks, - PositionFormatted = positionTicks is null ? null : TimeSpan.FromTicks(positionTicks.Value).ToString(@"hh\:mm\:ss"), - DriftMs = positionTicks is null || laggardTicks is null - ? null - : (positionTicks.Value - laggardTicks.Value) / TimeSpan.TicksPerMillisecond, - IsPaused = session.PlayState?.IsPaused, - PlayMethod = session.PlayState?.PlayMethod?.ToString(), - Bitrate = transcoding?.Bitrate, - VideoCodec = transcoding?.VideoCodec, - AudioCodec = transcoding?.AudioCodec, - Container = transcoding?.Container, - IsVideoDirect = transcoding is null ? null : transcoding.IsVideoDirect, - IsAudioDirect = transcoding is null ? null : transcoding.IsAudioDirect, - TranscodeReasons = transcoding is null ? null : transcoding.TranscodeReasons.ToString() - }); - } - - result.Add(new GroupStatsDto - { - GroupId = groupId, - Members = members - }); + result.Add(BuildGroupStats(groupId, deviceIds, sessionsByDevice)); } return result; } + /// + /// Gets the live stats for the calling device's own SyncPlay group only. Unlike + /// 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 ) polls, and regular + /// viewers aren't admins. + /// + /// The caller's group stats, with Group null when not in a group. + [HttpGet("Mine")] + [Authorize] + public ActionResult 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 GetSessionsByDevice() + => _sessionManager.Sessions + .Where(s => !string.IsNullOrEmpty(s.DeviceId)) + .GroupBy(s => s.DeviceId) + .ToDictionary(g => g.Key, g => g.First()); + + private static GroupStatsDto BuildGroupStats( + Guid groupId, + IReadOnlyCollection deviceIds, + Dictionary sessionsByDevice) + { + var members = new List(); + long? laggardTicks = null; + + foreach (var deviceId in deviceIds) + { + if (!sessionsByDevice.TryGetValue(deviceId, out var session)) + { + continue; + } + + var positionTicks = session.PlayState?.PositionTicks; + if (positionTicks is not null && !(session.PlayState?.IsPaused ?? false)) + { + laggardTicks = laggardTicks is null ? positionTicks : Math.Min(laggardTicks.Value, positionTicks.Value); + } + } + + foreach (var deviceId in deviceIds) + { + if (!sessionsByDevice.TryGetValue(deviceId, out var session)) + { + members.Add(new MemberStatsDto { DeviceId = deviceId, Connected = false }); + continue; + } + + var positionTicks = session.PlayState?.PositionTicks; + var transcoding = session.TranscodingInfo; + + members.Add(new MemberStatsDto + { + DeviceId = deviceId, + Connected = true, + DeviceName = session.DeviceName, + UserName = session.UserName, + NowPlaying = session.NowPlayingItem?.Name, + PositionTicks = positionTicks, + PositionFormatted = positionTicks is null ? null : TimeSpan.FromTicks(positionTicks.Value).ToString(@"hh\:mm\:ss"), + DriftMs = positionTicks is null || laggardTicks is null + ? null + : (positionTicks.Value - laggardTicks.Value) / TimeSpan.TicksPerMillisecond, + IsPaused = session.PlayState?.IsPaused, + PlayMethod = session.PlayState?.PlayMethod?.ToString(), + Bitrate = transcoding?.Bitrate, + VideoCodec = transcoding?.VideoCodec, + AudioCodec = transcoding?.AudioCodec, + Container = transcoding?.Container, + IsVideoDirect = transcoding is null ? null : transcoding.IsVideoDirect, + IsAudioDirect = transcoding is null ? null : transcoding.IsAudioDirect, + TranscodeReasons = transcoding is null ? null : transcoding.TranscodeReasons.ToString() + }); + } + + return new GroupStatsDto + { + GroupId = groupId, + Members = members + }; + } + /// /// One-shot: seeks the calling device's own session to match whoever in its SyncPlay /// group is furthest behind (never the other direction -- if the caller is themselves @@ -385,6 +434,112 @@ public class SyncPlusStatsController : ControllerBase """; + // 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