Scaffold Emby Watch Party plugin

Design: coordinate parties on top of Emby's existing session
remote-control commands (SendPlaystateCommand) rather than building a
new player. Each member keeps their own Emby app/controls; the server
periodically corrects drift and mirrors pause/unpause against one
reference member's reported position.

- src/WatchParty: plugin skeleton (BasePlugin, IServerEntryPoint,
  IService/[Route] API, in-memory party store, drift-correction loop)
- web/control-panel: plain HTML/JS/CSS companion page to create/join
  parties and resync, served by the plugin itself
- docs/architecture.md: full design, sources consulted, and an
  explicit list of SDK details that still need verifying against a
  real build (no .NET SDK / Emby SDK assemblies available in this
  environment to compile against)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Nicolas 2026-09-13 14:02:43 +02:00
commit 66af05f259
17 changed files with 1610 additions and 0 deletions

22
.github/workflows/build.yml vendored Normal file
View file

@ -0,0 +1,22 @@
name: Build
on:
push:
branches: [main]
pull_request:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: "8.0.x"
- name: Restore
run: dotnet restore src/WatchParty/WatchParty.csproj
- name: Build
run: dotnet build src/WatchParty/WatchParty.csproj --configuration Release --no-restore

17
.gitignore vendored Normal file
View file

@ -0,0 +1,17 @@
## .NET
bin/
obj/
*.user
*.suo
.vs/
## Rider / VS Code
.idea/
*.swp
## Node (only relevant if control-panel tooling is ever added)
node_modules/
## OS cruft
.DS_Store
Thumbs.db

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Nicolas
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

85
README.md Normal file
View file

