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>
214 lines
6.9 KiB
JavaScript
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);
|
|
}
|
|
})();
|