Emby-style UI: sidebar, carousels, series drill-down, episode queue, player tracks
All checks were successful
Publier l'image Docker / publish (push) Successful in 7s
All checks were successful
Publier l'image Docker / publish (push) Successful in 7s
Big UI pass towards looking/behaving like Emby. Deferred: music (needs
its own player + a real-time collaborative playlist - different enough
subsystem to be its own pass; sidebar/routing already treat it as a
first-class collectionType, page just says "not yet" for now).
Layout:
- client/js/shell.js: persistent sidebar (one entry per Emby library,
from GET /api/catalog/libraries which now also returns
collectionType) + a #mainContent the router swaps on navigation.
router.js made idempotent (start() safe to call more than once) so
a login -> shell boot can happen twice in one page lifetime (logout
then back in) without piling up duplicate hashchange listeners.
- app.js's boot() renders either the bare login screen or the full
shell depending on session state; login.js calls it again on
success instead of navigate() (no router running yet at that point).
Browsing, by collection type:
- views/home.js (route /home): a carousel of every active party
server-wide, a join-by-code fallback, then one "recently added"
carousel per non-music library (GET /api/catalog/libraries/:id/recent,
Emby's Items/Latest).
- views/library.js (route /library/:id): dispatches on collectionType -
movies/tvshows get the tile grid (Series tiles link into /series/:id
instead of starting a party directly), music gets an honest
placeholder.
- views/series.js (route /series/:id): season tabs + episode list
(new GET /api/catalog/series/:id/seasons and .../episodes, Emby's
/Shows/{id}/Seasons and /Episodes).
- tiles.js: poster-tile rendering shared by home.js and library.js so
they can't drift apart.
Episode queue / "next episode":
- POST /api/parties now builds a queue when starting from an episode
(fetches the rest of the series, from that episode onward) -
partyManager.js gained queue/queueIndex/advanceQueue.
- New WS message {type:"next"} (host-only) advances the queue and
broadcasts {type:"itemChanged", itemId, itemName, ...}; every client
reloads the player against the new item. player.js shows "Épisode
suivant" when hasNext, and auto-sends next() on the video's `ended`
event.
Player: language/subtitles/quality:
- New GET /api/catalog/item/:id/playback-options exposes PlaybackInfo's
MediaStreams as audio/subtitle track lists.
- master.m3u8 now forwards AudioStreamIndex/SubtitleStreamIndex/
MaxStreamingBitrate to PlaybackInfo; player.js has three <select>s
(host-only) that reload the stream at the same position on change.
Subtitles end up burned into the transcode (via SubtitleStreamIndex),
not a toggleable track - simpler than sidecar WebVTT for this pass.
"Quality" is a MaxStreamingBitrate ceiling, not true adaptive bitrate.
Verified by driving a browser against mocked /api/* responses (see
session history): sidebar renders/highlights correctly, home carousels
render (parties + one row per non-music library, music correctly
skipped), library grid dispatch works, series page season-tab
switching + episode list render and the season/episode navigation
flow works end to end. Real Emby response shapes for all of this
(Views' CollectionType, Seasons/Episodes, Items/Latest, PlaybackInfo's
MediaStreams) and the queue/track-picker protocol are NOT yet
confirmed against a live server - see docs/architecture.md.
Removed client/js/views/browse.js, superseded by home.js + library.js.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
1891e87c83
commit
f563f5b0a2
18 changed files with 1052 additions and 291 deletions
42
README.md
42
README.md
|
|
@ -12,22 +12,33 @@ including why this replaced an earlier Emby-plugin attempt (still in
|
|||
|
||||
1. Sign in with your Emby account (just username/password — the server
|
||||
address is fixed server-side, see Deploying below).
|
||||
2. Search your Emby catalog for a title.
|
||||
3. Start a party (you become the host) or join one with a 6-character code.
|
||||
4. The host's play/pause/seek drives everyone else's player automatically.
|
||||
2. A sidebar lists your Emby libraries; the home page shows every ongoing
|
||||
watch party plus a "recently added" row per library, Emby-style.
|
||||
3. Pick a movie/episode tile and hit "Lancer une watch party" (or drill into
|
||||
a Series' seasons/episodes first) — you become the host — or join one
|
||||
with a 6-character code.
|
||||
4. The host's play/pause/seek drives everyone else's player automatically;
|
||||
starting from an episode queues the rest of the series so the host can
|
||||
just hit "Épisode suivant" instead of restarting a party each time.
|
||||
|
||||
Everything — catalog, images, video, sync — flows through this app's own
|
||||
backend, which proxies whatever it needs from Emby server-side. The browser
|
||||
never talks to Emby directly and never holds an Emby access token; it only
|
||||
ever has a session cookie for this app.
|
||||
|
||||
Music isn't part of this yet — it needs its own player and a collaborative
|
||||
playlist, different enough to be its own pass. The sidebar already lists a
|
||||
Music library if you have one; the page just says so for now.
|
||||
|
||||
## Status
|
||||
|
||||
Login has been exercised end to end against the real Emby server (with
|
||||
intentionally-wrong credentials, to confirm the round trip without needing
|
||||
real ones) — see [docs/architecture.md](docs/architecture.md#assumptions-to-verify)
|
||||
for exactly what that confirms and what's still unverified beyond it
|
||||
(mainly: HLS playback once actually logged in).
|
||||
Login, and the rendering/navigation logic for the sidebar/home/library/series
|
||||
pages, have been exercised end to end (login against the real Emby server
|
||||
with intentionally-wrong credentials; the pages against mocked API
|
||||
responses). See [docs/architecture.md](docs/architecture.md#assumptions-to-verify)
|
||||
for exactly what that does and doesn't confirm — the real Emby response
|
||||
shapes this now depends on (library `CollectionType`, seasons/episodes,
|
||||
`PlaybackInfo`'s media streams) and actual HLS playback are still unverified.
|
||||
|
||||
## Project layout
|
||||
|
||||
|
|
@ -43,9 +54,11 @@ server/ Node.js backend: proxies ALL Emby access (auth, catalog,
|
|||
|
||||
client/ Static frontend (plain JS, ES modules, no build step).
|
||||
Talks only to server/ - never to Emby directly.
|
||||
js/embyApi.js calls our own /api/* routes (auth, search, item, stream URL)
|
||||
js/shell.js persistent sidebar + #mainContent the router swaps
|
||||
js/tiles.js poster-tile rendering shared by home.js and library.js
|
||||
js/embyApi.js calls our own /api/* routes (auth, catalog, playback options)
|
||||
js/party.js our own backend's party REST + WebSocket client
|
||||
js/views/ one file per screen (login, browse, item, player)
|
||||
js/views/ one file per screen: login, home, library, series, player
|
||||
|
||||
src/WatchParty/ The earlier Emby-plugin attempt. Builds fine and installs,
|
||||
but Emby has no supported extension point for a player
|
||||
|
|
@ -84,10 +97,11 @@ Then put it behind your existing reverse proxy like everything else
|
|||
## Roadmap
|
||||
|
||||
- [x] Design + scaffold the standalone app
|
||||
- [x] Smoke-test locally (routing, error handling) without a real Emby server
|
||||
- [ ] Verify against the real Emby server (CORS is the main risk - see above)
|
||||
- [ ] Try a full party end-to-end with two accounts
|
||||
- [ ] TV show navigation (seasons/episodes) beyond flat search
|
||||
- [x] Proxy all Emby access server-side; Emby-themed UI (sidebar, carousels)
|
||||
- [x] TV show navigation (Series → Seasons → Episodes) + episode auto-queue
|
||||
- [x] Player language/subtitle/quality pickers
|
||||
- [ ] Try a full party end-to-end with two real accounts (still the main gap)
|
||||
- [ ] Music: dedicated player, artist/album browsing, collaborative playlist
|
||||
- [ ] Tune transcoding target / support direct play when possible
|
||||
|
||||
No chat, no OIDC — deliberately out of scope (Emby-only auth was the
|
||||
|
|
|
|||
|
|
@ -29,8 +29,8 @@ body {
|
|||
|
||||
#app { min-height: 100%; }
|
||||
|
||||
/* Login is a small centered form - everything else (the home page) is
|
||||
full-width, see .home below. */
|
||||
/* Login is a small centered form - everything else lives inside the app
|
||||
shell (sidebar + .mainContent), see below. */
|
||||
.wrap {
|
||||
max-width: 420px;
|
||||
margin: 0 auto;
|
||||
|
|
@ -40,15 +40,6 @@ body {
|
|||
h1 { font-size: 22px; margin: 0; }
|
||||
h2 { font-size: 16px; margin: 0 0 12px; }
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--bg-header);
|
||||
border-radius: 12px;
|
||||
|
|
@ -109,30 +100,115 @@ button.linklike:hover { background: none; color: var(--accent-bright); }
|
|||
|
||||
.code { color: var(--accent-bright); letter-spacing: .1em; }
|
||||
|
||||
/* ---- Home page: full width, responsive ---- */
|
||||
/* ---- App shell: sidebar + routed main content ---- */
|
||||
|
||||
.home {
|
||||
width: 100%;
|
||||
padding: 24px clamp(16px, 4vw, 48px) 80px;
|
||||
.appShell {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 220px;
|
||||
flex-shrink: 0;
|
||||
background: var(--bg-header);
|
||||
padding: 20px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sidebarBrand {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
padding: 0 12px 20px;
|
||||
}
|
||||
|
||||
.sidebarNav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.navSectionLabel {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .05em;
|
||||
padding: 16px 12px 6px;
|
||||
}
|
||||
|
||||
.navItem {
|
||||
display: block;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.navItem:hover { background: rgba(255, 255, 255, .06); }
|
||||
.navItem.active { background: var(--accent); color: white; font-weight: 600; }
|
||||
|
||||
.sidebarLogout { margin-top: 12px; padding: 8px 12px; }
|
||||
|
||||
.mainContent {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 24px clamp(16px, 3vw, 48px) 80px;
|
||||
}
|
||||
|
||||
/* Sidebar becomes a horizontal scrollable topbar on narrow screens - same
|
||||
"scroll instead of wrap" treatment as .tabs below. */
|
||||
@media (max-width: 720px) {
|
||||
.appShell { flex-direction: column; }
|
||||
.sidebar {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 10px 12px;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
.sidebarBrand { padding: 0 12px 0 0; }
|
||||
.sidebarNav { flex-direction: row; flex: none; }
|
||||
.navSectionLabel { display: none; }
|
||||
.sidebarLogout { margin: 0 0 0 12px; }
|
||||
}
|
||||
|
||||
.pageHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.joinInline { display: flex; gap: 8px; align-items: center; }
|
||||
.joinInline input { width: 140px; margin: 0; }
|
||||
.joinInline button { padding: 10px 14px; }
|
||||
|
||||
.section { margin-bottom: 32px; }
|
||||
|
||||
.joinSection {
|
||||
/* ---- Horizontal carousels (home page) ---- */
|
||||
|
||||
.carousel {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
gap: 16px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.joinSection label { margin-bottom: 0; min-width: 160px; }
|
||||
.joinSection .error { width: 100%; }
|
||||
|
||||
.partiesList {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.carousel .tile { flex: 0 0 160px; }
|
||||
.carousel .partyCard { flex: 0 0 280px; }
|
||||
|
||||
.partyCard {
|
||||
display: flex;
|
||||
|
|
@ -162,16 +238,10 @@ button.linklike:hover { background: none; color: var(--accent-bright); }
|
|||
gap: 2px;
|
||||
}
|
||||
|
||||
.partyCardInfo strong {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.partyCardInfo strong { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.partyCard button { flex-shrink: 0; padding: 8px 14px; font-size: 13px; }
|
||||
|
||||
/* Tabs: one per Emby library. Horizontal scroll on narrow screens instead
|
||||
of wrapping, so it stays a single, obviously-swipeable row. */
|
||||
/* Tabs: one per Emby library (library.js) or per season (series.js). */
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
|
|
@ -194,6 +264,8 @@ button.linklike:hover { background: none; color: var(--accent-bright); }
|
|||
.tab:hover { background: none; color: var(--text); }
|
||||
.tab.active { color: var(--accent-bright); border-bottom-color: var(--accent-bright); }
|
||||
|
||||
/* ---- Tile grid (library.js) + tiles (shared with the carousels) ---- */
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
|
|
@ -216,8 +288,12 @@ button.linklike:hover { background: none; color: var(--accent-bright); }
|
|||
display: block;
|
||||
}
|
||||
|
||||
.tile img.noImage {
|
||||
visibility: hidden;
|
||||
.tile img.noImage { visibility: hidden; }
|
||||
|
||||
.tileLink {
|
||||
position: absolute;
|
||||
inset: 0 0 26px 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.tileLabel {
|
||||
|
|
@ -232,7 +308,8 @@ button.linklike:hover { background: none; color: var(--accent-bright); }
|
|||
see the (hover: none) block below, since :hover is unreachable there. */
|
||||
.tileOverlay {
|
||||
position: absolute;
|
||||
inset: 0 0 26px 0; /* stop above .tileLabel */
|
||||
inset: 0 0 26px 0;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
|
|
@ -243,11 +320,13 @@ button.linklike:hover { background: none; color: var(--accent-bright); }
|
|||
border-radius: 8px;
|
||||
opacity: 0;
|
||||
transition: opacity .15s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tile:hover .tileOverlay,
|
||||
.tile:focus-within .tileOverlay {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.tileBtn {
|
||||
|
|
@ -269,6 +348,7 @@ button.linklike:hover { background: none; color: var(--accent-bright); }
|
|||
@media (hover: none) {
|
||||
.tileOverlay {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
inset: auto 0 26px 0;
|
||||
flex-direction: row;
|
||||
background: linear-gradient(to top, rgba(0,0,0,.75), transparent);
|
||||
|
|
@ -277,6 +357,53 @@ button.linklike:hover { background: none; color: var(--accent-bright); }
|
|||
.tileBtn { font-size: 11px; padding: 6px 4px; }
|
||||
}
|
||||
|
||||
/* ---- Series page: seasons tabs + episode list ---- */
|
||||
|
||||
.episodeList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.episodeRow {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
background: var(--bg-header);
|
||||
border-radius: 10px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.episodeRow img {
|
||||
width: 120px;
|
||||
aspect-ratio: 16 / 9;
|
||||
object-fit: cover;
|
||||
border-radius: 6px;
|
||||
background: var(--panel);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.episodeRow img.noImage { visibility: hidden; }
|
||||
|
||||
.episodeInfo { flex: 1; min-width: 0; }
|
||||
.episodeInfo strong { display: block; margin-bottom: 4px; }
|
||||
.episodeInfo p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.episodeRow button { flex-shrink: 0; }
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.episodeRow { flex-wrap: wrap; }
|
||||
.episodeInfo { order: 3; flex-basis: 100%; }
|
||||
}
|
||||
|
||||
/* ---- Player ---- */
|
||||
|
||||
.playerWrap {
|
||||
|
|
@ -310,6 +437,35 @@ button.linklike:hover { background: none; color: var(--accent-bright); }
|
|||
.overlay .itemTitle { font-weight: 600; }
|
||||
.overlay .members { color: var(--muted); flex: 1; }
|
||||
|
||||
.hostControls {
|
||||
position: absolute;
|
||||
top: 64px;
|
||||
right: 16px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
background: rgba(0, 0, 0, .6);
|
||||
padding: 10px;
|
||||
border-radius: 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.hostControls label {
|
||||
color: var(--muted);
|
||||
margin-bottom: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.hostControls select {
|
||||
background: var(--panel);
|
||||
color: var(--text);
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 6px;
|
||||
padding: 4px 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.followerNotice {
|
||||
position: absolute;
|
||||
bottom: 24px;
|
||||
|
|
|
|||
|
|
@ -1,22 +1,37 @@
|
|||
import { route, start, navigate } from './router.js';
|
||||
import { auth, restoreSession } from './embyApi.js';
|
||||
import { renderShell } from './shell.js';
|
||||
import { renderLogin } from './views/login.js';
|
||||
import { renderBrowse } from './views/browse.js';
|
||||
import { renderHome } from './views/home.js';
|
||||
import { renderLibrary } from './views/library.js';
|
||||
import { renderSeries } from './views/series.js';
|
||||
import { renderPartyPlayer } from './views/player.js';
|
||||
|
||||
function requireAuth(renderFn) {
|
||||
return async (root, params) => {
|
||||
if (!auth.isLoggedIn && !(await restoreSession())) {
|
||||
navigate('/login');
|
||||
return;
|
||||
}
|
||||
renderFn(root, params);
|
||||
};
|
||||
route('/', () => navigate('/home'));
|
||||
route('/login', renderLogin);
|
||||
route('/home', renderHome);
|
||||
route('/library/:id', renderLibrary);
|
||||
route('/series/:id', renderSeries);
|
||||
route('/party/:id', renderPartyPlayer);
|
||||
|
||||
/**
|
||||
* Renders either the bare login screen or the full shell (sidebar + routed
|
||||
* content), depending on whether our session cookie is still valid. Exported
|
||||
* so login.js can call it again right after a successful sign-in - there's
|
||||
* no shell/router running yet at that point, just the login form.
|
||||
*/
|
||||
export async function boot() {
|
||||
const appEl = document.getElementById('app');
|
||||
const loggedIn = auth.isLoggedIn || await restoreSession();
|
||||
|
||||
if (!loggedIn) {
|
||||
appEl.innerHTML = '';
|
||||
renderLogin(appEl);
|
||||
return;
|
||||
}
|
||||
|
||||
const mainContent = await renderShell(appEl);
|
||||
start(mainContent);
|
||||
}
|
||||
|
||||
route('/', () => navigate('/browse'));
|
||||
route('/login', renderLogin);
|
||||
route('/browse', requireAuth(renderBrowse));
|
||||
route('/party/:id', requireAuth(renderPartyPlayer));
|
||||
|
||||
start(document.getElementById('app'));
|
||||
boot();
|
||||
|
|
|
|||
|
|
@ -68,6 +68,29 @@ export async function getLibraryItems(libraryId) {
|
|||
return data.items || [];
|
||||
}
|
||||
|
||||
export function streamUrl(itemId) {
|
||||
return `/api/catalog/stream/${itemId}/master.m3u8`;
|
||||
export async function getRecentItems(libraryId) {
|
||||
const data = await apiFetch(`/api/catalog/libraries/${libraryId}/recent`);
|
||||
return data.items || [];
|
||||
}
|
||||
|
||||
export async function getSeriesSeasons(seriesId) {
|
||||
const data = await apiFetch(`/api/catalog/series/${seriesId}/seasons`);
|
||||
return data.seasons || [];
|
||||
}
|
||||
|
||||
export async function getSeriesEpisodes(seriesId, seasonId) {
|
||||
const data = await apiFetch(`/api/catalog/series/${seriesId}/episodes?seasonId=${encodeURIComponent(seasonId)}`);
|
||||
return data.episodes || [];
|
||||
}
|
||||
|
||||
export async function getPlaybackOptions(itemId) {
|
||||
return apiFetch(`/api/catalog/item/${itemId}/playback-options`);
|
||||
}
|
||||
|
||||
/** `options` may carry AudioStreamIndex/SubtitleStreamIndex/MaxStreamingBitrate. */
|
||||
export function streamUrl(itemId, options = {}) {
|
||||
const qs = new URLSearchParams(
|
||||
Object.fromEntries(Object.entries(options).filter(([, v]) => v !== '' && v != null))
|
||||
).toString();
|
||||
return `/api/catalog/stream/${itemId}/master.m3u8${qs ? '?' + qs : ''}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ export function getParty(partyId) {
|
|||
* cookies to that handshake automatically for a same-origin URL) - so all
|
||||
* this needs to send is which party to join.
|
||||
*/
|
||||
export function connectParty(partyId, { onMembers, onPlaystate, onError, onClose } = {}) {
|
||||
export function connectParty(partyId, { onMembers, onPlaystate, onItemChanged, onError, onClose } = {}) {
|
||||
const proto = location.protocol === 'https:' ? 'wss://' : 'ws://';
|
||||
const ws = new WebSocket(proto + location.host + '/ws');
|
||||
|
||||
|
|
@ -55,6 +55,7 @@ export function connectParty(partyId, { onMembers, onPlaystate, onError, onClose
|
|||
try { msg = JSON.parse(evt.data); } catch (err) { return; }
|
||||
if (msg.type === 'members' && onMembers) onMembers(msg.members);
|
||||
if (msg.type === 'playstate' && onPlaystate) onPlaystate(msg);
|
||||
if (msg.type === 'itemChanged' && onItemChanged) onItemChanged(msg);
|
||||
if (msg.type === 'error' && onError) onError(msg.message);
|
||||
});
|
||||
|
||||
|
|
@ -66,6 +67,12 @@ export function connectParty(partyId, { onMembers, onPlaystate, onError, onClose
|
|||
ws.send(JSON.stringify({ type: 'playstate', isPaused, positionTicks }));
|
||||
}
|
||||
},
|
||||
/** Host-only: advance the party's queue (e.g. to the next episode). */
|
||||
next() {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'next' }));
|
||||
}
|
||||
},
|
||||
leave() {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'leave' }));
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@
|
|||
// (root, params) where params come from ":name" segments in the pattern.
|
||||
|
||||
const routes = [];
|
||||
let currentRoot = null;
|
||||
let listenerAttached = false;
|
||||
|
||||
export function route(pattern, handler) {
|
||||
const keys = [];
|
||||
|
|
@ -23,22 +25,33 @@ export function navigate(path) {
|
|||
location.hash = '#' + path;
|
||||
}
|
||||
|
||||
export function start(root) {
|
||||
function render() {
|
||||
const path = (location.hash.slice(1) || '/').split('?')[0];
|
||||
for (const r of routes) {
|
||||
const match = path.match(r.regex);
|
||||
if (match) {
|
||||
const params = {};
|
||||
r.keys.forEach((key, i) => { params[key] = decodeURIComponent(match[i + 1]); });
|
||||
root.innerHTML = '';
|
||||
r.handler(root, params);
|
||||
return;
|
||||
}
|
||||
function render() {
|
||||
if (!currentRoot) return;
|
||||
const [path, query] = (location.hash.slice(1) || '/').split('?');
|
||||
const params = Object.fromEntries(new URLSearchParams(query || ''));
|
||||
for (const r of routes) {
|
||||
const match = path.match(r.regex);
|
||||
if (match) {
|
||||
r.keys.forEach((key, i) => { params[key] = decodeURIComponent(match[i + 1]); });
|
||||
currentRoot.innerHTML = '';
|
||||
r.handler(currentRoot, params);
|
||||
return;
|
||||
}
|
||||
root.innerHTML = '<main class="wrap"><p>Page introuvable.</p></main>';
|
||||
}
|
||||
currentRoot.innerHTML = '<main class="wrap"><p>Page introuvable.</p></main>';
|
||||
}
|
||||
|
||||
window.addEventListener('hashchange', render);
|
||||
/**
|
||||
* (Re)targets the router at `root` and renders the current route into it.
|
||||
* Safe to call more than once (e.g. after a logout/login cycle re-renders
|
||||
* the app shell) - the hashchange listener is only ever attached once, so
|
||||
* repeated calls don't pile up duplicate listeners.
|
||||
*/
|
||||
export function start(root) {
|
||||
currentRoot = root;
|
||||
if (!listenerAttached) {
|
||||
window.addEventListener('hashchange', render);
|
||||
listenerAttached = true;
|
||||
}
|
||||
render();
|
||||
}
|
||||
|
|
|
|||
63
client/js/shell.js
Normal file
63
client/js/shell.js
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
// The persistent app chrome (sidebar with collections + main content area),
|
||||
// rendered once per login. The router (router.js) only ever swaps what's
|
||||
// inside #mainContent - see app.js's boot().
|
||||
import { auth, logout, getLibraries } from './embyApi.js';
|
||||
|
||||
const COLLECTION_ICONS = { movies: '🎬', tvshows: '📺', music: '🎵' };
|
||||
|
||||
export async function renderShell(appEl) {
|
||||
appEl.innerHTML = `
|
||||
<div class="appShell">
|
||||
<aside class="sidebar">
|
||||
<div class="sidebarBrand">Watch Party</div>
|
||||
<nav class="sidebarNav" id="sidebarNav">
|
||||
<a href="#/home" class="navItem">Accueil</a>
|
||||
<div class="navSectionLabel">Bibliothèques</div>
|
||||
<p class="hint" id="libStatus">Chargement...</p>
|
||||
</nav>
|
||||
<button class="linklike sidebarLogout" id="logout">Se déconnecter (${escapeHtml(auth.userName || '')})</button>
|
||||
</aside>
|
||||
<main id="mainContent" class="mainContent"></main>
|
||||
</div>`;
|
||||
|
||||
appEl.querySelector('#logout').addEventListener('click', async () => {
|
||||
await logout();
|
||||
// Simplest reliable way back to a bare login screen: a real reload,
|
||||
// rather than hand-tearing-down every shell/router listener in place.
|
||||
location.hash = '';
|
||||
location.reload();
|
||||
});
|
||||
|
||||
const nav = appEl.querySelector('#sidebarNav');
|
||||
const status = appEl.querySelector('#libStatus');
|
||||
try {
|
||||
const libraries = await getLibraries();
|
||||
status.remove();
|
||||
libraries.forEach((lib) => {
|
||||
const a = document.createElement('a');
|
||||
a.href = `#/library/${lib.id}`;
|
||||
a.className = 'navItem';
|
||||
a.textContent = `${COLLECTION_ICONS[lib.collectionType] || '📁'} ${lib.name}`;
|
||||
nav.appendChild(a);
|
||||
});
|
||||
} catch (err) {
|
||||
status.textContent = 'Bibliothèques indisponibles.';
|
||||
}
|
||||
|
||||
const highlight = () => {
|
||||
nav.querySelectorAll('.navItem').forEach((a) => {
|
||||
const isHome = a.getAttribute('href') === '#/home';
|
||||
a.classList.toggle('active', a.getAttribute('href') === location.hash || (isHome && !location.hash));
|
||||
});
|
||||
};
|
||||
highlight();
|
||||
window.addEventListener('hashchange', highlight);
|
||||
|
||||
return appEl.querySelector('#mainContent');
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
46
client/js/tiles.js
Normal file
46
client/js/tiles.js
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
// Shared poster-tile rendering - used by the home carousels and the library
|
||||
// grid, so the two stay visually/behaviorally identical instead of drifting.
|
||||
|
||||
export function escapeHtml(str) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
export function tileHtml(item) {
|
||||
const seriesHref = item.type === 'Series' ? `#/series/${item.id}` : null;
|
||||
return `
|
||||
<div class="tile">
|
||||
${seriesHref ? `<a href="${seriesHref}" class="tileLink" aria-label="${escapeHtml(item.name)}"></a>` : ''}
|
||||
<img src="${item.imageUrl}" alt="" loading="lazy" onerror="this.classList.add('noImage')" />
|
||||
<div class="tileOverlay">
|
||||
${item.canWatchParty ? `<button class="tileBtn startPartyBtn" data-item-id="${item.id}">Lancer une watch party</button>` : ''}
|
||||
${seriesHref ? `<a class="tileBtn" href="${seriesHref}">Voir les épisodes</a>` : ''}
|
||||
<a class="tileBtn tileBtnLink" href="${item.embyUrl}" target="_blank" rel="noopener">Voir sur Emby</a>
|
||||
</div>
|
||||
<span class="tileLabel">${escapeHtml(item.name)}${item.year ? ` (${item.year})` : ''}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wires up every "Lancer une watch party" button inside `container`.
|
||||
* `onStart(itemId)` does the actual work (create the party + navigate) - kept
|
||||
* as a caller-supplied callback so this module doesn't need to depend on
|
||||
* party.js/router.js itself.
|
||||
*/
|
||||
export function wireTileButtons(container, onStart) {
|
||||
container.querySelectorAll('.startPartyBtn').forEach((btn) => {
|
||||
btn.addEventListener('click', async (e) => {
|
||||
e.preventDefault();
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Démarrage...';
|
||||
try {
|
||||
await onStart(btn.dataset.itemId);
|
||||
} catch (err) {
|
||||
alert(err.message);
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Lancer une watch party';
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -1,154 +0,0 @@
|
|||
import { auth, logout, getLibraries, getLibraryItems } from '../embyApi.js';
|
||||
import { listActiveParties, createParty, joinParty } from '../party.js';
|
||||
import { navigate } from '../router.js';
|
||||
|
||||
export function renderBrowse(root) {
|
||||
root.innerHTML = `
|
||||
<main class="home">
|
||||
<div class="topbar">
|
||||
<h1>Watch Party</h1>
|
||||
<button class="linklike" id="logout">Se déconnecter (${escapeHtml(auth.userName || '')})</button>
|
||||
</div>
|
||||
|
||||
<section id="partiesSection" class="section hide">
|
||||
<h2>Watch parties en cours</h2>
|
||||
<div id="partiesList" class="partiesList"></div>
|
||||
</section>
|
||||
|
||||
<section class="section joinSection">
|
||||
<label>Rejoindre avec un code
|
||||
<input id="joinCode" maxlength="6" style="text-transform:uppercase" />
|
||||
</label>
|
||||
<button id="joinBtn">Rejoindre</button>
|
||||
<p id="joinError" class="error"></p>
|
||||
</section>
|
||||
|
||||
<nav id="tabs" class="tabs"></nav>
|
||||
<div id="tabContent" class="grid"><p class="hint">Chargement...</p></div>
|
||||
</main>`;
|
||||
|
||||
root.querySelector('#logout').addEventListener('click', async () => {
|
||||
await logout();
|
||||
navigate('/login');
|
||||
});
|
||||
|
||||
root.querySelector('#joinBtn').addEventListener('click', async () => {
|
||||
const code = root.querySelector('#joinCode').value.trim();
|
||||
if (!code) return;
|
||||
try {
|
||||
const party = await joinParty(code);
|
||||
navigate(`/party/${party.partyId}`);
|
||||
} catch (err) {
|
||||
root.querySelector('#joinError').textContent = err.message;
|
||||
}
|
||||
});
|
||||
|
||||
loadParties(root);
|
||||
loadLibraries(root);
|
||||
}
|
||||
|
||||
async function loadParties(root) {
|
||||
let parties;
|
||||
try {
|
||||
parties = await listActiveParties();
|
||||
} catch (err) {
|
||||
return; // non-critical for the page as a whole - just skip the section
|
||||
}
|
||||
if (!parties.length) return;
|
||||
|
||||
const section = root.querySelector('#partiesSection');
|
||||
section.classList.remove('hide');
|
||||
root.querySelector('#partiesList').innerHTML = parties.map((p) => `
|
||||
<div class="partyCard">
|
||||
<img src="${p.itemImageUrl || ''}" alt="" onerror="this.classList.add('noImage')" />
|
||||
<div class="partyCardInfo">
|
||||
<strong>${escapeHtml(p.itemName || '')}</strong>
|
||||
<span class="hint">${escapeHtml(p.hostUserName || '')} · ${p.memberCount} participant${p.memberCount > 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
<button class="joinPartyBtn" data-party-id="${p.partyId}">Rejoindre</button>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
root.querySelectorAll('.joinPartyBtn').forEach((btn) => {
|
||||
btn.addEventListener('click', () => navigate(`/party/${btn.dataset.partyId}`));
|
||||
});
|
||||
}
|
||||
|
||||
async function loadLibraries(root) {
|
||||
const tabsEl = root.querySelector('#tabs');
|
||||
const contentEl = root.querySelector('#tabContent');
|
||||
|
||||
let libraries;
|
||||
try {
|
||||
libraries = await getLibraries();
|
||||
} catch (err) {
|
||||
contentEl.innerHTML = '<p class="error">Impossible de charger les bibliothèques Emby.</p>';
|
||||
return;
|
||||
}
|
||||
if (!libraries.length) {
|
||||
contentEl.innerHTML = '<p class="hint">Aucune bibliothèque disponible sur ce compte.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
tabsEl.innerHTML = libraries
|
||||
.map((lib, i) => `<button class="tab${i === 0 ? ' active' : ''}" data-library-id="${lib.id}">${escapeHtml(lib.name)}</button>`)
|
||||
.join('');
|
||||
|
||||
tabsEl.querySelectorAll('.tab').forEach((tab) => {
|
||||
tab.addEventListener('click', () => {
|
||||
if (tab.classList.contains('active')) return;
|
||||
tabsEl.querySelectorAll('.tab').forEach((t) => t.classList.remove('active'));
|
||||
tab.classList.add('active');
|
||||
loadLibraryItems(contentEl, tab.dataset.libraryId);
|
||||
});
|
||||
});
|
||||
|
||||
loadLibraryItems(contentEl, libraries[0].id);
|
||||
}
|
||||
|
||||
async function loadLibraryItems(contentEl, libraryId) {
|
||||
contentEl.innerHTML = '<p class="hint">Chargement...</p>';
|
||||
let items;
|
||||
try {
|
||||
items = await getLibraryItems(libraryId);
|
||||
} catch (err) {
|
||||
contentEl.innerHTML = '<p class="error">Impossible de charger cette bibliothèque.</p>';
|
||||
return;
|
||||
}
|
||||
if (!items.length) {
|
||||
contentEl.innerHTML = '<p class="hint">Bibliothèque vide.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
contentEl.innerHTML = items.map((item) => `
|
||||
<div class="tile">
|
||||
<img src="${item.imageUrl}" alt="" loading="lazy" onerror="this.classList.add('noImage')" />
|
||||
<div class="tileOverlay">
|
||||
${item.canWatchParty ? `<button class="tileBtn startPartyBtn" data-item-id="${item.id}">Lancer une watch party</button>` : ''}
|
||||
<a class="tileBtn tileBtnLink" href="${item.embyUrl}" target="_blank" rel="noopener">Voir sur Emby</a>
|
||||
</div>
|
||||
<span class="tileLabel">${escapeHtml(item.name)}${item.year ? ` (${item.year})` : ''}</span>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
contentEl.querySelectorAll('.startPartyBtn').forEach((btn) => {
|
||||
btn.addEventListener('click', async () => {
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Démarrage...';
|
||||
try {
|
||||
const party = await createParty(btn.dataset.itemId);
|
||||
navigate(`/party/${party.partyId}`);
|
||||
} catch (err) {
|
||||
alert(err.message);
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Lancer une watch party';
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
98
client/js/views/home.js
Normal file
98
client/js/views/home.js
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import { getLibraries, getRecentItems } from '../embyApi.js';
|
||||
import { listActiveParties, createParty, joinParty } from '../party.js';
|
||||
import { navigate } from '../router.js';
|
||||
import { tileHtml, wireTileButtons, escapeHtml } from '../tiles.js';
|
||||
|
||||
export function renderHome(root) {
|
||||
root.innerHTML = `
|
||||
<div class="pageHeader">
|
||||
<h1>Accueil</h1>
|
||||
<div class="joinInline">
|
||||
<input id="joinCode" maxlength="6" placeholder="Code de party" style="text-transform:uppercase" />
|
||||
<button id="joinBtn">Rejoindre</button>
|
||||
</div>
|
||||
</div>
|
||||
<p id="joinError" class="error"></p>
|
||||
|
||||
<section class="section" id="partiesSection" hidden>
|
||||
<h2>Watch parties en cours</h2>
|
||||
<div id="partiesCarousel" class="carousel"></div>
|
||||
</section>
|
||||
|
||||
<div id="recentSections"></div>`;
|
||||
|
||||
root.querySelector('#joinBtn').addEventListener('click', async () => {
|
||||
const code = root.querySelector('#joinCode').value.trim();
|
||||
if (!code) return;
|
||||
try {
|
||||
const party = await joinParty(code);
|
||||
navigate(`/party/${party.partyId}`);
|
||||
} catch (err) {
|
||||
root.querySelector('#joinError').textContent = err.message;
|
||||
}
|
||||
});
|
||||
|
||||
loadParties(root);
|
||||
loadRecentSections(root);
|
||||
}
|
||||
|
||||
async function loadParties(root) {
|
||||
let parties;
|
||||
try {
|
||||
parties = await listActiveParties();
|
||||
} catch (err) {
|
||||
return; // non-critical - the rest of the home page still works
|
||||
}
|
||||
if (!parties.length) return;
|
||||
|
||||
root.querySelector('#partiesSection').hidden = false;
|
||||
root.querySelector('#partiesCarousel').innerHTML = parties.map((p) => `
|
||||
<div class="partyCard">
|
||||
<img src="${p.itemImageUrl || ''}" alt="" onerror="this.classList.add('noImage')" />
|
||||
<div class="partyCardInfo">
|
||||
<strong>${escapeHtml(p.itemName || '')}</strong>
|
||||
<span class="hint">${escapeHtml(p.hostUserName || '')} · ${p.memberCount} participant${p.memberCount > 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
<button class="joinPartyBtn" data-party-id="${p.partyId}">Rejoindre</button>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
root.querySelectorAll('.joinPartyBtn').forEach((btn) => {
|
||||
btn.addEventListener('click', () => navigate(`/party/${btn.dataset.partyId}`));
|
||||
});
|
||||
}
|
||||
|
||||
async function loadRecentSections(root) {
|
||||
const container = root.querySelector('#recentSections');
|
||||
let libraries;
|
||||
try {
|
||||
libraries = await getLibraries();
|
||||
} catch (err) {
|
||||
container.innerHTML = '<p class="error">Impossible de charger les bibliothèques Emby.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
for (const lib of libraries) {
|
||||
if (lib.collectionType === 'music') continue; // music gets its own experience later, not a poster row
|
||||
|
||||
const section = document.createElement('section');
|
||||
section.className = 'section';
|
||||
section.innerHTML = `<h2>Ajouts récents - ${escapeHtml(lib.name)}</h2><div class="carousel"><p class="hint">Chargement...</p></div>`;
|
||||
container.appendChild(section);
|
||||
|
||||
getRecentItems(lib.id)
|
||||
.then((items) => {
|
||||
if (!items.length) {
|
||||
section.remove();
|
||||
return;
|
||||
}
|
||||
const carousel = section.querySelector('.carousel');
|
||||
carousel.innerHTML = items.map(tileHtml).join('');
|
||||
wireTileButtons(carousel, async (itemId) => {
|
||||
const party = await createParty(itemId);
|
||||
navigate(`/party/${party.partyId}`);
|
||||
});
|
||||
})
|
||||
.catch(() => section.remove());
|
||||
}
|
||||
}
|
||||
66
client/js/views/library.js
Normal file
66
client/js/views/library.js
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { getLibraries, getLibraryItems } from '../embyApi.js';
|
||||
import { createParty } from '../party.js';
|
||||
import { navigate } from '../router.js';
|
||||
import { tileHtml, wireTileButtons, escapeHtml } from '../tiles.js';
|
||||
|
||||
/**
|
||||
* Generic "one library" page, reached from the sidebar. What it renders
|
||||
* depends entirely on the library's Emby CollectionType:
|
||||
* - movies/tvshows (or anything else playable): a tile grid, same as the
|
||||
* home page's carousels (Series tiles link into /series/:id instead of
|
||||
* starting a party directly - see tiles.js).
|
||||
* - music: a placeholder for now - see docs/architecture.md, deliberately
|
||||
* out of scope for this pass (needs its own player + collaborative
|
||||
* playlist, not just a grid).
|
||||
*/
|
||||
export async function renderLibrary(root, params) {
|
||||
root.innerHTML = '<p class="hint">Chargement...</p>';
|
||||
|
||||
let libraries;
|
||||
try {
|
||||
libraries = await getLibraries();
|
||||
} catch (err) {
|
||||
root.innerHTML = '<p class="error">Impossible de charger les bibliothèques.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
const library = libraries.find((l) => l.id === params.id);
|
||||
if (!library) {
|
||||
root.innerHTML = '<p class="error">Bibliothèque introuvable.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
if (library.collectionType === 'music') {
|
||||
root.innerHTML = `
|
||||
<div class="pageHeader"><h1>${escapeHtml(library.name)}</h1></div>
|
||||
<p class="hint">
|
||||
La musique arrive dans une prochaine version : lecteur dédié, navigation
|
||||
artiste / album, et une playlist partagée où chaque membre de la party
|
||||
peut ajouter des morceaux. Pas encore disponible ici.
|
||||
</p>`;
|
||||
return;
|
||||
}
|
||||
|
||||
root.innerHTML = `
|
||||
<div class="pageHeader"><h1>${escapeHtml(library.name)}</h1></div>
|
||||
<div id="grid" class="grid"><p class="hint">Chargement...</p></div>`;
|
||||
|
||||
const grid = root.querySelector('#grid');
|
||||
let items;
|
||||
try {
|
||||
items = await getLibraryItems(library.id);
|
||||
} catch (err) {
|
||||
grid.innerHTML = '<p class="error">Impossible de charger cette bibliothèque.</p>';
|
||||
return;
|
||||
}
|
||||
if (!items.length) {
|
||||
grid.innerHTML = '<p class="hint">Bibliothèque vide.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
grid.innerHTML = items.map(tileHtml).join('');
|
||||
wireTileButtons(grid, async (itemId) => {
|
||||
const party = await createParty(itemId);
|
||||
navigate(`/party/${party.partyId}`);
|
||||
});
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { login } from '../embyApi.js';
|
||||
import { navigate } from '../router.js';
|
||||
import { boot } from '../app.js';
|
||||
|
||||
export function renderLogin(root) {
|
||||
root.innerHTML = `
|
||||
|
|
@ -25,7 +25,7 @@ export function renderLogin(root) {
|
|||
errorEl.textContent = '';
|
||||
try {
|
||||
await login(form.querySelector('#username').value, form.querySelector('#password').value);
|
||||
navigate('/browse');
|
||||
await boot(); // no shell/router running yet at the login screen - (re)boot into it now
|
||||
} catch (err) {
|
||||
errorEl.textContent = "Connexion impossible - vérifie ton nom d'utilisateur et ton mot de passe.";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,21 @@
|
|||
import { streamUrl } from '../embyApi.js';
|
||||
import { streamUrl, getPlaybackOptions } from '../embyApi.js';
|
||||
import { getParty, leaveParty, connectParty } from '../party.js';
|
||||
import { navigate } from '../router.js';
|
||||
import { escapeHtml } from '../tiles.js';
|
||||
|
||||
const TICKS_PER_SECOND = 10000000; // Emby/.NET convention: 1 tick = 100ns
|
||||
const DRIFT_TOLERANCE_SECONDS = 1.5;
|
||||
|
||||
// "Quality" is really "max transcoding bitrate", the same simplification
|
||||
// Emby's/Jellyfin's own web clients use - not true adaptive-bitrate
|
||||
// switching. See docs/architecture.md.
|
||||
const QUALITY_PRESETS = [
|
||||
{ label: 'Auto (qualité source)', value: '' },
|
||||
{ label: '1080p (~8 Mbps)', value: '8000000' },
|
||||
{ label: '720p (~4 Mbps)', value: '4000000' },
|
||||
{ label: '480p (~1.5 Mbps)', value: '1500000' }
|
||||
];
|
||||
|
||||
export function renderPartyPlayer(root, params) {
|
||||
const partyId = params.id;
|
||||
|
||||
|
|
@ -15,8 +26,14 @@ export function renderPartyPlayer(root, params) {
|
|||
<span id="itemTitle" class="itemTitle"></span>
|
||||
<span>Code: <span id="code" class="code"></span></span>
|
||||
<span id="members" class="members"></span>
|
||||
<button id="nextBtn" class="linklike hide">Épisode suivant →</button>
|
||||
<button id="leaveBtn" class="linklike">Quitter</button>
|
||||
</div>
|
||||
<div id="hostControls" class="hostControls hide">
|
||||
<label>Langue <select id="audioSelect"></select></label>
|
||||
<label>Sous-titres <select id="subtitleSelect"><option value="">Aucun</option></select></label>
|
||||
<label>Qualité <select id="qualitySelect"></select></label>
|
||||
</div>
|
||||
<p id="followerNotice" class="followerNotice hide">L'hôte contrôle la lecture.</p>
|
||||
</div>`;
|
||||
|
||||
|
|
@ -24,12 +41,22 @@ export function renderPartyPlayer(root, params) {
|
|||
const itemTitleEl = root.querySelector('#itemTitle');
|
||||
const codeEl = root.querySelector('#code');
|
||||
const membersEl = root.querySelector('#members');
|
||||
const nextBtn = root.querySelector('#nextBtn');
|
||||
const followerNotice = root.querySelector('#followerNotice');
|
||||
const hostControls = root.querySelector('#hostControls');
|
||||
const audioSelect = root.querySelector('#audioSelect');
|
||||
const subtitleSelect = root.querySelector('#subtitleSelect');
|
||||
const qualitySelect = root.querySelector('#qualitySelect');
|
||||
|
||||
qualitySelect.innerHTML = QUALITY_PRESETS.map((q) => `<option value="${q.value}">${q.label}</option>`).join('');
|
||||
|
||||
let isHost = false;
|
||||
let ws = null;
|
||||
let hls = null;
|
||||
let currentItemId = null;
|
||||
let suppressLocalEvents = false; // avoid feeding our own correction back as a new event
|
||||
let heartbeat = null;
|
||||
let hasNextQueued = false;
|
||||
|
||||
function applyRemoteState(msg) {
|
||||
suppressLocalEvents = true;
|
||||
|
|
@ -47,13 +74,74 @@ export function renderPartyPlayer(root, params) {
|
|||
ws.sendPlaystate(video.paused, Math.round(video.currentTime * TICKS_PER_SECOND));
|
||||
}
|
||||
|
||||
async function loadTrackOptions(itemId) {
|
||||
try {
|
||||
const options = await getPlaybackOptions(itemId);
|
||||
audioSelect.innerHTML = options.audioTracks.length
|
||||
? options.audioTracks.map((t) => `<option value="${t.index}">${escapeHtml(t.label)}</option>`).join('')
|
||||
: '<option value="">Piste par défaut</option>';
|
||||
subtitleSelect.innerHTML = '<option value="">Aucun</option>' +
|
||||
options.subtitleTracks.map((t) => `<option value="${t.index}">${escapeHtml(t.label)}</option>`).join('');
|
||||
} catch (err) {
|
||||
// Non-fatal: playback still works with Emby's own defaults, just without a picker.
|
||||
}
|
||||
}
|
||||
|
||||
function loadVideo(itemId, resumeSeconds, wasPlaying) {
|
||||
currentItemId = itemId;
|
||||
if (hls) {
|
||||
hls.destroy();
|
||||
hls = null;
|
||||
}
|
||||
|
||||
const src = streamUrl(itemId, {
|
||||
AudioStreamIndex: audioSelect.value,
|
||||
SubtitleStreamIndex: subtitleSelect.value,
|
||||
MaxStreamingBitrate: qualitySelect.value
|
||||
});
|
||||
|
||||
const resume = () => {
|
||||
if (resumeSeconds) video.currentTime = resumeSeconds;
|
||||
if (wasPlaying) video.play().catch(() => {});
|
||||
};
|
||||
|
||||
if (window.Hls && window.Hls.isSupported()) {
|
||||
hls = new window.Hls();
|
||||
hls.loadSource(src);
|
||||
hls.attachMedia(video);
|
||||
hls.once(window.Hls.Events.MANIFEST_PARSED, resume);
|
||||
} else {
|
||||
video.src = src;
|
||||
video.addEventListener('loadedmetadata', resume, { once: true });
|
||||
}
|
||||
}
|
||||
|
||||
// Changing language/subtitles/quality means a fresh transcode (Emby bakes
|
||||
// the selection in server-side - see index.js) - reload at the same
|
||||
// position instead of restarting from zero.
|
||||
function reloadWithCurrentOptions() {
|
||||
loadVideo(currentItemId, video.currentTime, !video.paused);
|
||||
}
|
||||
|
||||
[audioSelect, subtitleSelect, qualitySelect].forEach((sel) => {
|
||||
sel.addEventListener('change', reloadWithCurrentOptions);
|
||||
});
|
||||
|
||||
async function onItemChanged(msg) {
|
||||
itemTitleEl.textContent = msg.itemName || '';
|
||||
hasNextQueued = !!msg.hasNext;
|
||||
nextBtn.classList.toggle('hide', !(isHost && hasNextQueued));
|
||||
await loadTrackOptions(msg.itemId);
|
||||
loadVideo(msg.itemId, 0, true);
|
||||
}
|
||||
|
||||
async function init() {
|
||||
let party;
|
||||
try {
|
||||
party = await getParty(partyId);
|
||||
} catch (err) {
|
||||
alert(err.message);
|
||||
navigate('/browse');
|
||||
navigate('/home');
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -61,20 +149,18 @@ export function renderPartyPlayer(root, params) {
|
|||
itemTitleEl.textContent = party.itemName || '';
|
||||
codeEl.textContent = party.joinCode;
|
||||
video.controls = isHost;
|
||||
hostControls.classList.toggle('hide', !isHost);
|
||||
followerNotice.classList.toggle('hide', isHost);
|
||||
hasNextQueued = !!party.hasNext;
|
||||
nextBtn.classList.toggle('hide', !(isHost && hasNextQueued));
|
||||
|
||||
const src = streamUrl(party.itemId);
|
||||
if (window.Hls && window.Hls.isSupported()) {
|
||||
const hls = new window.Hls();
|
||||
hls.loadSource(src);
|
||||
hls.attachMedia(video);
|
||||
} else {
|
||||
video.src = src;
|
||||
}
|
||||
await loadTrackOptions(party.itemId);
|
||||
loadVideo(party.itemId, 0, false);
|
||||
|
||||
ws = connectParty(partyId, {
|
||||
onMembers: renderMembers,
|
||||
onPlaystate: applyRemoteState,
|
||||
onItemChanged,
|
||||
onError: (message) => alert(message),
|
||||
onClose: () => {}
|
||||
});
|
||||
|
|
@ -83,6 +169,7 @@ export function renderPartyPlayer(root, params) {
|
|||
video.addEventListener('play', sendLocalState);
|
||||
video.addEventListener('pause', sendLocalState);
|
||||
video.addEventListener('seeked', sendLocalState);
|
||||
video.addEventListener('ended', () => { if (hasNextQueued) ws.next(); });
|
||||
heartbeat = setInterval(sendLocalState, 3000);
|
||||
}
|
||||
}
|
||||
|
|
@ -93,11 +180,14 @@ export function renderPartyPlayer(root, params) {
|
|||
.join(', ');
|
||||
}
|
||||
|
||||
nextBtn.addEventListener('click', () => { if (ws) ws.next(); });
|
||||
|
||||
root.querySelector('#leaveBtn').addEventListener('click', async () => {
|
||||
if (heartbeat) clearInterval(heartbeat);
|
||||
if (hls) hls.destroy();
|
||||
if (ws) ws.leave();
|
||||
await leaveParty(partyId).catch(() => {});
|
||||
navigate('/browse');
|
||||
navigate('/home');
|
||||
});
|
||||
|
||||
init();
|
||||
|
|
|
|||
93
client/js/views/series.js
Normal file
93
client/js/views/series.js
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import { getItem, getSeriesSeasons, getSeriesEpisodes } from '../embyApi.js';
|
||||
import { createParty } from '../party.js';
|
||||
import { navigate } from '../router.js';
|
||||
import { escapeHtml } from '../tiles.js';
|
||||
|
||||
export async function renderSeries(root, params) {
|
||||
const seriesId = params.id;
|
||||
root.innerHTML = '<p class="hint">Chargement...</p>';
|
||||
|
||||
let series;
|
||||
let seasons;
|
||||
try {
|
||||
[series, seasons] = await Promise.all([getItem(seriesId), getSeriesSeasons(seriesId)]);
|
||||
} catch (err) {
|
||||
root.innerHTML = '<p class="error">Impossible de charger cette série.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
root.innerHTML = `
|
||||
<button class="linklike" id="back">← Retour</button>
|
||||
<div class="pageHeader"><h1>${escapeHtml(series.name)}</h1></div>
|
||||
<nav id="seasonTabs" class="tabs"></nav>
|
||||
<div id="episodeList" class="episodeList"><p class="hint">Chargement...</p></div>`;
|
||||
|
||||
root.querySelector('#back').addEventListener('click', () => history.back());
|
||||
|
||||
if (!seasons.length) {
|
||||
root.querySelector('#episodeList').innerHTML = '<p class="hint">Aucune saison trouvée.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
const tabsEl = root.querySelector('#seasonTabs');
|
||||
tabsEl.innerHTML = seasons
|
||||
.map((s, i) => `<button class="tab${i === 0 ? ' active' : ''}" data-season-id="${s.id}">${escapeHtml(s.name)}</button>`)
|
||||
.join('');
|
||||
|
||||
tabsEl.querySelectorAll('.tab').forEach((tab) => {
|
||||
tab.addEventListener('click', () => {
|
||||
if (tab.classList.contains('active')) return;
|
||||
tabsEl.querySelectorAll('.tab').forEach((t) => t.classList.remove('active'));
|
||||
tab.classList.add('active');
|
||||
loadEpisodes(root, seriesId, tab.dataset.seasonId);
|
||||
});
|
||||
});
|
||||
|
||||
loadEpisodes(root, seriesId, seasons[0].id);
|
||||
}
|
||||
|
||||
async function loadEpisodes(root, seriesId, seasonId) {
|
||||
const listEl = root.querySelector('#episodeList');
|
||||
listEl.innerHTML = '<p class="hint">Chargement...</p>';
|
||||
|
||||
let episodes;
|
||||
try {
|
||||
episodes = await getSeriesEpisodes(seriesId, seasonId);
|
||||
} catch (err) {
|
||||
listEl.innerHTML = '<p class="error">Impossible de charger les épisodes.</p>';
|
||||
return;
|
||||
}
|
||||
if (!episodes.length) {
|
||||
listEl.innerHTML = '<p class="hint">Aucun épisode dans cette saison.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Starting a party from any episode here queues the rest of the series
|
||||
// from that point on server-side (see server/src/index.js) - the host can
|
||||
// then use "Épisode suivant" in the player instead of restarting per episode.
|
||||
listEl.innerHTML = episodes.map((ep) => `
|
||||
<div class="episodeRow">
|
||||
<img src="${ep.imageUrl}" alt="" loading="lazy" onerror="this.classList.add('noImage')" />
|
||||
<div class="episodeInfo">
|
||||
<strong>${ep.year ? ep.year + ' · ' : ''}${escapeHtml(ep.name)}</strong>
|
||||
<p class="hint">${escapeHtml(ep.overview || '')}</p>
|
||||
</div>
|
||||
<button class="startEpisodeBtn" data-item-id="${ep.id}">Lancer une watch party</button>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
listEl.querySelectorAll('.startEpisodeBtn').forEach((btn) => {
|
||||
btn.addEventListener('click', async () => {
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Démarrage...';
|
||||
try {
|
||||
const party = await createParty(btn.dataset.itemId);
|
||||
navigate(`/party/${party.partyId}`);
|
||||
} catch (err) {
|
||||
alert(err.message);
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Lancer une watch party';
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -181,47 +181,113 @@ point is confirmed:
|
|||
matches the long-standing convention, not confirmed against this exact
|
||||
build's routing.
|
||||
|
||||
## Home page: library tabs, tiles, ongoing parties
|
||||
## UI: Emby-like shell, per-type browsing, episode queue
|
||||
|
||||
`client/js/views/browse.js` (the home page, route `/browse`) is now:
|
||||
Reworked to look and navigate more like Emby itself, and to treat each
|
||||
collection type (movies / TV / music) as its own experience rather than one
|
||||
generic grid:
|
||||
|
||||
- **A "watch parties en cours" section** listing every active party
|
||||
server-wide (`GET /api/parties` — deliberately not filtered to "your"
|
||||
parties, since the point is letting people discover and join one without
|
||||
needing a code passed to them separately), each with a direct "Rejoindre".
|
||||
- **A join-by-code fallback** below it, for a party that isn't visible there
|
||||
for whatever reason.
|
||||
- **One tab per Emby library** (`GET /api/catalog/libraries`, i.e. Emby's
|
||||
`/Users/{id}/Views`), each showing that library's top-level items
|
||||
(`GET /api/catalog/libraries/:id/items`, `Recursive=false` — Movies as
|
||||
movies, TV shows as Series tiles, not every episode flattened together).
|
||||
- **Tiles**: poster + name, with a hover-revealed overlay carrying two
|
||||
actions - "Lancer une watch party" (only for directly-playable types:
|
||||
Movie/Episode/Video — a Series can't be streamed as-is, so it only gets
|
||||
the second action) and "Voir sur Emby" (opens Emby's own web client to
|
||||
that item in a new tab, via a `embyUrl` the backend builds from
|
||||
`EMBY_SERVER_URL` + the session's `serverId`, captured at login).
|
||||
`:hover`/`:focus-within` reveal the overlay on mouse/keyboard; a
|
||||
`@media (hover: none)` fallback keeps it always visible (smaller, inline)
|
||||
on touch devices, which have no hover state to trigger it at all.
|
||||
- **Full width, no max-width cap** (`.home`), with a plain CSS grid
|
||||
(`repeat(auto-fill, minmax(160px, 1fr))`) doing all the responsive
|
||||
reflowing — no breakpoints needed.
|
||||
- **`client/js/shell.js`** renders the persistent chrome once per login: a
|
||||
sidebar (collections from `GET /api/catalog/libraries`, one icon per
|
||||
`collectionType`) + a `#mainContent` element the router (`router.js`)
|
||||
swaps on every navigation. `router.js` was made idempotent
|
||||
(`start(root)` can be called again safely) specifically so login → shell
|
||||
boot can happen more than once in a page's lifetime without piling up
|
||||
duplicate `hashchange` listeners — the same class of bug the earlier Emby
|
||||
plugin work ran into with its page lifecycle, worth remembering.
|
||||
- **`views/home.js`** (route `/home`): a horizontal carousel of every active
|
||||
party server-wide (`GET /api/parties`, deliberately not filtered to "your"
|
||||
parties — the point is letting people discover and join one without a
|
||||
code passed out of band) plus a join-by-code fallback, then one more
|
||||
carousel per library of its recently-added items
|
||||
(`GET /api/catalog/libraries/:id/recent`, Emby's own
|
||||
`/Users/{id}/Items/Latest`) — skipping music (see below).
|
||||
- **`views/library.js`** (route `/library/:id`): dispatches purely on the
|
||||
library's `collectionType`. `movies`/`tvshows`/anything else gets a tile
|
||||
grid (`GET /api/catalog/libraries/:id/items`, `Recursive=false` — Movies
|
||||
as movies, TV as Series tiles); `music` gets an honest "not built yet"
|
||||
message instead of a half-working page (see Non-goals).
|
||||
- **Tiles** (`client/js/tiles.js`, shared by the home carousels and the
|
||||
library grid so they can't drift apart): poster + name, hover-revealed
|
||||
overlay with "Lancer une watch party" (only for directly-playable types —
|
||||
a Series tile gets "Voir les épisodes" instead, linking to `/series/:id`)
|
||||
and "Voir sur Emby" (opens Emby's own web client via a `embyUrl` the
|
||||
backend builds from `EMBY_SERVER_URL` + the session's `serverId`).
|
||||
`:hover`/`:focus-within` reveal it on mouse/keyboard; `@media (hover: none)`
|
||||
keeps it always visible on touch devices, which have no hover state at all.
|
||||
- **`views/series.js`** (route `/series/:id`): season tabs
|
||||
(`GET /api/catalog/series/:id/seasons`, Emby's `/Shows/{id}/Seasons`) and
|
||||
an episode list per season (`GET /api/catalog/series/:id/episodes`, Emby's
|
||||
`/Shows/{id}/Episodes?SeasonId=...`), each episode startable as its own
|
||||
party.
|
||||
|
||||
Confirmed by actually driving a browser against this (see session history):
|
||||
tab switching swaps grid content correctly, the hover overlay shows/hides
|
||||
per-tile (not globally), a Series tile correctly omits the watch-party
|
||||
button, and the touch fallback (`(hover: none)`, tested via mobile viewport
|
||||
emulation) shows both buttons inline without needing hover at all. That
|
||||
test used a mocked `fetch` (canned JSON standing in for `/api/parties` and
|
||||
`/api/catalog/...`) since exercising it for real still needs a logged-in
|
||||
session — so the rendering/interaction logic is verified, but the real Emby
|
||||
response shapes for `/Views` and library items are not (see below).
|
||||
### Episode queue / "next episode"
|
||||
|
||||
Starting a party from an episode (`POST /api/parties`) now has the backend
|
||||
fetch the rest of that series (`/Shows/{seriesId}/Episodes`, no season
|
||||
filter) and store everything from the clicked episode onward as the party's
|
||||
`queue` (`server/src/partyManager.js`). The host gets an "Épisode suivant"
|
||||
button once `hasNext` is true; clicking it (or the video's `ended` event
|
||||
firing) sends a `{type:"next"}` WebSocket message
|
||||
(`server/src/wsHandler.js`), host-only, which advances `queueIndex`,
|
||||
resets every member's known position, and broadcasts `{type:"itemChanged",
|
||||
itemId, itemName, ...}` — every client (including the host) reloads the
|
||||
player against the new `itemId` on that message. A movie or a
|
||||
standalone-started episode just gets a one-item queue, so this is a strict
|
||||
superset of the old single-item behavior, not a separate code path.
|
||||
|
||||
### Player: language, subtitles, quality
|
||||
|
||||
`GET /api/catalog/item/:id/playback-options` reads `PlaybackInfo`'s
|
||||
`MediaSources[0].MediaStreams` and returns the Audio/Subtitle entries as
|
||||
`{index, label}` pairs for two `<select>`s in the player's host-only
|
||||
controls. Picking a value reloads the stream
|
||||
(`/api/catalog/stream/:id/master.m3u8?AudioStreamIndex=&SubtitleStreamIndex=&MaxStreamingBitrate=`)
|
||||
at the same position (`hls.js` destroyed and rebuilt, `currentTime`/paused
|
||||
state preserved across the reload) rather than restarting from zero.
|
||||
"Quality" is a fixed set of `MaxStreamingBitrate` presets (Auto/1080p/720p/
|
||||
480p) forwarded to `PlaybackInfo` — the same simplification Emby's/
|
||||
Jellyfin's own web clients use; it's a transcode ceiling, not true
|
||||
client-side adaptive bitrate switching. Subtitles picked this way get
|
||||
burned into the transcoded video (Emby's doing, via `SubtitleStreamIndex`)
|
||||
rather than rendered as a toggleable `<track>` — simpler to implement
|
||||
correctly than sidecar WebVTT, at the cost of a re-transcode to turn them
|
||||
on/off.
|
||||
|
||||
### Confirmed vs. not, for all of the above
|
||||
|
||||
Driving an actual browser against this (see session history) confirms the
|
||||
**rendering and interaction logic**: sidebar highlighting, home carousels
|
||||
(parties + one row per non-music library), library grid dispatch, and the
|
||||
series page's season-tab switching + episode list all render and navigate
|
||||
correctly. That test mocked every `/api/...` response (canned JSON), since
|
||||
exercising it for real needs a logged-in session — so what's **not**
|
||||
confirmed yet is the shape of the real Emby responses this now depends on:
|
||||
`Views` items actually carrying a `CollectionType` field the way assumed,
|
||||
`/Shows/{id}/Seasons` and `/Shows/{id}/Episodes`, `Items/Latest`'s
|
||||
plain-array response shape (handled defensively — see `index.js` — but not
|
||||
seen for real), and `PlaybackInfo`'s `MediaStreams` shape for the track
|
||||
pickers. The queue/"next episode" WebSocket flow and the player's
|
||||
track/quality reload are new protocol surface with **no test coverage at
|
||||
all yet** (no way to exercise real HLS playback without a live session) —
|
||||
the first things to check once a real party actually plays something.
|
||||
|
||||
## Non-goals (updated)
|
||||
|
||||
**Music** was requested (dedicated audio player, artist/album browsing, a
|
||||
shared/collaborative playlist party members can add to together) but is
|
||||
deliberately not in this pass: it's a genuinely different subsystem — a
|
||||
different player paradigm, full library-depth browsing down to the track,
|
||||
and real-time collaborative queue state that doesn't fit the existing
|
||||
"host drives, everyone follows" model at all (multiple people adding to one
|
||||
shared list is a different kind of shared state than one host's playback
|
||||
position). The sidebar and library routing already treat `music` as a first
|
||||
-class `collectionType` specifically so slotting in a real implementation
|
||||
later doesn't need another structural pass — see `library.js`'s early
|
||||
return. Right now it just shows an honest placeholder.
|
||||
|
||||
## Known limitations
|
||||
|
||||
- Library tabs show top-level items only — no drilling into a Series'
|
||||
seasons/episodes yet (a Series tile links out to Emby instead, see above).
|
||||
- Playback always goes through `hls.js` against whatever Emby's
|
||||
`PlaybackInfo` suggests. If that ever comes back as a direct-play source
|
||||
with no `TranscodingUrl` (no transcoding needed/possible), the client has
|
||||
|
|
|
|||
|
|
@ -119,7 +119,75 @@ app.get('/api/catalog/libraries', requireSession, async (req, res) => {
|
|||
return;
|
||||
}
|
||||
const data = await embyRes.json();
|
||||
res.json({ libraries: (data.Items || []).map((v) => ({ id: v.Id, name: v.Name })) });
|
||||
res.json({
|
||||
libraries: (data.Items || []).map((v) => ({
|
||||
id: v.Id,
|
||||
name: v.Name,
|
||||
// "movies" / "tvshows" / "music" / others - drives which browsing
|
||||
// experience client/js/views/library.js renders. See docs/architecture.md.
|
||||
collectionType: v.CollectionType || null
|
||||
}))
|
||||
});
|
||||
});
|
||||
|
||||
// Home page carousel: Emby's own "recently added" endpoint, one row per library.
|
||||
app.get('/api/catalog/libraries/:libraryId/recent', requireSession, async (req, res) => {
|
||||
const params = new URLSearchParams({ ParentId: req.params.libraryId, Limit: '16', Fields: 'ProductionYear' });
|
||||
const embyRes = await embyFetch(`/Users/${req.embySession.userId}/Items/Latest?${params}`, req.embySession.embyToken);
|
||||
if (!embyRes.ok) {
|
||||
res.status(502).json({ error: 'Impossible de récupérer les ajouts récents.' });
|
||||
return;
|
||||
}
|
||||
const data = await embyRes.json();
|
||||
// Unlike most Items endpoints, Latest returns a plain array, not {Items:[...]}.
|
||||
const items = Array.isArray(data) ? data : (data.Items || []);
|
||||
res.json({ items: items.map((i) => toItemDto(i, req.embySession.serverId)) });
|
||||
});
|
||||
|
||||
// Series -> Seasons -> Episodes, for the "Contenus vidéo en série" browsing page.
|
||||
app.get('/api/catalog/series/:seriesId/seasons', requireSession, async (req, res) => {
|
||||
const embyRes = await embyFetch(`/Shows/${req.params.seriesId}/Seasons?userId=${req.embySession.userId}`, req.embySession.embyToken);
|
||||
if (!embyRes.ok) {
|
||||
res.status(502).json({ error: 'Impossible de récupérer les saisons.' });
|
||||
return;
|
||||
}
|
||||
const data = await embyRes.json();
|
||||
res.json({
|
||||
seasons: (data.Items || []).map((s) => ({ id: s.Id, name: s.Name, indexNumber: s.IndexNumber }))
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/api/catalog/series/:seriesId/episodes', requireSession, async (req, res) => {
|
||||
const params = new URLSearchParams({ userId: req.embySession.userId, Fields: 'Overview' });
|
||||
if (req.query.seasonId) params.set('SeasonId', req.query.seasonId);
|
||||
const embyRes = await embyFetch(`/Shows/${req.params.seriesId}/Episodes?${params}`, req.embySession.embyToken);
|
||||
if (!embyRes.ok) {
|
||||
res.status(502).json({ error: 'Impossible de récupérer les épisodes.' });
|
||||
return;
|
||||
}
|
||||
const data = await embyRes.json();
|
||||
res.json({ episodes: (data.Items || []).map((e) => toItemDto(e, req.embySession.serverId)) });
|
||||
});
|
||||
|
||||
// Audio/subtitle tracks available for an item, read from Emby's own playback
|
||||
// negotiation (PlaybackInfo) - powers the player's language/subtitle pickers.
|
||||
app.get('/api/catalog/item/:itemId/playback-options', requireSession, async (req, res) => {
|
||||
const infoRes = await embyFetch(`/Items/${req.params.itemId}/PlaybackInfo?UserId=${req.embySession.userId}`, req.embySession.embyToken);
|
||||
if (!infoRes.ok) {
|
||||
res.status(502).json({ error: 'Impossible de lire les options de lecture.' });
|
||||
return;
|
||||
}
|
||||
const info = await infoRes.json();
|
||||
const source = (info.MediaSources || [])[0];
|
||||
const streams = (source && source.MediaStreams) || [];
|
||||
res.json({
|
||||
audioTracks: streams
|
||||
.filter((s) => s.Type === 'Audio')
|
||||
.map((s) => ({ index: s.Index, label: s.DisplayTitle || s.Language || `Piste ${s.Index}` })),
|
||||
subtitleTracks: streams
|
||||
.filter((s) => s.Type === 'Subtitle')
|
||||
.map((s) => ({ index: s.Index, label: s.DisplayTitle || s.Language || `Piste ${s.Index}` }))
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/api/catalog/libraries/:libraryId/items', requireSession, async (req, res) => {
|
||||
|
|
@ -163,7 +231,17 @@ app.get('/api/catalog/stream/:itemId/master.m3u8', async (req, res) => {
|
|||
// lookup (MediaSourceId/PlaySessionId/DeviceId) that URL never supplied.
|
||||
// PlaybackInfo returns a MediaSource with its own ready-to-use
|
||||
// TranscodingUrl carrying whatever Emby actually needs.
|
||||
const infoRes = await embyFetch(`/Items/${itemId}/PlaybackInfo?UserId=${session.userId}`, session.embyToken);
|
||||
//
|
||||
// AudioStreamIndex/SubtitleStreamIndex/MaxStreamingBitrate come from the
|
||||
// player's language/subtitle/quality pickers - forwarded straight through
|
||||
// so Emby bakes the right selection into the transcode itself (subtitles
|
||||
// end up burned into the video this way, not a separate toggleable track -
|
||||
// simpler than sidecar WebVTT, at the cost of a re-transcode to change).
|
||||
const infoParams = new URLSearchParams({ UserId: session.userId });
|
||||
for (const key of ['AudioStreamIndex', 'SubtitleStreamIndex', 'MaxStreamingBitrate']) {
|
||||
if (req.query[key]) infoParams.set(key, req.query[key]);
|
||||
}
|
||||
const infoRes = await embyFetch(`/Items/${itemId}/PlaybackInfo?${infoParams}`, session.embyToken);
|
||||
if (!infoRes.ok) {
|
||||
res.status(502).json({ error: 'Impossible de préparer la lecture de ce titre.' });
|
||||
return;
|
||||
|
|
@ -253,16 +331,47 @@ app.post('/api/parties', requireSession, async (req, res) => {
|
|||
}
|
||||
const item = await embyRes.json();
|
||||
|
||||
// Starting a party from an episode queues the rest of the series from
|
||||
// there on, so the host can hit "next" (see wsHandler.js) instead of
|
||||
// everyone having to separately start a new party per episode.
|
||||
let queue = null;
|
||||
if (item.Type === 'Episode' && item.SeriesId) {
|
||||
const epsRes = await embyFetch(
|
||||
`/Shows/${item.SeriesId}/Episodes?userId=${req.embySession.userId}&Fields=Overview`,
|
||||
req.embySession.embyToken
|
||||
);
|
||||
if (epsRes.ok) {
|
||||
const epsData = await epsRes.json();
|
||||
const all = epsData.Items || [];
|
||||
const startIndex = all.findIndex((e) => e.Id === itemId);
|
||||
const remaining = startIndex >= 0 ? all.slice(startIndex) : [item];
|
||||
queue = remaining.map((e) => ({
|
||||
itemId: e.Id,
|
||||
itemName: formatEpisodeName(e),
|
||||
itemImageUrl: `/api/catalog/image/${e.Id}`
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
const party = PartyManager.create(
|
||||
req.embySession.userId,
|
||||
req.embySession.userName,
|
||||
itemId,
|
||||
item.Name,
|
||||
`/api/catalog/image/${itemId}`
|
||||
queue ? queue[0].itemName : item.Name,
|
||||
`/api/catalog/image/${itemId}`,
|
||||
queue
|
||||
);
|
||||
res.json(PartyManager.toPublicState(party, req.embySession.userId));
|
||||
});
|
||||
|
||||
function formatEpisodeName(episode) {
|
||||
if (episode.ParentIndexNumber != null && episode.IndexNumber != null) {
|
||||
const prefix = `S${episode.ParentIndexNumber}E${episode.IndexNumber}`;
|
||||
return episode.SeriesName ? `${episode.SeriesName} - ${prefix} - ${episode.Name}` : `${prefix} - ${episode.Name}`;
|
||||
}
|
||||
return episode.Name;
|
||||
}
|
||||
|
||||
app.post('/api/parties/:joinCode/join', requireSession, (req, res) => {
|
||||
const party = PartyManager.getByJoinCode(req.params.joinCode);
|
||||
if (!party) {
|
||||
|
|
|
|||
|
|
@ -28,13 +28,23 @@ class PartyManager {
|
|||
this.partyIdByJoinCode = new Map();
|
||||
}
|
||||
|
||||
create(hostUserId, hostUserName, itemId, itemName, itemImageUrl) {
|
||||
/**
|
||||
* `queue` is the ordered list of { itemId, itemName, itemImageUrl } this
|
||||
* party will play through - for a movie/standalone episode it's just the
|
||||
* one item; for "start watching this series from here", index.js builds
|
||||
* the rest of the season/series into it. `itemId`/`itemName`/`itemImageUrl`
|
||||
* always mirror queue[queueIndex] - kept as top-level fields too since
|
||||
* that's what most of the UI (and the old single-item design) reads.
|
||||
*/
|
||||
create(hostUserId, hostUserName, itemId, itemName, itemImageUrl, queue) {
|
||||
const party = {
|
||||
id: crypto.randomUUID(),
|
||||
joinCode: generateJoinCode(),
|
||||
itemId,
|
||||
itemName,
|
||||
itemImageUrl,
|
||||
queue: queue && queue.length ? queue : [{ itemId, itemName, itemImageUrl }],
|
||||
queueIndex: 0,
|
||||
hostUserId,
|
||||
createdUtc: Date.now(),
|
||||
lastActivityUtc: Date.now(),
|
||||
|
|
@ -81,6 +91,29 @@ class PartyManager {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves to the next item in the queue (e.g. next episode). Returns false
|
||||
* if there's nothing after the current position. Resets every member's
|
||||
* known position/pause state, since it's a new item playing from zero.
|
||||
*/
|
||||
advanceQueue(party) {
|
||||
if (party.queueIndex >= party.queue.length - 1) {
|
||||
return false;
|
||||
}
|
||||
party.queueIndex += 1;
|
||||
const next = party.queue[party.queueIndex];
|
||||
party.itemId = next.itemId;
|
||||
party.itemName = next.itemName;
|
||||
party.itemImageUrl = next.itemImageUrl;
|
||||
for (const member of party.members.values()) {
|
||||
member.positionTicks = 0;
|
||||
member.isPaused = false;
|
||||
member.lastUpdatedUtc = Date.now();
|
||||
}
|
||||
party.lastActivityUtc = Date.now();
|
||||
return true;
|
||||
}
|
||||
|
||||
/** All active parties - powers the "watch parties en cours" home section. */
|
||||
listActive() {
|
||||
return Array.from(this.partiesById.values());
|
||||
|
|
@ -110,6 +143,9 @@ class PartyManager {
|
|||
itemId: party.itemId,
|
||||
itemName: party.itemName,
|
||||
itemImageUrl: party.itemImageUrl,
|
||||
hasNext: party.queueIndex < party.queue.length - 1,
|
||||
queueLength: party.queue.length,
|
||||
queueIndex: party.queueIndex,
|
||||
hostUserName: host ? host.userName : null,
|
||||
memberCount: party.members.size,
|
||||
youAreHost: viewerUserId != null && viewerUserId === party.hostUserId,
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ const { PartyManager } = require('./partyManager');
|
|||
* <- {type:"members", members:[...]} (on any join/leave)
|
||||
* -> {type:"playstate", isPaused, positionTicks} (host only - ignored otherwise)
|
||||
* <- {type:"playstate", isPaused, positionTicks} (relayed to everyone but the host)
|
||||
* -> {type:"next"} (host only - advance the queue, e.g. next episode)
|
||||
* <- {type:"itemChanged", itemId, itemName, itemImageUrl, queueIndex, queueLength, hasNext}
|
||||
* -> {type:"leave"}
|
||||
* <- {type:"error", message}
|
||||
*/
|
||||
|
|
@ -71,6 +73,24 @@ function attachWebSocketServer(wss, { parseCookies, getSession }) {
|
|||
return;
|
||||
}
|
||||
|
||||
if (msg.type === 'next') {
|
||||
if (userId !== party.hostUserId) {
|
||||
return; // only the host can advance the queue
|
||||
}
|
||||
if (PartyManager.advanceQueue(party)) {
|
||||
broadcast(party, {
|
||||
type: 'itemChanged',
|
||||
itemId: party.itemId,
|
||||
itemName: party.itemName,
|
||||
itemImageUrl: party.itemImageUrl,
|
||||
queueIndex: party.queueIndex,
|
||||
queueLength: party.queue.length,
|
||||
hasNext: party.queueIndex < party.queue.length - 1
|
||||
}, null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === 'leave') {
|
||||
PartyManager.leave(party, userId);
|
||||
broadcastMembers(party);
|
||||
|
|
|
|||
Loading…
Reference in a new issue