Integrate Watch Party into Emby's own UI instead of a standalone app

Per user feedback: no separate control panel, no chat. Found that
PluginPageInfo has EnableInUserMenu/EnableInMainMenu (confirmed via
reflection on the real MediaBrowser.Model assembly, plus an Emby
community thread showing another plugin using EnableInUserMenu=true)
- a real, if barely documented, way to surface a plugin page to
regular users, not just admins, through the exact same
IHasWebPages/GetPages() mechanism as the admin settings page.

- Plugin.cs now registers a second page (partyPage.html) with
  EnableInUserMenu=true + EnableInMainMenu=true
- partyPage.html replaces the old standalone control panel: no login
  form, it runs inside the already-authenticated Emby web client and
  calls the REST API via the global ApiClient
- Removed web/control-panel/ (the standalone app, its own login flow,
  and the custom GET /WatchParty/app* static file routes in
  WatchPartyService.cs) - no longer needed
- docs/architecture.md and README.md updated: the new mechanism, and
  the honest limitation that comes with it (reaches Web/Electron
  clients, not official mobile/TV apps - those allowlist plugin pages
  client-side)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Nicolas 2026-09-13 19:44:31 +02:00
parent db815df7d5
commit 6f22f72532
9 changed files with 314 additions and 477 deletions

View file