@ -0,0 +1,85 @@
# Watch Party for Emby
An Emby Server plugin that lets a group of users on the same server watch the
same title together. Nobody switches players: everyone keeps using their
normal Emby app (web, mobile, TV, whatever), and the plugin nudges playback
back in sync using the same remote-control commands Emby already uses for
casting / "play on another device".
See [docs/architecture.md](docs/architecture.md) for the full design,
including which parts are solidly confirmed against Emby's SDK docs and which
still need verification against your exact server build.
## How it's meant to work
1. You start playing a title in your normal Emby app.
2. You open the Watch Party control panel (a small web page the plugin
serves) and start a party. You get a 6-character join code.
3. Friends open the control panel, sign in with their own Emby account, start
playing the *same* title in their own Emby app, and enter the code to join.
4. The server treats one member (the host, by default) as the reference
position, and periodically nudges everyone else's session back in sync
(seek correction, and mirroring pause/unpause) — see
[docs/architecture.md](docs/architecture.md#sync-algorithm).
The control panel is a companion page, not a video player — it never touches
the actual video stream.
## Status
Early scaffold, not yet built or run against a real server. It's structured
around Emby's documented plugin patterns (`BasePlugin<T>`, `IServerEntryPoint`,
the `IService`/`[Route]` API style) and the confirmed
`ISessionManager.SendPlaystateCommand` remote-control API, but several SDK
details are marked `TODO(verify)` in the code — see
[docs/architecture.md](docs/architecture.md#assumptions-to-verify) before
expecting it to compile as-is.
## Requirements
- Emby Server (developed against 4.9.5.0; should work on nearby 4.9.x/4.8.x
builds, but the plugin ABI does shift between versions)
- [.NET SDK](https://dotnet.microsoft.com/download) to build (not currently
installed in this environment — `dotnet --version` came back empty)
## Project layout
```
src/WatchParty/ the plugin itself (C#, netstandard2.0)
Plugin.cs plugin registration + admin settings page
Configuration/ plugin settings + the dashboard config page HTML
Parties/ Party / PartyMember model + in-memory PartyManager
Sync/ the drift-correction background loop
Api/ REST endpoints + serving the control panel's static files
web/control-panel/ the user-facing page (plain HTML/JS/CSS, no build step)
docs/architecture.md full design doc
```
## Building
```bash
dotnet build src/WatchParty/WatchParty.csproj
```
To have a build drop the DLL straight into a local Emby Server's plugin
folder, set `EMBY_PLUGINS_DIR` before building, e.g.:
```bash
EMBY_PLUGINS_DIR="C:/ProgramData/Emby-Server/programdata/plugins" dotnet build src/WatchParty/WatchParty.csproj
```
Adjust the path for however your server is installed (Windows service vs.
portable install vs. Docker volume).
## Roadmap
- [x] Design doc + scaffold
- [ ] Build against the real SDK, fix whatever the `TODO(verify)` spots got wrong
- [ ] Try it against a real Emby Server + two clients
- [ ] Live (WebSocket) updates in the control panel instead of polling
- [ ] Optional text chat, reusing the same realtime channel
- [ ] Per-party override of sync settings instead of only server-wide defaults
## License
MIT — see [LICENSE](LICENSE).

202
docs/architecture.md Normal file
View file

@ -0,0 +1,202 @@
# Architecture
## Goals
- Watch the same title, at (roughly) the same time, as other users on the
same Emby server.
- Don't require anyone to change how they watch — no new client, no browser
extension, no modified apps. People keep using whatever Emby app they
already use.
- Ship as a single Emby Server plugin.
## Non-goals (for v1)
- Frame-accurate sync. A few seconds of drift between corrections is
acceptable — that's inherent to the "generic remote control" approach (see
below), and is a conscious tradeoff, not an oversight.
- A shared/forced "host controls everyone" model. The chosen model (see
"Control model" below) is: everyone keeps their own play/pause/seek
controls; the party only shares a reference position to resync against.
- Cross-server parties, chat, reactions. Possible later, not in v1 — see
Roadmap in the README.
## The key idea: reuse Emby's existing remote-control commands
Emby already has an authoritative-session model for "cast" / "remote control
another device" style features: `ISessionManager` tracks every active
session's playback state, and a session can be commanded via
`SendPlaystateCommand` (`Play`, `Pause`, `Unpause`, `Seek`, ...) or
`SendGeneralCommand`. This already exists in Emby Server and, to whatever
extent each client supports being remote-controlled at all (most official
clients do, since it's how casting/remote-play works), in the clients too.
So instead of building a new synchronized player, this plugin is mostly a
coordinator sitting on top of that existing mechanism:
1. Track which sessions belong to which "party".
2. Watch each member's reported position/pause state (the same data Emby's
own "Now Playing" / dashboard already reads).
3. When a member drifts too far from the party's reference position, or its
pause state disagrees with the reference, send the same kind of
Seek/Pause/Unpause command Emby would send for ordinary remote control.
Sources consulted while designing this:
- [How to build a Server Plugin](https://github.com/MediaBrowser/Emby/wiki/How-to-build-a-Server-Plugin) — project setup, `BasePlugin<T>`, `IServerEntryPoint`.
- [Creating Api Endpoints](https://github.com/MediaBrowser/Emby/wiki/Creating-Api-Endpoints) — the `IService`/`[Route]`/`IReturn<T>` REST pattern.
- [Emby plugin dev docs](https://dev.emby.media/doc/plugins/dev/index.html) — confirms `netstandard2.0`, .NET Core/Mono runtimes.
- [`MediaBrowser.Server.Core` on NuGet](https://www.nuget.org/packages/mediabrowser.server.core) — package to reference; latest published at research time was `4.9.1.90`.
- Emby community thread on [remote-controlling seek](https://emby.media/community/topic/119432-emby-remote-control-another-client-to-seek-tracks/), and Jellyfin's (Emby-derived) [`SessionManager.cs`](https://github.com/jellyfin/jellyfin/blob/master/Emby.Server.Implementations/Session/SessionManager.cs) — shape of `SendPlaystateCommand(controllingSessionId, sessionId, PlaystateRequest, CancellationToken)` and `PlaystateRequest { Command, SeekPositionTicks, ControllingUserId }`.
## Control model
Design decision: **each participant keeps their own player and controls; the
party only shares a reference position to resync against.** Concretely:
- Nobody's play/pause/seek is disabled. You control your own playback
exactly like you always do.
- One member is the *reference* (the host, by default — falls back to
whoever's left if the host leaves). The server treats the reference
session's reported position/pause state as ground truth for the party.
- Other members get corrected against the reference:
- **Pause/unpause is mirrored immediately** (configurable —
`SyncPausePlay`, on by default): if the reference pauses, everyone gets a
`Pause` command; if it unpauses, everyone gets `Unpause`.
- **Position drift beyond a threshold is corrected with a `Seek`**, on a
cooldown so a shaky connection doesn't cause a stream of seek commands
(`DriftThresholdSeconds`, `ResyncCooldownSeconds`).
- Anyone can also hit **"Resync me now"** in the control panel for an
immediate correction, bypassing the cooldown.
This deliberately does *not* try to stop someone from pausing or seeking
locally — it just keeps pulling things back toward the reference. That fits
"loose sync via existing remote-control commands" much better than trying to
fight every local action.
## Sync algorithm
Runs on a timer (`SyncTickSeconds`, default 5s) inside `PartySyncEntryPoint`,
per active party:
```
reference = party.Members[party.ReferenceUserId]
referencePos = reference.EstimatedPositionTicks(now) # extrapolated if playing
for member in party.Members, excluding reference:
if SyncPausePlay and member.IsPaused != reference.IsPaused:
send Pause/Unpause to member's session
continue
if now - member.LastResyncUtc < ResyncCooldownSeconds:
continue # give the last correction time to take effect
drift = abs(member.EstimatedPositionTicks(now) - referencePos)
if drift > DriftThresholdSeconds:
send Seek(referencePos) to member's session
```
`EstimatedPositionTicks` extrapolates from the last known position using
elapsed wall-clock time when playing, so members aren't marked "drifted" just
because their last progress event was a few seconds ago.
Position/pause state itself comes from Emby's own session events
(`ISessionManager.PlaybackStart` / `PlaybackProgress` / `PlaybackStopped`) —
the plugin doesn't poll clients itself.
## Which session is "mine"?
The trickiest part of gluing "a user" to "a specific playback session",
since one user can have several active sessions (phone + TV + browser tab).
v1's blunt heuristic (`WatchPartyService.FindCallerSession`): when you
create/join a party, the server looks at your currently active sessions,
keeps the ones actually playing something (and, when joining, playing the
*same item* as the party), and picks the most recently active one. Good
enough for "one person, one device, actively watching" — the expected case —
but it's a real limitation if you're doing something else on a second device
at the same time. Documented here rather than hidden.
## Components
```
src/WatchParty/
Plugin.cs BasePlugin<PluginConfiguration> + admin settings page
Configuration/ PluginConfiguration (defaults) + configPage.html (dashboard UI)
Parties/
Party.cs party id, join code, item id, host/reference user, members
PartyMember.cs per-member session id + last known position/pause + EstimatedPositionTicks
PartyManager.cs in-memory store (ConcurrentDictionary); create/join/leave/lookup
Sync/
PartySyncEntryPoint.cs IServerEntryPoint: session event wiring + the tick loop above
Api/
WatchPartyService.cs REST endpoints (create/join/leave/get/resync) +
serves web/control-panel's static files
web/control-panel/ plain HTML/JS/CSS control panel (sign in, create/join,
member list with a sync-status dot, resync/leave)
```
Parties are **in-memory only** — a server restart clears them. That's fine
for v1: a party is meant to live for one watch session, not persist for
weeks.
The control panel is served *by the plugin itself* (`GET /WatchParty/app`,
`/app.js`, `/app.css`, reading embedded resources) rather than injected into
Emby's main web client. Emby doesn't document a stable way to inject a
user-facing (non-admin) page/script into the main web client, and hand-patching
the client's install directory is a well-known but fragile hack that breaks
on every Emby update — not worth it for v1. The tradeoff: opening the control
panel is a separate step from opening Emby, rather than a button inside the
Emby UI. It authenticates with your normal Emby username/password (via Emby's
standard `/Users/AuthenticateByName`) and only ever talks to your own server.
## Assumptions to verify
This was designed by reading Emby's public docs/wiki and a couple of
community plugins (linked above), without a local copy of the SDK to compile
against (no .NET SDK was installed in the environment this was scaffolded in,
and the exact `MediaBrowser.Server.Core` build for Emby Server 4.9.5.0 wasn't
available to inspect directly). Treat the overall shape as solid and these
specifics as needing a compile-and-fix pass:
- **`IHasWebPages` vs `IHasUIPages`** — different community plugins use
different names depending on SDK era; `Plugin.cs` assumes `IHasWebPages`.
- **`PluginPageInfo` property names** (`Name`, `EmbeddedResourcePath`).
- **ID types** — whether item/user/session IDs are `Guid` or `string` on your
SDK version (this has changed across Emby versions). `Party`/`PartyMember`
assume `Guid`.
- **How an `IService` reads the calling user** — `WatchPartyService` assumes
constructor-injected `IAuthorizationContext` + `IRequiresRequest.Request`,
then `GetAuthorizationInfo(Request).UserId`.
- **HTTP error mapping** — endpoints currently throw plain
`ArgumentException`/`InvalidOperationException`; Emby likely has a more
specific exception type (something like `ResourceNotFoundException`) that
maps to a proper 404/400 instead of a generic 500.
- **Static file content types** — `GET /WatchParty/app` etc. currently return
a bare string. The wiki confirms an `IHasResultFactory` extension point
exists for setting a real `Content-Type`/caching headers; wiring that up is
a follow-up rather than done here.
- **Logging API shape** — `ILogManager.GetLogger(name)` /
`ILogger.Debug(...)` / `.ErrorException(...)` is the classic
Emby/MediaBrowser pattern; some SDK generations may expect
`Microsoft.Extensions.Logging` instead.
- **`X-Emby-Authorization` header fields and `/Users/AuthenticateByName`'s
`Pw` field name** in `web/control-panel/app.js` — long-standing Emby/
Jellyfin convention, not re-verified against 4.9.5.0 specifically.
The fastest way to nail all of these down: install the .NET SDK, `dotnet
restore` the project (pulls the real `MediaBrowser.Server.Core` /
`MediaBrowser.Common` assemblies from NuGet), and let the compiler point at
whatever doesn't match.
## Known limitations
- Sync is best-effort and loose by design (see "Non-goals"). Don't expect
frame-accuracy.
- Depends on the member's *own* client actually honoring remote Seek/Pause
commands smoothly — most official Emby apps do (it's how casting/remote
control works today), but behavior can vary by platform, and a Seek can
cause a visible re-buffer.
- One item per party; no shared "up next" queue.
- No chat/reactions in v1 (kept out on purpose — see the design conversation
that led to this repo).
- A user with two simultaneously active sessions on the same item is
resolved by "most recently active", which can guess wrong.

View file

@ -0,0 +1,239 @@
using System;
using System.IO;
using System.Linq;
using System.Threading;
using MediaBrowser.Controller.Session;
using MediaBrowser.Model.Services;
using MediaBrowser.Model.Session;
using WatchParty.Parties;
namespace WatchParty.Api
{
// ---------------------------------------------------------------------------
// The [Route]/IService/IReturn<T> pattern here is confirmed against Emby's
// official wiki: https://github.com/MediaBrowser/Emby/wiki/Creating-Api-Endpoints
//
// Less certain - see docs/architecture.md#assumptions-to-verify:
// - How a service reads the calling user (assumed here: constructor-injected
// IAuthorizationContext + IRequiresRequest.Request, then
// GetAuthorizationInfo(Request).UserId).
// - Exact HTTP-error exception types (using plain ArgumentException /
// InvalidOperationException below as a placeholder).
// - Content-Type on the static file responses (returning bare strings for
// now; IHasResultFactory looks like the right extension point later).
// ---------------------------------------------------------------------------
[Route("/WatchParty/Parties", "POST")]
public class CreatePartyRequest : IReturn<PartyStateDto>
{
}
[Route("/WatchParty/Parties/{JoinCode}/Join", "POST")]
public class JoinPartyRequest : IReturn<PartyStateDto>
{
public string JoinCode { get; set; }
}
[Route("/WatchParty/Parties/{PartyId}/Leave", "POST")]
public class LeavePartyRequest : IReturnVoid
{
public Guid PartyId { get; set; }
}
[Route("/WatchParty/Parties/{PartyId}", "GET")]
public class GetPartyRequest : IReturn<PartyStateDto>
{
public Guid PartyId { get; set; }
}
[Route("/WatchParty/Parties/{PartyId}/Resync", "POST")]
public class ResyncRequest : IReturnVoid
{
public Guid PartyId { get; set; }
}
[Route("/WatchParty/app", "GET")]
public class GetControlPanelHtml : IReturn<string>
{
}
[Route("/WatchParty/app.js", "GET")]
public class GetControlPanelJs : IReturn<string>
{
}
[Route("/WatchParty/app.css", "GET")]
public class GetControlPanelCss : IReturn<string>
{
}
public class PartyStateDto
{
public Guid PartyId { get; set; }
public string JoinCode { get; set; }
public Guid ItemId { get; set; }
public Guid HostUserId { get; set; }
public Guid ReferenceUserId { get; set; }
public PartyMemberDto[] Members { get; set; }
}
public class PartyMemberDto
{
public Guid UserId { get; set; }
public string UserName { get; set; }
public bool IsPaused { get; set; }
public long PositionTicks { get; set; }
public bool IsReference { get; set; }
}
public class WatchPartyService : IService, IRequiresRequest
{
private readonly ISessionManager _sessionManager;
private readonly IAuthorizationContext _authContext;
public IRequest Request { get; set; }
public WatchPartyService(ISessionManager sessionManager, IAuthorizationContext authContext)
{
_sessionManager = sessionManager;
_authContext = authContext;
}
public object Post(CreatePartyRequest request)
{
var userId = CallerUserId();
var session = FindCallerSession(userId, null);
var party = PartyManager.Instance.Create(userId, session.UserName, session.Id, session.NowPlayingItem.Id);
return ToDto(party);
}
public object Post(JoinPartyRequest request)
{
var userId = CallerUserId();
var party = PartyManager.Instance.GetByJoinCode(request.JoinCode);
if (party == null)
{
throw new InvalidOperationException("No party with that code (it may have ended).");
}
var session = FindCallerSession(userId, party.ItemId);
PartyManager.Instance.Join(party, userId, session.UserName, session.Id);
return ToDto(party);
}
public void Post(LeavePartyRequest request)
{
var party = PartyManager.Instance.GetById(request.PartyId);
if (party != null)
{
PartyManager.Instance.Leave(party, CallerUserId());
}
}
public object Get(GetPartyRequest request)
{
var party = PartyManager.Instance.GetById(request.PartyId);
if (party == null)
{
throw new InvalidOperationException("Party not found (it may have ended).");
}
return ToDto(party);
}
public void Post(ResyncRequest request)
{
var party = PartyManager.Instance.GetById(request.PartyId);
if (party == null)
{
return;
}
var userId = CallerUserId();
if (!party.Members.TryGetValue(userId, out var member) || string.IsNullOrEmpty(member.SessionId))
{
return;
}
if (!party.Members.TryGetValue(party.ReferenceUserId, out var reference))
{
return;
}
// Immediate one-off correction, bypassing the normal cooldown: the
// user explicitly asked to be put back in sync right now.
_sessionManager.SendPlaystateCommand(null, member.SessionId, new PlaystateRequest
{
Command = PlaystateCommand.Seek,
SeekPositionTicks = reference.EstimatedPositionTicks(DateTime.UtcNow)
}, CancellationToken.None);
}
public object Get(GetControlPanelHtml request) => ReadEmbedded("index.html");
public object Get(GetControlPanelJs request) => ReadEmbedded("app.js");
public object Get(GetControlPanelCss request) => ReadEmbedded("app.css");
private Guid CallerUserId()
{
return _authContext.GetAuthorizationInfo(Request).UserId;
}
private SessionInfo FindCallerSession(Guid userId, Guid? expectedItemId)
{
var candidate = _sessionManager.Sessions
.Where(s => s.UserId == userId && s.NowPlayingItem != null)
.Where(s => expectedItemId == null || s.NowPlayingItem.Id == expectedItemId)
.OrderByDescending(s => s.LastActivityDate)
.FirstOrDefault();
if (candidate == null)
{
throw new ArgumentException(expectedItemId == null
? "Start playing something in Emby first, then create the party."
: "Start playing the same title in Emby first, then join the party.");
}
return candidate;
}
private static string ReadEmbedded(string logicalName)
{
var assembly = typeof(WatchPartyService).Assembly;
using (var stream = assembly.GetManifestResourceStream("WatchParty.web." + logicalName))
{
if (stream == null)
{
return string.Empty;
}
using (var reader = new StreamReader(stream))
{
return reader.ReadToEnd();
}
}
}
private static PartyStateDto ToDto(Party party)
{
var now = DateTime.UtcNow;
return new PartyStateDto
{
PartyId = party.Id,
JoinCode = party.JoinCode,
ItemId = party.ItemId,
HostUserId = party.HostUserId,
ReferenceUserId = party.ReferenceUserId,
Members = party.Members.Values.Select(m => new PartyMemberDto
{
UserId = m.UserId,
UserName = m.UserName,
IsPaused = m.LastKnownIsPaused,
PositionTicks = m.EstimatedPositionTicks(now),
IsReference = m.UserId == party.ReferenceUserId
}).ToArray()
};
}
}
}

View file

@ -0,0 +1,40 @@
using MediaBrowser.Model.Plugins;
namespace WatchParty.Configuration
{
/// <summary>
/// Server-wide default settings for the plugin. Individual parties don't
/// currently override any of these - see docs/architecture.md roadmap.
/// </summary>
public class PluginConfiguration : BasePluginConfiguration
{
/// <summary>
/// If a member's estimated position drifts from the party's reference
/// position by more than this many seconds, the server sends a
/// corrective Seek command.
/// </summary>
public int DriftThresholdSeconds { get; set; } = 5;
/// <summary>
/// Minimum time between automatic corrective commands sent to the same
/// member, so a shaky connection doesn't cause a stream of seek commands.
/// </summary>
public int ResyncCooldownSeconds { get; set; } = 15;
/// <summary>How often the background sync loop re-evaluates every active party.</summary>
public int SyncTickSeconds { get; set; } = 5;
/// <summary>
/// When true, a Pause/Unpause on the reference session is immediately
/// mirrored to every other member's session. When false, pause state is
/// left alone and only position drift is corrected.
/// </summary>
public bool SyncPausePlay { get; set; } = true;
/// <summary>
/// How long an idle party (no member activity) is kept around before
/// being garbage-collected from memory.
/// </summary>
public int PartyIdleTimeoutMinutes { get; set; } = 240;
}
}

View file

@ -0,0 +1,85 @@
<div id="watchPartyConfigPage" data-role="page" class="page type-interior pluginConfigurationPage" data-require="emby-input,emby-button,emby-checkbox">
<div data-role="content">
<div class="content-primary">
<form id="watchPartyConfigForm">
<div class="verticalSection">
<h2>Watch Party</h2>
<p>Defaults used for every party. See the project's docs/architecture.md for what each option does.</p>
</div>
<div class="inputContainer">
<label class="inputLabel" for="driftThresholdSeconds">Drift threshold (seconds)</label>
<input is="emby-input" type="number" id="driftThresholdSeconds" min="1" />
</div>
<div class="inputContainer">
<label class="inputLabel" for="resyncCooldownSeconds">Resync cooldown (seconds)</label>
<input is="emby-input" type="number" id="resyncCooldownSeconds" min="1" />
</div>
<div class="inputContainer">
<label class="inputLabel" for="syncTickSeconds">Sync tick interval (seconds)</label>
<input is="emby-input" type="number" id="syncTickSeconds" min="1" />
</div>
<div class="checkboxContainer">
<label>
<input is="emby-checkbox" type="checkbox" id="syncPausePlay" />
<span>Mirror pause/unpause to the whole party</span>
</label>
</div>
<div class="inputContainer">
<label class="inputLabel" for="partyIdleTimeoutMinutes">Idle party timeout (minutes)</label>
<input is="emby-input" type="number" id="partyIdleTimeoutMinutes" min="1" />
</div>
<button is="emby-button" type="submit" class="raised button-submit block">
<span>Save</span>
</button>
</form>
</div>
</div>
<script type="text/javascript">
// Standard Emby dashboard plugin-config wiring (ApiClient.getPluginConfiguration /
// updatePluginConfiguration, Dashboard.show/hideLoadingMsg). TODO(verify) against
// your SDK/dashboard version if this page doesn't load or save correctly -
// see docs/architecture.md#assumptions-to-verify.
(function () {
var pluginId = "5b6a1e2c-6e3a-4b8b-9c1a-1f7a2c9d4e10"; // must match Plugin.Id
function view() { return document.querySelector('#watchPartyConfigPage'); }
document.addEventListener('pageshow', function (e) {
if (!e.target || e.target.id !== 'watchPartyConfigPage') return;
Dashboard.showLoadingMsg();
ApiClient.getPluginConfiguration(pluginId).then(function (config) {
view().querySelector('#driftThresholdSeconds').value = config.DriftThresholdSeconds;
view().querySelector('#resyncCooldownSeconds').value = config.ResyncCooldownSeconds;
view().querySelector('#syncTickSeconds').value = config.SyncTickSeconds;
view().querySelector('#syncPausePlay').checked = config.SyncPausePlay;
view().querySelector('#partyIdleTimeoutMinutes').value = config.PartyIdleTimeoutMinutes;
Dashboard.hideLoadingMsg();
});
});
document.addEventListener('submit', function (e) {
if (!e.target || e.target.id !== 'watchPartyConfigForm') return;
e.preventDefault();
Dashboard.showLoadingMsg();
ApiClient.getPluginConfiguration(pluginId).then(function (config) {
config.DriftThresholdSeconds = parseInt(view().querySelector('#driftThresholdSeconds').value, 10);
config.ResyncCooldownSeconds = parseInt(view().querySelector('#resyncCooldownSeconds').value, 10);
config.SyncTickSeconds = parseInt(view().querySelector('#syncTickSeconds').value, 10);
config.SyncPausePlay = view().querySelector('#syncPausePlay').checked;
config.PartyIdleTimeoutMinutes = parseInt(view().querySelector('#partyIdleTimeoutMinutes').value, 10);
ApiClient.updatePluginConfiguration(pluginId, config).then(function (result) {
Dashboard.processPluginConfigurationUpdateResult(result);
});
});
return false;
});
})();
</script>
</div>

View file

@ -0,0 +1,35 @@
using System;
using System.Collections.Concurrent;
namespace WatchParty.Parties
{
/// <summary>
/// A watch party: a join code, the item everyone's meant to be watching,
/// and the set of members being kept in sync. See docs/architecture.md.
/// </summary>
public class Party
{
public Guid Id { get; set; } = Guid.NewGuid();
/// <summary>Short human-typeable code used to join, e.g. "K7QX2P".</summary>
public string JoinCode { get; set; }
/// <summary>The library item everyone is meant to be watching.</summary>
public Guid ItemId { get; set; }
public Guid HostUserId { get; set; }
/// <summary>
/// Whose reported position is treated as ground truth. Defaults to the
/// host, but falls back to whoever's left if the host leaves (see
/// PartyManager.Leave).
/// </summary>
public Guid ReferenceUserId { get; set; }
public DateTime CreatedUtc { get; set; } = DateTime.UtcNow;
public ConcurrentDictionary<Guid, PartyMember> Members { get; } = new ConcurrentDictionary<Guid, PartyMember>();
public DateTime LastActivityUtc { get; set; } = DateTime.UtcNow;
}
}

View file

@ -0,0 +1,133 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
namespace WatchParty.Parties
{
/// <summary>
/// In-memory store of active parties. Nothing is persisted to disk - a server
/// restart clears every party, which is fine for v1 (a party is meant to live
/// for one watch session, not persist for weeks). See docs/architecture.md.
/// </summary>
public class PartyManager
{
private static readonly Lazy<PartyManager> LazyInstance =
new Lazy<PartyManager>(() => new PartyManager());
public static PartyManager Instance => LazyInstance.Value;
private readonly ConcurrentDictionary<Guid, Party> _partiesById = new ConcurrentDictionary<Guid, Party>();
private readonly ConcurrentDictionary<string, Guid> _partyIdByJoinCode =
new ConcurrentDictionary<string, Guid>(StringComparer.OrdinalIgnoreCase);
public Party Create(Guid hostUserId, string hostUserName, string hostSessionId, Guid itemId)
{
var party = new Party
{
ItemId = itemId,
HostUserId = hostUserId,
ReferenceUserId = hostUserId,
JoinCode = GenerateJoinCode()
};
party.Members[hostUserId] = new PartyMember
{
UserId = hostUserId,
UserName = hostUserName,
SessionId = hostSessionId,
JoinedUtc = DateTime.UtcNow,
LastUpdatedUtc = DateTime.UtcNow
};
_partiesById[party.Id] = party;
_partyIdByJoinCode[party.JoinCode] = party.Id;
return party;
}
public Party GetById(Guid partyId)
{
_partiesById.TryGetValue(partyId, out var party);
return party;
}
public Party GetByJoinCode(string joinCode)
{
if (joinCode != null && _partyIdByJoinCode.TryGetValue(joinCode, out var partyId))
{
return GetById(partyId);
}
return null;
}
public PartyMember Join(Party party, Guid userId, string userName, string sessionId)
{
var member = new PartyMember
{
UserId = userId,
UserName = userName,
SessionId = sessionId,
JoinedUtc = DateTime.UtcNow,
LastUpdatedUtc = DateTime.UtcNow
};
party.Members[userId] = member;
party.LastActivityUtc = DateTime.UtcNow;
return member;
}
public void Leave(Party party, Guid userId)
{
party.Members.TryRemove(userId, out _);
party.LastActivityUtc = DateTime.UtcNow;
if (party.ReferenceUserId == userId)
{
// Reference left: fall back to whoever's left (arbitrary but
// deterministic-ish; good enough for v1 - see docs/architecture.md).
var next = party.Members.Keys.FirstOrDefault();
if (next != Guid.Empty)
{
party.ReferenceUserId = next;
}
}
if (party.Members.IsEmpty)
{
_partiesById.TryRemove(party.Id, out _);
_partyIdByJoinCode.TryRemove(party.JoinCode, out _);
}
}
public IEnumerable<Party> GetAllActive() => _partiesById.Values;
public void RemoveIdleParties(int idleTimeoutMinutes)
{
var cutoff = DateTime.UtcNow.AddMinutes(-idleTimeoutMinutes);
foreach (var party in _partiesById.Values.Where(p => p.LastActivityUtc < cutoff).ToList())
{
_partiesById.TryRemove(party.Id, out _);
_partyIdByJoinCode.TryRemove(party.JoinCode, out _);
}
}
private static string GenerateJoinCode()
{
const string alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // no 0/O/1/I
var bytes = new byte[6];
using (var rng = System.Security.Cryptography.RandomNumberGenerator.Create())
{
rng.GetBytes(bytes);
}
var chars = new char[6];
for (var i = 0; i < 6; i++)
{
chars[i] = alphabet[bytes[i] % alphabet.Length];
}
return new string(chars);
}
}
}

View file

@ -0,0 +1,52 @@
using System;
namespace WatchParty.Parties
{
public class PartyMember
{
public Guid UserId { get; set; }
public string UserName { get; set; }
/// <summary>
/// The Emby session this member is watching from. Resolved at join time
/// from the caller's active sessions - see
/// docs/architecture.md#which-session-is-mine for the current, fairly
/// blunt, v1 heuristic.
/// </summary>
public string SessionId { get; set; }
public DateTime JoinedUtc { get; set; }
/// <summary>Last position reported for this member, in ticks (100ns units, Emby convention).</summary>
public long LastKnownPositionTicks { get; set; }
public bool LastKnownIsPaused { get; set; }
/// <summary>When LastKnownPositionTicks/LastKnownIsPaused were last updated.</summary>
public DateTime LastUpdatedUtc { get; set; }
/// <summary>When we last sent this member a corrective command, for cooldown purposes.</summary>
public DateTime? LastResyncUtc { get; set; }
/// <summary>
/// Extrapolates where this member's playback should be *right now*,
/// assuming unchanged play/pause state since LastUpdatedUtc.
/// </summary>
public long EstimatedPositionTicks(DateTime utcNow)
{
if (LastKnownIsPaused)
{
return LastKnownPositionTicks;
}
var elapsed = utcNow - LastUpdatedUtc;
if (elapsed <= TimeSpan.Zero)
{
return LastKnownPositionTicks;
}
return LastKnownPositionTicks + elapsed.Ticks;
}
}
}

60
src/WatchParty/Plugin.cs Normal file
View file

@ -0,0 +1,60 @@
using System;
using System.Collections.Generic;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Plugins;
using MediaBrowser.Model.Plugins;
using MediaBrowser.Model.Serialization;
using WatchParty.Configuration;
namespace WatchParty
{
// -----------------------------------------------------------------------
// Emby plugin entry point.
//
// Confirmed against Emby's own docs while writing this scaffold:
// - BasePlugin<T> + constructor shape:
// https://github.com/MediaBrowser/Emby/wiki/How-to-build-a-Server-Plugin
// - netstandard2.0 target / MediaBrowser.Server.Core package: same page.
//
// NOT independently confirmed (this project targets Emby Server 4.9.5.0;
// the wiki examples above are older) - check these once you can
// restore/build against the real SDK assemblies. Full list in
// docs/architecture.md#assumptions-to-verify:
// - IHasWebPages is assumed to be the current interface name for a
// dashboard settings page. At least one community plugin
// (emby-playlist-manager) uses IHasUIPages instead, which may be the
// older name.
// - PluginPageInfo's exact property names (Name / EmbeddedResourcePath).
// -----------------------------------------------------------------------
public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
{
public static Plugin Instance { get; private set; }
public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer)
: base(applicationPaths, xmlSerializer)
{
Instance = this;
}
// Generated once for this project - keep it stable across every release,
// Emby uses it to identify the plugin (and its config file) across upgrades.
public override Guid Id => new Guid("5b6a1e2c-6e3a-4b8b-9c1a-1f7a2c9d4e10");
public override string Name => "Watch Party";
public override string Description =>
"Watch the same title together with other users on this server: " +
"start or join a party, and the server nudges everyone's existing " +
"playback back in sync using Emby's normal remote-control commands.";
/// <summary>Admin-only settings page (thresholds etc.) - see Configuration/configPage.html.</summary>
public IEnumerable<PluginPageInfo> GetPages()
{
yield return new PluginPageInfo
{
Name = "WatchParty",
EmbeddedResourcePath = GetType().Namespace + ".Configuration.configPage.html"
};
}
}
}

View file

@ -0,0 +1,187 @@
using System;
using System.Threading;
using MediaBrowser.Controller.Plugins;
using MediaBrowser.Controller.Session;
using MediaBrowser.Model.Logging;
using MediaBrowser.Model.Session;
using WatchParty.Configuration;
using WatchParty.Parties;
namespace WatchParty.Sync
{
/// <summary>
/// Runs the drift-correction loop and keeps each party member's known
/// position/pause-state up to date from Emby's own session/playback events.
///
/// The core idea (see docs/architecture.md): this plugin doesn't build a new
/// player or take over anyone's playback. It watches each member's existing
/// session (the way Emby's own "Now Playing" / remote-control UI already
/// does) and, when someone drifts too far from the party's reference
/// position, sends the same kind of Seek/Pause/Unpause command Emby uses for
/// its normal remote-control feature (ISessionManager.SendPlaystateCommand).
/// </summary>
public class PartySyncEntryPoint : IServerEntryPoint
{
private readonly ISessionManager _sessionManager;
private readonly ILogger _logger;
private Timer _timer;
public PartySyncEntryPoint(ISessionManager sessionManager, ILogManager logManager)
{
_sessionManager = sessionManager;
_logger = logManager.GetLogger(nameof(PartySyncEntryPoint));
}
public void Run()
{
_sessionManager.PlaybackProgress += OnPlaybackProgress;
_sessionManager.PlaybackStart += OnPlaybackProgress;
_sessionManager.PlaybackStopped += OnPlaybackStopped;
var tickSeconds = Plugin.Instance?.Configuration.SyncTickSeconds ?? 5;
var interval = TimeSpan.FromSeconds(tickSeconds);
_timer = new Timer(_ => Tick(), null, interval, interval);
}
private void OnPlaybackProgress(object sender, PlaybackProgressEventArgs e)
{
UpdateMemberState(e.Session);
}
private void OnPlaybackStopped(object sender, PlaybackStopEventArgs e)
{
// v1: we leave the member in the party even after they stop playback -
// removing them automatically on every stop/seek blip would be more
// disruptive than useful. They just won't get fresh position updates
// until they hit play again.
}
private void UpdateMemberState(SessionInfo session)
{
if (session?.UserId == null || session.PlayState == null)
{
return;
}
foreach (var party in PartyManager.Instance.GetAllActive())
{
if (party.Members.TryGetValue(session.UserId.Value, out var member) &&
string.Equals(member.SessionId, session.Id, StringComparison.Ordinal))
{
member.LastKnownPositionTicks = session.PlayState.PositionTicks ?? member.LastKnownPositionTicks;
member.LastKnownIsPaused = session.PlayState.IsPaused;
member.LastUpdatedUtc = DateTime.UtcNow;
party.LastActivityUtc = DateTime.UtcNow;
}
}
}
private void Tick()
{
var config = Plugin.Instance?.Configuration;
if (config == null)
{
return;
}
var now = DateTime.UtcNow;
var driftThreshold = TimeSpan.FromSeconds(config.DriftThresholdSeconds);
var cooldown = TimeSpan.FromSeconds(config.ResyncCooldownSeconds);
foreach (var party in PartyManager.Instance.GetAllActive())
{
if (!party.Members.TryGetValue(party.ReferenceUserId, out var reference))
{
continue;
}
var referencePos = reference.EstimatedPositionTicks(now);
foreach (var member in party.Members.Values)
{
if (member.UserId == reference.UserId)
{
continue;
}
TryCorrect(member, reference, referencePos, now, config, driftThreshold, cooldown);
}
}
if (config.PartyIdleTimeoutMinutes > 0)
{
PartyManager.Instance.RemoveIdleParties(config.PartyIdleTimeoutMinutes);
}
}
private void TryCorrect(
PartyMember member,
PartyMember reference,
long referencePos,
DateTime now,
PluginConfiguration config,
TimeSpan driftThreshold,
TimeSpan cooldown)
{
if (string.IsNullOrEmpty(member.SessionId))
{
return;
}
if (config.SyncPausePlay && member.LastKnownIsPaused != reference.LastKnownIsPaused)
{
SendPlaystate(member.SessionId, reference.LastKnownIsPaused ? PlaystateCommand.Pause : PlaystateCommand.Unpause, null);
member.LastKnownIsPaused = reference.LastKnownIsPaused;
member.LastResyncUtc = now;
return;
}
if (member.LastResyncUtc.HasValue && now - member.LastResyncUtc.Value < cooldown)
{
return;
}
var memberPos = member.EstimatedPositionTicks(now);
var drift = TimeSpan.FromTicks(Math.Abs(memberPos - referencePos));
if (drift > driftThreshold)
{
SendPlaystate(member.SessionId, PlaystateCommand.Seek, referencePos);
member.LastKnownPositionTicks = referencePos;
member.LastUpdatedUtc = now;
member.LastResyncUtc = now;
_logger.Debug("WatchParty: corrected session {0} by {1:0.0}s", member.SessionId, drift.TotalSeconds);
}
}
private void SendPlaystate(string sessionId, PlaystateCommand command, long? seekPositionTicks)
{
try
{
_sessionManager.SendPlaystateCommand(
null, // controllingSessionId - TODO(verify): pass a real controlling
// session/user here if your SDK version rejects null.
sessionId,
new PlaystateRequest
{
Command = command,
SeekPositionTicks = seekPositionTicks
},
CancellationToken.None);
}
catch (Exception ex)
{
_logger.ErrorException("WatchParty: failed to send {0} to session {1}", ex, command, sessionId);
}
}
public void Dispose()
{
_timer?.Dispose();
_sessionManager.PlaybackProgress -= OnPlaybackProgress;
_sessionManager.PlaybackStart -= OnPlaybackProgress;
_sessionManager.PlaybackStopped -= OnPlaybackStopped;
}
}
}

View file

@ -0,0 +1,33 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<AssemblyName>WatchParty</AssemblyName>
<RootNamespace>WatchParty</RootNamespace>
<AssemblyVersion>0.1.0.0</AssemblyVersion>
<FileVersion>0.1.0.0</FileVersion>
<Nullable>disable</Nullable>
<LangVersion>latest</LangVersion>
</PropertyGroup>
<ItemGroup>
<!-- Match this to your Emby Server build (this repo targets 4.9.5.0).
4.9.1.90 was the newest release on NuGet at the time this was written -
check https://www.nuget.org/packages/mediabrowser.server.core for anything newer. -->
<PackageReference Include="MediaBrowser.Server.Core" Version="4.9.1.90" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Configuration\configPage.html" />
<EmbeddedResource Include="..\..\web\control-panel\index.html" LogicalName="WatchParty.web.index.html" />
<EmbeddedResource Include="..\..\web\control-panel\app.js" LogicalName="WatchParty.web.app.js" />
<EmbeddedResource Include="..\..\web\control-panel\app.css" LogicalName="WatchParty.web.app.css" />
</ItemGroup>
<!-- Dev convenience: after building, also drop the DLL into a local Emby
plugins folder if EMBY_PLUGINS_DIR is set. Not used in CI. -->
<Target Name="CopyToEmbyPlugins" AfterTargets="Build" Condition="'$(EMBY_PLUGINS_DIR)' != ''">
<Copy SourceFiles="$(TargetPath)" DestinationFolder="$(EMBY_PLUGINS_DIR)" />
</Target>
</Project>

142
web/control-panel/app.css Normal file
View file

@ -0,0 +1,142 @@
:root {
color-scheme: light dark;
--bg: #111418;
--panel: #1b1f26;
--text: #e8eaed;
--muted: #9aa4b2;
--accent: #5b8cff;
--danger: #ff6b6b;
--ok: #4cd97b;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
}
.wrap {
max-width: 480px;
margin: 0 auto;
padding: 32px 20px 80px;
}
h1 {
font-size: 22px;
margin-bottom: 24px;
}
h2 {
font-size: 16px;
margin: 0 0 8px;
}
.panel {
background: var(--panel);
border-radius: 12px;
padding: 20px;
margin-bottom: 16px;
}
.hidden {
display: none;
}
.hint {
color: var(--muted);
font-size: 13px;
margin: 0 0 16px;
}
label {
display: block;
font-size: 13px;
color: var(--muted);
margin-bottom: 12px;
}
input {
display: block;
width: 100%;
margin-top: 4px;
padding: 10px;
border-radius: 8px;
border: 1px solid #333a45;
background: #0e1116;
color: var(--text);
font-size: 14px;
}
button {
width: 100%;
padding: 12px;
border: none;
border-radius: 8px;
background: var(--accent);
color: white;
font-size: 14px;
font-weight: 600;
cursor: pointer;
margin-top: 4px;
}
button.secondary {
background: transparent;
border: 1px solid #333a45;
color: var(--muted);
margin-top: 12px;
}
.or {
text-align: center;
color: var(--muted);
font-size: 12px;
margin: 16px 0;
}
.error {
color: var(--danger);
font-size: 13px;
min-height: 18px;
}
.code {
color: var(--accent);
letter-spacing: 2px;
}
.members {
list-style: none;
padding: 0;
margin: 0 0 16px;
}
.members li {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 0;
border-bottom: 1px solid #262b34;
font-size: 14px;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--ok);
flex-shrink: 0;
}
.dot.drift {
background: #ffb84c;
}
.dot.paused {
background: var(--muted);
}

214
web/control-panel/app.js Normal file
View file

@ -0,0 +1,214 @@
(function () {
"use strict";
// -------------------------------------------------------------------------
// Plain JS, no build step on purpose (see docs/architecture.md). Talks
// directly to this Emby server: standard auth endpoint for sign-in, then
// this plugin's own /WatchParty/* routes for everything else.
//
// TODO(verify) - see docs/architecture.md#assumptions-to-verify:
// - X-Emby-Authorization header field names below.
// - /Users/AuthenticateByName's `Pw` field name.
// -------------------------------------------------------------------------
const state = {
serverUrl: localStorage.getItem("wp_serverUrl") || "",
accessToken: localStorage.getItem("wp_accessToken") || "",
userId: localStorage.getItem("wp_userId") || "",
partyId: localStorage.getItem("wp_partyId") || "",
pollTimer: null
};
const els = {
loginPanel: document.getElementById("loginPanel"),
lobbyPanel: document.getElementById("lobbyPanel"),
partyPanel: document.getElementById("partyPanel"),
serverUrl: document.getElementById("serverUrl"),
username: document.getElementById("username"),
password: document.getElementById("password"),
loginBtn: document.getElementById("loginBtn"),
loginError: document.getElementById("loginError"),
createBtn: document.getElementById("createBtn"),
joinCode: document.getElementById("joinCode"),
joinBtn: document.getElementById("joinBtn"),
lobbyError: document.getElementById("lobbyError"),
partyCode: document.getElementById("partyCode"),
memberList: document.getElementById("memberList"),
resyncBtn: document.getElementById("resyncBtn"),
leaveBtn: document.getElementById("leaveBtn")
};
function deviceId() {
let id = localStorage.getItem("wp_deviceId");
if (!id) {
id = "watchparty-web-" + Math.random().toString(36).slice(2);
localStorage.setItem("wp_deviceId", id);
}
return id;
}
function authHeader() {
return 'MediaBrowser Client="WatchParty", Device="Browser", DeviceId="' +
deviceId() + '", Version="0.1.0"';
}
async function embyFetch(path, options) {
options = options || {};
options.headers = Object.assign({}, options.headers, {
"X-Emby-Authorization": authHeader()
});
if (state.accessToken) {
options.headers["X-Emby-Token"] = state.accessToken;
}
const res = await fetch(state.serverUrl.replace(/\/+$/, "") + path, options);
if (!res.ok) {
throw new Error("Emby request failed: " + res.status);
}
const text = await res.text();
return text ? JSON.parse(text) : null;
}
async function login() {
els.loginError.textContent = "";
state.serverUrl = els.serverUrl.value.trim();
try {
const result = await embyFetch("/Users/AuthenticateByName", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ Username: els.username.value, Pw: els.password.value })
});
state.accessToken = result.AccessToken;
state.userId = result.User.Id;
localStorage.setItem("wp_serverUrl", state.serverUrl);
localStorage.setItem("wp_accessToken", state.accessToken);
localStorage.setItem("wp_userId", state.userId);
showLobbyOrParty();
} catch (err) {
els.loginError.textContent = "Couldn't sign in - check the server URL and credentials.";
}
}
async function createParty() {
els.lobbyError.textContent = "";
try {
const party = await embyFetch("/WatchParty/Parties", { method: "POST" });
enterParty(party);
} catch (err) {
els.lobbyError.textContent = "Couldn't start a party - are you currently playing something in Emby?";
}
}
async function joinParty() {
els.lobbyError.textContent = "";
const code = els.joinCode.value.trim().toUpperCase();
try {
const party = await embyFetch("/WatchParty/Parties/" + encodeURIComponent(code) + "/Join", { method: "POST" });
enterParty(party);
} catch (err) {
els.lobbyError.textContent = "Couldn't join - check the code, and make sure you're playing the same title.";
}
}
function enterParty(party) {
state.partyId = party.PartyId;
localStorage.setItem("wp_partyId", state.partyId);
renderParty(party);
show(els.partyPanel);
startPolling();
}
function renderParty(party) {
els.partyCode.textContent = party.JoinCode;
els.memberList.innerHTML = "";
const reference = party.Members.find((m) => m.IsReference);
party.Members.forEach((m) => {
const li = document.createElement("li");
const dot = document.createElement("span");
dot.className = "dot";
if (m.IsPaused) {
dot.classList.add("paused");
} else if (reference && Math.abs(m.PositionTicks - reference.PositionTicks) > 50000000) {
// 50,000,000 ticks = 5s - just for this indicator dot; the server enforces the real threshold.
dot.classList.add("drift");
}
li.appendChild(dot);
const label = document.createElement("span");
label.textContent = m.UserName + (m.IsReference ? " (reference)" : "");
li.appendChild(label);
els.memberList.appendChild(li);
});
}
async function refreshParty() {
try {
const party = await embyFetch("/WatchParty/Parties/" + state.partyId, {});
renderParty(party);
} catch (err) {
// Party probably ended - drop back to the lobby.
stopPolling();
state.partyId = "";
localStorage.removeItem("wp_partyId");
show(els.lobbyPanel);
}
}
function startPolling() {
stopPolling();
state.pollTimer = setInterval(refreshParty, 4000);
}
function stopPolling() {
if (state.pollTimer) {
clearInterval(state.pollTimer);
state.pollTimer = null;
}
}
async function resync() {
await embyFetch("/WatchParty/Parties/" + state.partyId + "/Resync", { method: "POST" });
}
async function leaveParty() {
await embyFetch("/WatchParty/Parties/" + state.partyId + "/Leave", { method: "POST" });
stopPolling();
state.partyId = "";
localStorage.removeItem("wp_partyId");
show(els.lobbyPanel);
}
function show(panel) {
[els.loginPanel, els.lobbyPanel, els.partyPanel].forEach((p) => p.classList.add("hidden"));
panel.classList.remove("hidden");
}
function showLobbyOrParty() {
show(state.partyId ? els.partyPanel : els.lobbyPanel);
}
els.loginBtn.addEventListener("click", login);
els.createBtn.addEventListener("click", createParty);
els.joinBtn.addEventListener("click", joinParty);
els.resyncBtn.addEventListener("click", resync);
els.leaveBtn.addEventListener("click", leaveParty);
// Resume a session if we already have a token stored.
if (state.accessToken && state.serverUrl) {
els.serverUrl.value = state.serverUrl;
if (state.partyId) {
show(els.partyPanel);
refreshParty().then(startPolling);
} else {
show(els.lobbyPanel);
}
} else {
show(els.loginPanel);
}
})();

View file

@ -0,0 +1,43 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Watch Party</title>
<link rel="stylesheet" href="app.css" />
</head>
<body>
<main class="wrap">
<h1>Watch Party</h1>
<section id="loginPanel" class="panel">
<h2>Sign in</h2>
<p class="hint">Uses your normal Emby account. Nothing leaves this browser except requests to your own server.</p>
<label>Server URL <input id="serverUrl" placeholder="http://192.168.1.10:8096" /></label>
<label>Username <input id="username" autocomplete="username" /></label>
<label>Password <input id="password" type="password" autocomplete="current-password" /></label>
<button id="loginBtn">Sign in</button>
<p id="loginError" class="error"></p>
</section>
<section id="lobbyPanel" class="panel hidden">
<h2>Start or join a party</h2>
<p class="hint">Start playing the title in your normal Emby app first, then come back here.</p>
<button id="createBtn">Start a party from what I'm watching</button>
<div class="or">or</div>
<label>Join code <input id="joinCode" maxlength="6" style="text-transform:uppercase" /></label>
<button id="joinBtn">Join</button>
<p id="lobbyError" class="error"></p>
</section>
<section id="partyPanel" class="panel hidden">
<h2>Party <span id="partyCode" class="code"></span></h2>
<p class="hint">Share the code above. Everyone keeps using their own Emby app - this page just keeps you in sync.</p>
<ul id="memberList" class="members"></ul>
<button id="resyncBtn">Resync me now</button>
<button id="leaveBtn" class="secondary">Leave party</button>
</section>
</main>
<script src="app.js"></script>
</body>
</html>