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:
parent
db815df7d5
commit
6f22f72532
9 changed files with 314 additions and 477 deletions
44
README.md
44
README.md
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
212
src/WatchParty/Configuration/partyPage.html
Normal file
212
src/WatchParty/Configuration/partyPage.html
Normal 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>
|
||||
|
|
@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
})();
|
||||
|
|
@ -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>
|
||||
Loading…
Reference in a new issue