@ -13,25 +13,30 @@ 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.
2. In Emby's web client (or the Electron desktop app), open **Watch Party**
from the menu and start a party. You get a 6-character join code. No
separate login — it's a real page inside Emby, using your existing session.
3. Friends open the same **Watch Party** menu item, 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.
The Watch Party page is a companion panel, not a video player — it never
touches the actual video stream, playback stays in your normal Emby app.
**Known limitation**: the menu item shows up on Web and Electron clients.
Official mobile/TV apps don't surface arbitrary plugin pages, so Watch Party
won't appear there — see [docs/architecture.md](docs/architecture.md#components).
## Status
Builds clean (0 errors, 0 warnings) against the real `MediaBrowser.Server.Core`
/ `MediaBrowser.Common` 4.9.1.90 assemblies from NuGet. Not yet installed or
run against an actual Emby Server — a few things (auth header format, HTTP
status codes, the dashboard settings page rendering) are only compile-checked
so far, not runtime-verified. See
/ `MediaBrowser.Common` 4.9.1.90 assemblies from NuGet, and is installed on a
real Emby Server (Docker) — the plugin loads and its admin page shows up.
The actual feature (`partyPage.html`, reached via `EnableInUserMenu`) hasn't
been exercised live yet. See
[docs/architecture.md](docs/architecture.md#assumptions-to-verify) for
exactly what was wrong on the first guess, what was fixed, and what's still
open.
@ -49,12 +54,13 @@ open.
```
src/WatchParty/ the plugin itself (C#, netstandard2.0)
Plugin.cs plugin registration + admin settings page
Configuration/ plugin settings + the dashboard config page HTML
Plugin.cs plugin registration + both plugin pages
Configuration/ plugin settings, the admin settings page, and
partyPage.html - the actual user-facing feature,
integrated into Emby's own menu (not a separate app)
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)
Api/ REST endpoints, called from partyPage.html
docs/architecture.md full design doc
```
@ -78,11 +84,15 @@ portable install vs. Docker volume).
- [x] Design doc + scaffold
- [x] 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
- [x] Install on a real Emby Server (Docker)
- [x] Integrate into Emby's own UI instead of a standalone web app
(`EnableInUserMenu` — see [docs/architecture.md](docs/architecture.md#components))
- [ ] Try a full party end-to-end with two accounts
- [ ] Live (WebSocket) updates in the party page instead of polling
- [ ] Per-party override of sync settings instead of only server-wide defaults
No chat/reactions — deliberately out of scope, not just deferred.
## License
MIT — see [LICENSE](LICENSE).

View file

@ -119,8 +119,12 @@ at the same time. Documented here rather than hidden.
```
src/WatchParty/
Plugin.cs BasePlugin<PluginConfiguration> + admin settings page
Configuration/ PluginConfiguration (defaults) + configPage.html (dashboard UI)
Plugin.cs BasePlugin<PluginConfiguration> + both plugin pages (see GetPages())
Configuration/
PluginConfiguration.cs defaults (drift threshold, cooldown, etc.)
configPage.html admin settings page (Dashboard > Plugins > Watch Party)
partyPage.html the actual feature: create/join, member list, resync/leave -
EnableInUserMenu=true, so regular users see it too
Parties/
Party.cs party id, join code, item id, host/reference user, members
PartyMember.cs per-member session id + last known position/pause + EstimatedPositionTicks
@ -128,25 +132,45 @@ src/WatchParty/
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)
WatchPartyService.cs REST endpoints (create/join/leave/get/resync), called from
partyPage.html via the Emby web client's own ApiClient
```
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.
The party UI is a real Emby plugin page (`Configuration/partyPage.html`,
registered in `Plugin.cs` via `IHasWebPages.GetPages()`), not a separate
web app. `PluginPageInfo` turns out to support exactly this: setting
`EnableInUserMenu = true` (`Plugin.cs`) surfaces the page to regular users, not
just admins — the same mechanism as the admin settings page
(`configPage.html`), just with different visibility flags. This was not
obvious from the official docs (which only cover the admin/dashboard case) —
confirmed instead via reflection on the real `PluginPageInfo` type (it has
`EnableInMainMenu`, `EnableInUserMenu`, `MenuSection`, `MenuIcon`,
`IsMainConfigPage`) and an Emby community thread showing another plugin using
`EnableInUserMenu = true` in practice.
Two consequences of doing it this way instead of a standalone page:
- No separate login: the page runs inside the already-authenticated Emby web
client and just calls the global `ApiClient`, the same as the admin
settings page already did. There's no server URL / username / password
form, no token stored anywhere by this plugin.
- **Known limitation**: this reaches the **Web browser and Electron desktop
client only**. Official mobile/TV apps allowlist which plugin pages they
render client-side (independent of what the server exposes), so the page
won't show up there without changes on the client side that aren't
realistic to make from this plugin. Playback sync itself (the
`SendPlaystateCommand` nudges) still works on any client regardless — it's
only the *page to create/join a party* that's Web/Electron-only.
An earlier version of this plugin shipped a fully standalone web app
(`web/control-panel/`, its own login form, served via custom
`GET /WatchParty/app` routes) to sidestep exactly this uncertainty. It was
removed once `EnableInUserMenu` was confirmed to work — see git history if
you ever need the standalone version back (e.g. to reach a client where the
integrated page doesn't show up).
## Assumptions to verify
@ -214,6 +238,13 @@ can't check):
- The dashboard settings page (`configPage.html`) actually rendering/saving
correctly — embedding compiles, but the page's own JS (`ApiClient`,
`Dashboard` globals) only exists inside a running Emby dashboard.
- **`partyPage.html`**: whether `ApiClient.ajax({...})` really behaves like
the jQuery-style options object it's called with here (`type`, `url`,
`dataType`) — that's the long-standing Emby/Jellyfin web client convention,
not confirmed against this specific build. Whether `EnableInMainMenu`/
`MenuIcon = "group"` place the page where you'd expect (main nav vs. a
user submenu) is also cosmetic-only until seen live — easy to adjust once
visible.
The remaining way to close these out: install the plugin DLL on a real Emby
Server 4.9.x and exercise it with two accounts.

View file

