commit 66af05f259f63315673e10d1f0d56e10d253adf2 Author: Nicolas Date: Sun Sep 13 14:02:43 2026 +0200 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 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..a0675f4 --- /dev/null +++ b/.github/workflows/build.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bc9b986 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..378dacd --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..743b38c --- /dev/null +++ b/README.md @@ -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`, `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). diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..0e1976d --- /dev/null +++ b/docs/architecture.md @@ -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`, `IServerEntryPoint`. +- [Creating Api Endpoints](https://github.com/MediaBrowser/Emby/wiki/Creating-Api-Endpoints) — the `IService`/`[Route]`/`IReturn` 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 + 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. diff --git a/src/WatchParty/Api/WatchPartyService.cs b/src/WatchParty/Api/WatchPartyService.cs new file mode 100644 index 0000000..09cae1a --- /dev/null +++ b/src/WatchParty/Api/WatchPartyService.cs @@ -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 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 + { + } + + [Route("/WatchParty/Parties/{JoinCode}/Join", "POST")] + public class JoinPartyRequest : IReturn + { + 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 + { + 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 + { + } + + [Route("/WatchParty/app.js", "GET")] + public class GetControlPanelJs : IReturn + { + } + + [Route("/WatchParty/app.css", "GET")] + public class GetControlPanelCss : IReturn + { + } + + 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() + }; + } + } +} diff --git a/src/WatchParty/Configuration/PluginConfiguration.cs b/src/WatchParty/Configuration/PluginConfiguration.cs new file mode 100644 index 0000000..ba2ff82 --- /dev/null +++ b/src/WatchParty/Configuration/PluginConfiguration.cs @@ -0,0 +1,40 @@ +using MediaBrowser.Model.Plugins; + +namespace WatchParty.Configuration +{ + /// + /// Server-wide default settings for the plugin. Individual parties don't + /// currently override any of these - see docs/architecture.md roadmap. + /// + public class PluginConfiguration : BasePluginConfiguration + { + /// + /// 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. + /// + public int DriftThresholdSeconds { get; set; } = 5; + + /// + /// Minimum time between automatic corrective commands sent to the same + /// member, so a shaky connection doesn't cause a stream of seek commands. + /// + public int ResyncCooldownSeconds { get; set; } = 15; + + /// How often the background sync loop re-evaluates every active party. + public int SyncTickSeconds { get; set; } = 5; + + /// + /// 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. + /// + public bool SyncPausePlay { get; set; } = true; + + /// + /// How long an idle party (no member activity) is kept around before + /// being garbage-collected from memory. + /// + public int PartyIdleTimeoutMinutes { get; set; } = 240; + } +} diff --git a/src/WatchParty/Configuration/configPage.html b/src/WatchParty/Configuration/configPage.html new file mode 100644 index 0000000..5f3ce0a --- /dev/null +++ b/src/WatchParty/Configuration/configPage.html @@ -0,0 +1,85 @@ +
+
+
+
+
+

Watch Party

+

Defaults used for every party. See the project's docs/architecture.md for what each option does.

+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ +
+ + +
+ + +
+
+
+ + +
diff --git a/src/WatchParty/Parties/Party.cs b/src/WatchParty/Parties/Party.cs new file mode 100644 index 0000000..74ae46a --- /dev/null +++ b/src/WatchParty/Parties/Party.cs @@ -0,0 +1,35 @@ +using System; +using System.Collections.Concurrent; + +namespace WatchParty.Parties +{ + /// + /// 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. + /// + public class Party + { + public Guid Id { get; set; } = Guid.NewGuid(); + + /// Short human-typeable code used to join, e.g. "K7QX2P". + public string JoinCode { get; set; } + + /// The library item everyone is meant to be watching. + public Guid ItemId { get; set; } + + public Guid HostUserId { get; set; } + + /// + /// 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). + /// + public Guid ReferenceUserId { get; set; } + + public DateTime CreatedUtc { get; set; } = DateTime.UtcNow; + + public ConcurrentDictionary Members { get; } = new ConcurrentDictionary(); + + public DateTime LastActivityUtc { get; set; } = DateTime.UtcNow; + } +} diff --git a/src/WatchParty/Parties/PartyManager.cs b/src/WatchParty/Parties/PartyManager.cs new file mode 100644 index 0000000..8d532f5 --- /dev/null +++ b/src/WatchParty/Parties/PartyManager.cs @@ -0,0 +1,133 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; + +namespace WatchParty.Parties +{ + /// + /// 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. + /// + public class PartyManager + { + private static readonly Lazy LazyInstance = + new Lazy(() => new PartyManager()); + + public static PartyManager Instance => LazyInstance.Value; + + private readonly ConcurrentDictionary _partiesById = new ConcurrentDictionary(); + private readonly ConcurrentDictionary _partyIdByJoinCode = + new ConcurrentDictionary(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 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); + } + } +} diff --git a/src/WatchParty/Parties/PartyMember.cs b/src/WatchParty/Parties/PartyMember.cs new file mode 100644 index 0000000..b48f1e0 --- /dev/null +++ b/src/WatchParty/Parties/PartyMember.cs @@ -0,0 +1,52 @@ +using System; + +namespace WatchParty.Parties +{ + public class PartyMember + { + public Guid UserId { get; set; } + + public string UserName { get; set; } + + /// + /// 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. + /// + public string SessionId { get; set; } + + public DateTime JoinedUtc { get; set; } + + /// Last position reported for this member, in ticks (100ns units, Emby convention). + public long LastKnownPositionTicks { get; set; } + + public bool LastKnownIsPaused { get; set; } + + /// When LastKnownPositionTicks/LastKnownIsPaused were last updated. + public DateTime LastUpdatedUtc { get; set; } + + /// When we last sent this member a corrective command, for cooldown purposes. + public DateTime? LastResyncUtc { get; set; } + + /// + /// Extrapolates where this member's playback should be *right now*, + /// assuming unchanged play/pause state since LastUpdatedUtc. + /// + public long EstimatedPositionTicks(DateTime utcNow) + { + if (LastKnownIsPaused) + { + return LastKnownPositionTicks; + } + + var elapsed = utcNow - LastUpdatedUtc; + if (elapsed <= TimeSpan.Zero) + { + return LastKnownPositionTicks; + } + + return LastKnownPositionTicks + elapsed.Ticks; + } + } +} diff --git a/src/WatchParty/Plugin.cs b/src/WatchParty/Plugin.cs new file mode 100644 index 0000000..e45cd31 --- /dev/null +++ b/src/WatchParty/Plugin.cs @@ -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 + 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, 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."; + + /// Admin-only settings page (thresholds etc.) - see Configuration/configPage.html. + public IEnumerable GetPages() + { + yield return new PluginPageInfo + { + Name = "WatchParty", + EmbeddedResourcePath = GetType().Namespace + ".Configuration.configPage.html" + }; + } + } +} diff --git a/src/WatchParty/Sync/PartySyncEntryPoint.cs b/src/WatchParty/Sync/PartySyncEntryPoint.cs new file mode 100644 index 0000000..afff62e --- /dev/null +++ b/src/WatchParty/Sync/PartySyncEntryPoint.cs @@ -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 +{ + /// + /// 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). + /// + 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; + } + } +} diff --git a/src/WatchParty/WatchParty.csproj b/src/WatchParty/WatchParty.csproj new file mode 100644 index 0000000..969cd24 --- /dev/null +++ b/src/WatchParty/WatchParty.csproj @@ -0,0 +1,33 @@ + + + + netstandard2.0 + WatchParty + WatchParty + 0.1.0.0 + 0.1.0.0 + disable + latest + + + + + + + + + + + + + + + + + + + + diff --git a/web/control-panel/app.css b/web/control-panel/app.css new file mode 100644 index 0000000..d73e331 --- /dev/null +++ b/web/control-panel/app.css @@ -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); +} diff --git a/web/control-panel/app.js b/web/control-panel/app.js new file mode 100644 index 0000000..ad94660 --- /dev/null +++ b/web/control-panel/app.js @@ -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); + } +})(); diff --git a/web/control-panel/index.html b/web/control-panel/index.html new file mode 100644 index 0000000..0d78942 --- /dev/null +++ b/web/control-panel/index.html @@ -0,0 +1,43 @@ + + + + + + Watch Party + + + +
+

Watch Party

+ +
+

Sign in

+

Uses your normal Emby account. Nothing leaves this browser except requests to your own server.

+ + + + +

+
+ + + + +
+ + +