watchparty/web/control-panel/app.js
Nicolas 66af05f259 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>
2026-09-13 14:02:43 +02:00

214 lines
6.9 KiB
JavaScript

(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);
}
})();