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:
@@ -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><script></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>
|
||||||
|
|||||||
Reference in New Issue
Block a user