All checks were successful
Publier l'image Docker / publish (push) Successful in 7s
- New backend endpoints: GET /api/catalog/libraries (Emby's
/Users/{id}/Views), GET /api/catalog/libraries/:id/items (top-level,
Recursive=false - movies as movies, TV as Series tiles), GET
/api/parties (list every active party server-wide, for the home
page's "watch parties en cours" section)
- Item DTO gained `type` and `canWatchParty` (only Movie/Episode/Video
are directly streamable - a Series tile only gets "Voir sur Emby")
and `embyUrl`, a deep link into Emby's own web client built from
EMBY_SERVER_URL + serverId (now captured at login)
- Party creation now fetches and stores the item's name/poster so the
home page's party list doesn't need a separate lookup per party
- client/js/views/browse.js rewritten: ongoing-parties section, a
join-by-code fallback, one tab per library, and a tile grid with a
hover-revealed two-button overlay (start party / view on Emby) - the
first only shown for playable types
- Removed itemDetail.js/the /item/:id route (tiles act directly now,
no intermediate detail page); player.js simplified to use
party.itemName instead of a redundant item lookup
- app.css: full-width home layout (no max-width cap, as asked), CSS
grid handling all the responsive reflow, :hover/:focus-within reveal
for the tile overlay with an always-visible (hover: none) fallback
for touch devices
Verified by actually driving a browser (mocked fetch standing in for
/api/parties and /api/catalog/* - real login still needs real
credentials): tab switching swaps content correctly, the overlay
reveals per-tile only, a Series tile correctly omits the watch-party
button, and the touch fallback shows both buttons inline without
hover. See docs/architecture.md for exactly what that does and doesn't
confirm (real Emby /Views and item-list response shapes are still
unverified).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
139 lines
4.1 KiB
JavaScript
139 lines
4.1 KiB
JavaScript
'use strict';
|
|
|
|
const crypto = require('crypto');
|
|
|
|
// No 0/O/1/I - easy to read aloud / type on a phone.
|
|
const JOIN_CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
|
|
|
function generateJoinCode() {
|
|
const bytes = crypto.randomBytes(6);
|
|
let code = '';
|
|
for (let i = 0; i < 6; i++) {
|
|
code += JOIN_CODE_ALPHABET[bytes[i] % JOIN_CODE_ALPHABET.length];
|
|
}
|
|
return code;
|
|
}
|
|
|
|
/**
|
|
* In-memory party store - mirrors the Party/PartyMember/PartyManager design
|
|
* from the earlier Emby-plugin attempt (see git history / docs/architecture.md),
|
|
* adapted for a model where we own the player: one host drives playback,
|
|
* everyone else's client just applies whatever the host reports. Nothing is
|
|
* persisted to disk; a server restart clears every party, which is fine - a
|
|
* party is meant to live for one watch session, not persist for weeks.
|
|
*/
|
|
class PartyManager {
|
|
constructor() {
|
|
this.partiesById = new Map();
|
|
this.partyIdByJoinCode = new Map();
|
|
}
|
|
|
|
create(hostUserId, hostUserName, itemId, itemName, itemImageUrl) {
|
|
const party = {
|
|
id: crypto.randomUUID(),
|
|
joinCode: generateJoinCode(),
|
|
itemId,
|
|
itemName,
|
|
itemImageUrl,
|
|
hostUserId,
|
|
createdUtc: Date.now(),
|
|
lastActivityUtc: Date.now(),
|
|
// userId -> { userId, userName, connection, isPaused, positionTicks, lastUpdatedUtc }
|
|
members: new Map()
|
|
};
|
|
|
|
party.members.set(hostUserId, this._newMember(hostUserId, hostUserName));
|
|
this.partiesById.set(party.id, party);
|
|
this.partyIdByJoinCode.set(party.joinCode, party.id);
|
|
return party;
|
|
}
|
|
|
|
getById(partyId) {
|
|
return this.partiesById.get(partyId) || null;
|
|
}
|
|
|
|
getByJoinCode(joinCode) {
|
|
const id = this.partyIdByJoinCode.get((joinCode || '').toUpperCase());
|
|
return id ? this.getById(id) : null;
|
|
}
|
|
|
|
join(party, userId, userName) {
|
|
const member = this._newMember(userId, userName);
|
|
party.members.set(userId, member);
|
|
party.lastActivityUtc = Date.now();
|
|
return member;
|
|
}
|
|
|
|
leave(party, userId) {
|
|
party.members.delete(userId);
|
|
party.lastActivityUtc = Date.now();
|
|
|
|
if (party.hostUserId === userId) {
|
|
// Host left: promote whoever's left (arbitrary but deterministic-ish;
|
|
// good enough for v1 - matches the old plugin's reference-fallback logic).
|
|
const next = party.members.keys().next();
|
|
party.hostUserId = next.done ? null : next.value;
|
|
}
|
|
|
|
if (party.members.size === 0) {
|
|
this.partiesById.delete(party.id);
|
|
this.partyIdByJoinCode.delete(party.joinCode);
|
|
}
|
|
}
|
|
|
|
/** All active parties - powers the "watch parties en cours" home section. */
|
|
listActive() {
|
|
return Array.from(this.partiesById.values());
|
|
}
|
|
|
|
removeIdleParties(idleTimeoutMs) {
|
|
const cutoff = Date.now() - idleTimeoutMs;
|
|
for (const party of this.partiesById.values()) {
|
|
if (party.lastActivityUtc < cutoff) {
|
|
this.partiesById.delete(party.id);
|
|
this.partyIdByJoinCode.delete(party.joinCode);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* `viewerUserId` lets the response say "youAreHost" instead of the client
|
|
* having to know its own Emby user id to figure that out itself - now that
|
|
* auth is a server-side session (see embyAuth.js), the client doesn't hold
|
|
* an id to compare with at all.
|
|
*/
|
|
toPublicState(party, viewerUserId) {
|
|
const host = party.members.get(party.hostUserId);
|
|
return {
|
|
partyId: party.id,
|
|
joinCode: party.joinCode,
|
|
itemId: party.itemId,
|
|
itemName: party.itemName,
|
|
itemImageUrl: party.itemImageUrl,
|
|
hostUserName: host ? host.userName : null,
|
|
memberCount: party.members.size,
|
|
youAreHost: viewerUserId != null && viewerUserId === party.hostUserId,
|
|
members: Array.from(party.members.values()).map((m) => ({
|
|
userId: m.userId,
|
|
userName: m.userName,
|
|
isHost: m.userId === party.hostUserId,
|
|
isPaused: m.isPaused,
|
|
positionTicks: m.positionTicks,
|
|
connected: !!m.connection
|
|
}))
|
|
};
|
|
}
|
|
|
|
_newMember(userId, userName) {
|
|
return {
|
|
userId,
|
|
userName,
|
|
connection: null,
|
|
isPaused: true,
|
|
positionTicks: 0,
|
|
lastUpdatedUtc: Date.now()
|
|
};
|
|
}
|
|
}
|
|
|
|
module.exports = { PartyManager: new PartyManager() };
|