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
|
## How it's meant to work
|
||||||
|
|
||||||
1. You start playing a title in your normal Emby app.
|
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
|
2. In Emby's web client (or the Electron desktop app), open **Watch Party**
|
||||||
serves) and start a party. You get a 6-character join code.
|
from the menu and start a party. You get a 6-character join code. No
|
||||||
3. Friends open the control panel, sign in with their own Emby account, start
|
separate login — it's a real page inside Emby, using your existing session.
|
||||||
playing the *same* title in their own Emby app, and enter the code to join.
|
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
|
4. The server treats one member (the host, by default) as the reference
|
||||||
position, and periodically nudges everyone else's session back in sync
|
position, and periodically nudges everyone else's session back in sync
|
||||||
(seek correction, and mirroring pause/unpause) — see
|
(seek correction, and mirroring pause/unpause) — see
|
||||||
[docs/architecture.md](docs/architecture.md#sync-algorithm).
|
[docs/architecture.md](docs/architecture.md#sync-algorithm).
|
||||||
|
|
||||||
The control panel is a companion page, not a video player — it never touches
|
The Watch Party page is a companion panel, not a video player — it never
|
||||||
the actual video stream.
|
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
|
## Status
|
||||||
|
|
||||||
Builds clean (0 errors, 0 warnings) against the real `MediaBrowser.Server.Core`
|
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
|
/ `MediaBrowser.Common` 4.9.1.90 assemblies from NuGet, and is installed on a
|
||||||
run against an actual Emby Server — a few things (auth header format, HTTP
|
real Emby Server (Docker) — the plugin loads and its admin page shows up.
|
||||||
status codes, the dashboard settings page rendering) are only compile-checked
|
The actual feature (`partyPage.html`, reached via `EnableInUserMenu`) hasn't
|
||||||
so far, not runtime-verified. See
|
been exercised live yet. See
|
||||||
[docs/architecture.md](docs/architecture.md#assumptions-to-verify) for
|
[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
|
exactly what was wrong on the first guess, what was fixed, and what's still
|
||||||
open.
|
open.
|
||||||
|
|
@ -49,12 +54,13 @@ open.
|
||||||
|
|
||||||
```
|
```
|
||||||
src/WatchParty/ the plugin itself (C#, netstandard2.0)
|
src/WatchParty/ the plugin itself (C#, netstandard2.0)
|
||||||
Plugin.cs plugin registration + admin settings page
|
Plugin.cs plugin registration + both plugin pages
|
||||||
Configuration/ plugin settings + the dashboard config page HTML
|
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
|
Parties/ Party / PartyMember model + in-memory PartyManager
|
||||||
Sync/ the drift-correction background loop
|
Sync/ the drift-correction background loop
|
||||||
Api/ REST endpoints + serving the control panel's static files
|
Api/ REST endpoints, called from partyPage.html
|
||||||
web/control-panel/ the user-facing page (plain HTML/JS/CSS, no build step)
|
|
||||||
docs/architecture.md full design doc
|
docs/architecture.md full design doc
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -78,11 +84,15 @@ portable install vs. Docker volume).
|
||||||
|
|
||||||
- [x] Design doc + scaffold
|
- [x] Design doc + scaffold
|
||||||
- [x] Build against the real SDK, fix whatever the `TODO(verify)` spots got wrong
|
- [x] Build against the real SDK, fix whatever the `TODO(verify)` spots got wrong
|
||||||
- [ ] Try it against a real Emby Server + two clients
|
- [x] Install on a real Emby Server (Docker)
|
||||||
- [ ] Live (WebSocket) updates in the control panel instead of polling
|
- [x] Integrate into Emby's own UI instead of a standalone web app
|
||||||
- [ ] Optional text chat, reusing the same realtime channel
|
(`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
|
- [ ] Per-party override of sync settings instead of only server-wide defaults
|
||||||
|
|
||||||
|
No chat/reactions — deliberately out of scope, not just deferred.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT — see [LICENSE](LICENSE).
|
MIT — see [LICENSE](LICENSE).
|
||||||
|
|
|
||||||
|
|
@ -119,8 +119,12 @@ at the same time. Documented here rather than hidden.
|
||||||
|
|
||||||
```
|
```
|
||||||
src/WatchParty/
|
src/WatchParty/
|
||||||
Plugin.cs BasePlugin<PluginConfiguration> + admin settings page
|
Plugin.cs BasePlugin<PluginConfiguration> + both plugin pages (see GetPages())
|
||||||
Configuration/ PluginConfiguration (defaults) + configPage.html (dashboard UI)
|
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/
|
Parties/
|
||||||
Party.cs party id, join code, item id, host/reference user, members
|
Party.cs party id, join code, item id, host/reference user, members
|
||||||
PartyMember.cs per-member session id + last known position/pause + EstimatedPositionTicks
|
PartyMember.cs per-member session id + last known position/pause + EstimatedPositionTicks
|
||||||
|
|
@ -128,25 +132,45 @@ src/WatchParty/
|
||||||
Sync/
|
Sync/
|
||||||
PartySyncEntryPoint.cs IServerEntryPoint: session event wiring + the tick loop above
|
PartySyncEntryPoint.cs IServerEntryPoint: session event wiring + the tick loop above
|
||||||
Api/
|
Api/
|
||||||
WatchPartyService.cs REST endpoints (create/join/leave/get/resync) +
|
WatchPartyService.cs REST endpoints (create/join/leave/get/resync), called from
|
||||||
serves web/control-panel's static files
|
partyPage.html via the Emby web client's own ApiClient
|
||||||
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
|
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
|
for v1: a party is meant to live for one watch session, not persist for
|
||||||
weeks.
|
weeks.
|
||||||
|
|
||||||
The control panel is served *by the plugin itself* (`GET /WatchParty/app`,
|
The party UI is a real Emby plugin page (`Configuration/partyPage.html`,
|
||||||
`/app.js`, `/app.css`, reading embedded resources) rather than injected into
|
registered in `Plugin.cs` via `IHasWebPages.GetPages()`), not a separate
|
||||||
Emby's main web client. Emby doesn't document a stable way to inject a
|
web app. `PluginPageInfo` turns out to support exactly this: setting
|
||||||
user-facing (non-admin) page/script into the main web client, and hand-patching
|
`EnableInUserMenu = true` (`Plugin.cs`) surfaces the page to regular users, not
|
||||||
the client's install directory is a well-known but fragile hack that breaks
|
just admins — the same mechanism as the admin settings page
|
||||||
on every Emby update — not worth it for v1. The tradeoff: opening the control
|
(`configPage.html`), just with different visibility flags. This was not
|
||||||
panel is a separate step from opening Emby, rather than a button inside the
|
obvious from the official docs (which only cover the admin/dashboard case) —
|
||||||
Emby UI. It authenticates with your normal Emby username/password (via Emby's
|
confirmed instead via reflection on the real `PluginPageInfo` type (it has
|
||||||
standard `/Users/AuthenticateByName`) and only ever talks to your own server.
|
`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
|
## Assumptions to verify
|
||||||
|
|
||||||
|
|
@ -214,6 +238,13 @@ can't check):
|
||||||
- The dashboard settings page (`configPage.html`) actually rendering/saving
|
- The dashboard settings page (`configPage.html`) actually rendering/saving
|
||||||
correctly — embedding compiles, but the page's own JS (`ApiClient`,
|
correctly — embedding compiles, but the page's own JS (`ApiClient`,
|
||||||
`Dashboard` globals) only exists inside a running Emby dashboard.
|
`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
|
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.
|
Server 4.9.x and exercise it with two accounts.
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
using System;
|
using System;
|
||||||
using System.IO;
|
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using MediaBrowser.Controller.Net;
|
using MediaBrowser.Controller.Net;
|
||||||
|
|
@ -28,8 +27,10 @@ namespace WatchParty.Api
|
||||||
// - Exact HTTP-error exception types (using plain ArgumentException /
|
// - Exact HTTP-error exception types (using plain ArgumentException /
|
||||||
// InvalidOperationException below as a placeholder - they compile fine,
|
// InvalidOperationException below as a placeholder - they compile fine,
|
||||||
// but Emby likely has something that maps more precisely to a 404/400).
|
// 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")]
|
[Route("/WatchParty/Parties", "POST")]
|
||||||
|
|
@ -61,21 +62,6 @@ namespace WatchParty.Api
|
||||||
public Guid PartyId { get; set; }
|
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 class PartyStateDto
|
||||||
{
|
{
|
||||||
public Guid PartyId { get; set; }
|
public Guid PartyId { get; set; }
|
||||||
|
|
@ -178,12 +164,6 @@ namespace WatchParty.Api
|
||||||
}, CancellationToken.None);
|
}, 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()
|
private string CallerUserId()
|
||||||
{
|
{
|
||||||
// AuthorizationInfo.UserId is a `long` (an internal row id) on this SDK
|
// AuthorizationInfo.UserId is a `long` (an internal row id) on this SDK
|
||||||
|
|
@ -216,23 +196,6 @@ namespace WatchParty.Api
|
||||||
return candidate;
|
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)
|
private static PartyStateDto ToDto(Party party)
|
||||||
{
|
{
|
||||||
var now = DateTime.UtcNow;
|
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 " +
|
"start or join a party, and the server nudges everyone's existing " +
|
||||||
"playback back in sync using Emby's normal remote-control commands.";
|
"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()
|
public IEnumerable<PluginPageInfo> GetPages()
|
||||||
{
|
{
|
||||||
|
// Admin-only settings page (thresholds etc.) - reached from
|
||||||
|
// Dashboard > Plugins > Watch Party > Settings.
|
||||||
yield return new PluginPageInfo
|
yield return new PluginPageInfo
|
||||||
{
|
{
|
||||||
Name = "WatchParty",
|
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>
|
<ItemGroup>
|
||||||
<EmbeddedResource Include="Configuration\configPage.html" />
|
<EmbeddedResource Include="Configuration\configPage.html" />
|
||||||
<EmbeddedResource Include="..\..\web\control-panel\index.html" LogicalName="WatchParty.web.index.html" />
|
<EmbeddedResource Include="Configuration\partyPage.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>
|
</ItemGroup>
|
||||||
|
|
||||||
<!-- Dev convenience: after building, also drop the DLL into a local Emby
|
<!-- 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