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:
commit
66af05f259
17 changed files with 1610 additions and 0 deletions
22
.github/workflows/build.yml
vendored
Normal file
22
.github/workflows/build.yml
vendored
Normal 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
17
.gitignore
vendored
Normal 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
21
LICENSE
Normal 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
85
README.md
Normal 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
202
docs/architecture.md
Normal 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.
|
||||||
239
src/WatchParty/Api/WatchPartyService.cs
Normal file
239
src/WatchParty/Api/WatchPartyService.cs
Normal 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()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
40
src/WatchParty/Configuration/PluginConfiguration.cs
Normal file
40
src/WatchParty/Configuration/PluginConfiguration.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
85
src/WatchParty/Configuration/configPage.html
Normal file
85
src/WatchParty/Configuration/configPage.html
Normal 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>
|
||||||
35
src/WatchParty/Parties/Party.cs
Normal file
35
src/WatchParty/Parties/Party.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
133
src/WatchParty/Parties/PartyManager.cs
Normal file
133
src/WatchParty/Parties/PartyManager.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
52
src/WatchParty/Parties/PartyMember.cs
Normal file
52
src/WatchParty/Parties/PartyMember.cs
Normal 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
60
src/WatchParty/Plugin.cs
Normal 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"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
187
src/WatchParty/Sync/PartySyncEntryPoint.cs
Normal file
187
src/WatchParty/Sync/PartySyncEntryPoint.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
33
src/WatchParty/WatchParty.csproj
Normal file
33
src/WatchParty/WatchParty.csproj
Normal 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
142
web/control-panel/app.css
Normal 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
214
web/control-panel/app.js
Normal 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);
|
||||||
|
}
|
||||||
|
})();
|
||||||
43
web/control-panel/index.html
Normal file
43
web/control-panel/index.html
Normal 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>
|
||||||
Loading…
Reference in a new issue