5 Commits
Author SHA1 Message Date
seer 1052ac5fa5 Fix release workflow's manifest-commit checkout and correct 0.3.0.0 checksum
Build Check / build (push) Successful in 21s
The "Commit updated manifest to master" step left manifest.json dirty
(modified during the tag checkout by package-release.sh) and then tried to
git checkout master, which git correctly refused -- that's the CI failure
from the v0.3.0.0 tag push. Discard the working-tree manifest.json before
switching branches, matching the /tmp copy-then-restore pattern already used
for carrying the update across.

Also corrects the 0.3.0.0 manifest entry itself: it had been hand-edited
locally with 0.2.0.0's leftover checksum instead of the real zip's hash, and
had dropped the 0.2.0.0 history entry entirely instead of appending. Rebuilt
via update_manifest.py against origin/master's manifest.json using the
actual checksum of the already-uploaded v0.3.0.0 release asset.
2026-07-10 20:19:58 +02:00
seer 5015ffe2b4 FIx versioning
Release Plugin / release (push) Failing after 19s
2026-07-10 14:13:17 +02:00
seerandClaude Fable 5 697b934108 Collapse cog menu to a single item to stop the sheet overflowing
Release Plugin / release (push) Failing after 21s
Two injected menu items made the settings action sheet tall enough to
run off the bottom of the screen (jellyfin measures/positions the sheet
before our async injection adds height). Now inject just one item,
"SyncPlay stats", and move the sync button back into the panel it opens
alongside the drift readout -- so the sheet barely grows and both
actions live in our own overflow-proof UI.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 14:06:57 +02:00
seerandClaude Fable 5 64a30bfa64 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>
2026-07-10 14:01:49 +02:00
seerandClaude Fable 5 268a6ed2d0 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>
2026-07-10 13:55:51 +02:00
8 changed files with 448 additions and 65 deletions
+1
View File
@@ -78,6 +78,7 @@ jobs:
VERSION: ${{ steps.meta.outputs.VERSION }}
run: |
cp manifest.json /tmp/manifest.json
git checkout -- manifest.json
git fetch origin master
git checkout master
cp /tmp/manifest.json manifest.json
+42
View File
@@ -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
`<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)
+2 -2
View File
@@ -1,6 +1,6 @@
name: "JellyfinSyncPlus"
guid: "267dbfe9-bb9c-4eeb-97aa-f0449283cfe6"
version: "0.2.0.0"
version: "0.3.0.0"
targetAbi: "10.11.6.0"
framework: "net9.0"
owner: "cynic"
@@ -16,6 +16,6 @@ category: "General"
artifacts:
- "JellyfinSyncPlus.dll"
changelog: >
0.2.0.0: SyncPlay stats-for-nerds page and manual one-shot "sync me to
0.3.0.0: SyncPlay stats-for-nerds page and manual one-shot "sync me to
group" button. No automatic playback intervention.
0.1.0.0: Phase 0 scaffold. Trivial plugin, no functionality yet.
+2 -2
View File
@@ -3,13 +3,13 @@
# .github/workflows/release.yml; safe to run locally too for a dry run.
#
# Required env vars:
# VERSION -- e.g. 0.2.0.0 (must match build.yaml's version)
# VERSION -- e.g. 0.3.0.0 (must match build.yaml's version)
# SERVER_URL -- e.g. https://gitea.mrcynic.site
# REPO_OWNER -- Gitea org/user the repo lives under
# REPO_NAME -- Gitea repo name
set -euo pipefail
: "${VERSION:?VERSION env var required, e.g. 0.2.0.0}"
: "${VERSION:?VERSION env var required, e.g. 0.3.0.0}"
: "${SERVER_URL:?SERVER_URL env var required, e.g. https://gitea.mrcynic.site}"
: "${REPO_OWNER:?REPO_OWNER env var required}"
: "${REPO_NAME:?REPO_NAME env var required}"
+8
View File
@@ -7,6 +7,14 @@
"owner": "cynic",
"category": "General",
"versions": [
{
"version": "0.3.0.0",
"changelog": "0.3.0.0: SyncPlay stats-for-nerds page and manual one-shot \"sync me to group\" button. No automatic playback intervention. 0.1.0.0: Phase 0 scaffold. Trivial plugin, no functionality yet.",
"targetAbi": "10.11.6.0",
"sourceUrl": "https://gitea.mrcynic.site/seer/JellyfinSyncPlus/releases/download/v0.3.0.0/JellyfinSyncPlus_0.3.0.0.zip",
"checksum": "16b354299e9f6b0900f393045339a93a",
"timestamp": "2026-07-10T18:19:38Z"
},
{
"version": "0.2.0.0",
"changelog": "0.2.0.0: SyncPlay stats-for-nerds page and manual one-shot \"sync me to group\" button. No automatic playback intervention. 0.1.0.0: Phase 0 scaffold. Trivial plugin, no functionality yet.",
@@ -2,6 +2,7 @@ using Jellyfin.Plugin.SyncPlus.SyncPlay;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Plugins;
using MediaBrowser.Controller.SyncPlay;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.DependencyInjection;
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.
serviceCollection.AddSingleton<ConcreteSyncPlayManager>();
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");
}
/// <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>
/// Gets a live snapshot of every active SyncPlay group's members and their drift
/// relative to whoever in the group is furthest behind.
@@ -74,13 +90,49 @@ public class SyncPlusStatsController : ControllerBase
[Authorize(Policy = Policies.RequiresElevation)]
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))
.GroupBy(s => s.DeviceId)
.ToDictionary(g => g.Key, g => g.First());
var result = new List<GroupStatsDto>();
foreach (var (groupId, deviceIds) in _membershipStore.GetAllGroups())
private static GroupStatsDto BuildGroupStats(
Guid groupId,
IReadOnlyCollection<string> deviceIds,
Dictionary<string, SessionInfo> sessionsByDevice)
{
var members = new List<MemberStatsDto>();
long? laggardTicks = null;
@@ -134,14 +186,11 @@ public class SyncPlusStatsController : ControllerBase
});
}
result.Add(new GroupStatsDto
return new GroupStatsDto
{
GroupId = groupId,
Members = members
});
}
return result;
};
}
/// <summary>
@@ -385,6 +434,164 @@ public class SyncPlusStatsController : ControllerBase
</html>
""";
// 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 a single native-looking menu
// item ("SyncPlay stats") that opens our own floating panel holding both the drift
// readout and the sync button. Just one item on purpose -- the sheet is measured and
// positioned before our async injection runs, so every added item risks pushing it
// off the bottom of the screen (which is exactly what happened with two). 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 client, including the sheet's own delegated click handler
// that auto-closes it on any .actionSheetMenuItem click). If a future jellyfin-web
// rename breaks these, the failure mode is just "the item doesn't appear" -- nothing
// errors, and the standalone stats page still works.
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 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,.8);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>' +
'<button id="syncPlusSyncBtn" style="margin-top:10px;background:#00a4dc;color:#fff;border:none;border-radius:3px;padding:6px 12px;cursor:pointer;font-size:12px;">Sync me to group</button>' +
'<div id="syncPlusResult" style="margin-top:6px;color:#999;font-size:12px;min-height:1em;"></div>';
document.body.appendChild(panel);
var rows = panel.querySelector('#syncPlusRows');
var result = panel.querySelector('#syncPlusResult');
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'; }
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; });
}
function togglePanel() {
if (panel.style.display !== 'none') { closePanel(); return; }
result.textContent = '';
panel.style.display = 'block';
refresh();
pollHandle = setInterval(refresh, 1000);
}
panel.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; });
});
// 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 a single item. Just one
// item (both stats + the sync button live in our own panel it opens) keeps the sheet
// from growing tall enough to overflow off-screen -- jellyfin measures and positions
// the sheet before our async injection runs, so every added item risks pushing content
// past the viewport bottom. The sheet's own delegated click handler closes it on any
// .actionSheetMenuItem click, so our item gets native close behavior for free.
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-stats"]')) { continue; }
var scroller = sheet.querySelector('.actionSheetScroller');
if (!scroller) { continue; }
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 () {
if (!document.querySelector('video') && panel.style.display !== 'none') {
closePanel();
}
}, 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>
/// Result of a <see cref="SyncMe"/> call.
/// </summary>