@ -1,5 +1,4 @@
using System;
using System.IO;
using System.Linq;
using System.Threading;
using MediaBrowser.Controller.Net;
@ -28,8 +27,10 @@ namespace WatchParty.Api
// - Exact HTTP-error exception types (using plain ArgumentException /
// InvalidOperationException below as a placeholder - they compile fine,
// but Emby likely has something that maps more precisely to a 404/400).
// - Content-Type on the static file responses (returning bare strings for
// now; IHasResultFactory looks like the right extension point later).
//
// The UI used to be served from here too (GET /WatchParty/app + friends,
// reading embedded static files) before the feature moved to a proper
// Emby-integrated page - see Configuration/partyPage.html and Plugin.cs.
// ---------------------------------------------------------------------------
[Route("/WatchParty/Parties", "POST")]
@ -61,21 +62,6 @@ namespace WatchParty.Api
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; }
@ -178,12 +164,6 @@ namespace WatchParty.Api
}, 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 string CallerUserId()
{
// AuthorizationInfo.UserId is a `long` (an internal row id) on this SDK
@ -216,23 +196,6 @@ namespace WatchParty.Api
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;

View file

@ -0,0 +1,212 @@
<div id="watchPartyPage" data-role="page" class="page type-interior" data-require="emby-input,emby-button">
<div data-role="content">
<div class="content-primary">
<div class="verticalSection">
<h1>Watch Party</h1>
<p class="fieldDescription">
Regarde le même titre en même temps que d'autres utilisateurs de ce serveur.
Lance la lecture dans Emby comme d'habitude - cette page sert juste à créer ou
rejoindre une party et à rester synchronisé.
</p>
</div>
<div id="wpLobby" class="verticalSection">
<button is="emby-button" type="button" id="wpCreateBtn" class="raised block">
<span>Démarrer une party depuis ce que je regarde</span>
</button>
<div class="inputContainer" style="margin-top:1.5em">
<label class="inputLabel" for="wpJoinCode">Code de la party</label>
<input is="emby-input" type="text" id="wpJoinCode" maxlength="6" style="text-transform:uppercase" />
</div>
<button is="emby-button" type="button" id="wpJoinBtn" class="raised block">
<span>Rejoindre</span>
</button>
<p id="wpLobbyError" class="wpError"></p>
</div>
<div id="wpParty" class="verticalSection hide">
<h2>Party <span id="wpPartyCode" class="wpCode"></span></h2>
<p class="fieldDescription">
Partage le code ci-dessus. Chacun garde son appli Emby habituelle - cette page
sert juste à rester synchronisé.
</p>
<div id="wpMemberList"></div>
<button is="emby-button" type="button" id="wpResyncBtn" class="raised block">
<span>Me resynchroniser</span>
</button>
<button is="emby-button" type="button" id="wpLeaveBtn" class="block">
<span>Quitter la party</span>
</button>
</div>
</div>
</div>
<style>
#watchPartyPage .hide { display: none; }
#watchPartyPage .wpError { color: #ff6b6b; min-height: 1.2em; }
#watchPartyPage .wpCode { letter-spacing: .1em; opacity: .8; }
#watchPartyPage .wpMember { display: flex; align-items: center; gap: .6em; padding: .5em 0; border-bottom: 1px solid rgba(255,255,255,.1); }
#watchPartyPage .wpDot { width: 8px; height: 8px; border-radius: 50%; background: #4cd97b; flex-shrink: 0; }
#watchPartyPage .wpDot.drift { background: #ffb84c; }
#watchPartyPage .wpDot.paused { background: #9aa4b2; }
</style>
<script type="text/javascript">
// Same IHasWebPages/PluginPageInfo/EmbeddedResourcePath mechanism as the
// admin settings page (configPage.html), but registered in Plugin.cs with
// EnableInUserMenu=true (+ EnableInMainMenu=true) so regular users see it,
// not just admins. Confirmed as a real, working combination via an Emby
// community thread showing another plugin using the exact same flags -
// not from official docs, so still worth a sanity check in practice.
//
// Runs inside the already-authenticated Emby web client, so - unlike the
// old standalone control panel this replaced - it just uses the global
// ApiClient (assumed available here the same way it is on dashboard
// pages; TODO(verify) if calls below fail with something ApiClient-shaped
// rather than a normal error). No separate login of any kind.
(function () {
var partyId = localStorage.getItem("wp_partyId") || "";
var pollTimer = null;
function view() { return document.querySelector('#watchPartyPage'); }
function $(sel) { return view().querySelector(sel); }
function showLobby() {
$('#wpParty').classList.add('hide');
$('#wpLobby').classList.remove('hide');
}
function showParty() {
$('#wpLobby').classList.add('hide');
$('#wpParty').classList.remove('hide');
}
function renderParty(party) {
$('#wpPartyCode').textContent = party.JoinCode;
var list = $('#wpMemberList');
list.innerHTML = '';
var reference = party.Members.filter(function (m) { return m.IsReference; })[0];
party.Members.forEach(function (m) {
var row = document.createElement('div');
row.className = 'wpMember';
var dot = document.createElement('span');
dot.className = 'wpDot';
if (m.IsPaused) {
dot.className += ' paused';
} else if (reference && Math.abs(m.PositionTicks - reference.PositionTicks) > 50000000) {
// 50,000,000 ticks = 5s - just for this indicator; the server enforces the real threshold.
dot.className += ' drift';
}
row.appendChild(dot);
var label = document.createElement('span');
label.textContent = m.UserName + (m.IsReference ? ' (référence)' : '');
row.appendChild(label);
list.appendChild(row);
});
}
function stopPolling() {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
}
function refreshParty() {
ApiClient.ajax({
type: 'GET',
url: ApiClient.getUrl('WatchParty/Parties/' + partyId),
dataType: 'json'
}).then(renderParty).catch(function () {
// Party probably ended - drop back to the lobby.
stopPolling();
partyId = '';
localStorage.removeItem('wp_partyId');
showLobby();
});
}
function startPolling() {
stopPolling();
pollTimer = setInterval(refreshParty, 4000);
}
function enterParty(party) {
partyId = party.PartyId;
localStorage.setItem('wp_partyId', partyId);
renderParty(party);
showParty();
startPolling();
}
function createParty() {
$('#wpLobbyError').textContent = '';
ApiClient.ajax({
type: 'POST',
url: ApiClient.getUrl('WatchParty/Parties'),
dataType: 'json'
}).then(enterParty).catch(function () {
$('#wpLobbyError').textContent =
"Impossible de démarrer une party - es-tu bien en train de regarder quelque chose sur Emby ?";
});
}
function joinParty() {
$('#wpLobbyError').textContent = '';
var code = ($('#wpJoinCode').value || '').trim().toUpperCase();
ApiClient.ajax({
type: 'POST',
url: ApiClient.getUrl('WatchParty/Parties/' + encodeURIComponent(code) + '/Join'),
dataType: 'json'
}).then(enterParty).catch(function () {
$('#wpLobbyError').textContent =
"Impossible de rejoindre - vérifie le code, et assure-toi de regarder le même titre.";
});
}
function resync() {
ApiClient.ajax({
type: 'POST',
url: ApiClient.getUrl('WatchParty/Parties/' + partyId + '/Resync')
});
}
function leaveParty() {
ApiClient.ajax({
type: 'POST',
url: ApiClient.getUrl('WatchParty/Parties/' + partyId + '/Leave')
}).then(function () {
stopPolling();
partyId = '';
localStorage.removeItem('wp_partyId');
showLobby();
});
}
$('#wpCreateBtn').addEventListener('click', createParty);
$('#wpJoinBtn').addEventListener('click', joinParty);
$('#wpResyncBtn').addEventListener('click', resync);
$('#wpLeaveBtn').addEventListener('click', leaveParty);
// Bound directly to this page's own element (not `document`) so a
// fresh script execution on the next visit doesn't pile up extra
// listeners on top of ones from a previous visit.
view().addEventListener('pageshow', function () {
if (partyId) {
showParty();
refreshParty();
startPolling();
} else {
showLobby();
}
});
view().addEventListener('pagehide', stopPolling);
})();
</script>
</div>

View file

@ -38,13 +38,35 @@ namespace WatchParty
"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()
{
// Admin-only settings page (thresholds etc.) - reached from
// Dashboard > Plugins > Watch Party > Settings.
yield return new PluginPageInfo
{
Name = "WatchParty",
EmbeddedResourcePath = GetType().Namespace + ".Configuration.configPage.html"
DisplayName = "Watch Party",
EmbeddedResourcePath = GetType().Namespace + ".Configuration.configPage.html",
IsMainConfigPage = true
};
// The actual feature, reachable by regular users - not just admins.
// EnableInUserMenu/EnableInMainMenu is a real, if barely-documented,
// PluginPageInfo mechanism (confirmed via MediaBrowser.Model
// reflection and an Emby community thread showing a working plugin
// using this exact combination). Known limitation: this reaches
// Web/Electron clients, NOT the official mobile/TV apps - those
// allowlist which plugin pages they show client-side, independent
// of the server. See docs/architecture.md#assumptions-to-verify.
yield return new PluginPageInfo
{
Name = "WatchPartyRoom",
DisplayName = "Watch Party",
EmbeddedResourcePath = GetType().Namespace + ".Configuration.partyPage.html",
EnableInMainMenu = true,
EnableInUserMenu = true,
MenuIcon = "group",
IsMainConfigPage = false
};
}
}

View file

@ -19,9 +19,7 @@
<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" />
<EmbeddedResource Include="Configuration\partyPage.html" />
</ItemGroup>
<!-- Dev convenience: after building, also drop the DLL into a local Emby

View file

@ -1,142 +0,0 @@
: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);
}

View file

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

View file

@ -1,43 +0,0 @@
<!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>