Compare commits

..

No commits in common. "master" and "fix/player-cursor-hide" have entirely different histories.

83 changed files with 315 additions and 7411 deletions

View file

@ -55,16 +55,13 @@ jobs:
# node:20-alpine n'a pas git préinstallé - même approche de clone
# manuel que le job publish (pas d'actions/checkout, action JS qui a
# ses propres soucis dans les conteneurs Alpine minimalistes ici).
# Sur une PR, `ref_name` vaut le numéro de la PR (ex. `29`), qui n'est
# pas une ref récupérable ("couldn't find remote ref 29") : on récupère
# alors la branche source de la PR (`head_ref`, vide hors PR).
- name: Récupérer les sources
run: |
set -eu
apk add --no-cache git
git init -q
git remote add origin "${{ github.server_url }}/${{ github.repository }}.git"
git fetch -q --depth 1 origin "${{ github.head_ref || github.ref_name }}"
git fetch -q --depth 1 origin "${{ github.ref_name }}"
git checkout -q FETCH_HEAD
- name: Serveur - installer les dépendances

3
.gitignore vendored
View file

@ -21,9 +21,6 @@ server/dist/
client/dist/
*.tsbuildinfo
## Runtime data (static watch parties - see server/src/services/StaticPartyStore.ts)
data/
## OS cruft
.DS_Store
Thumbs.db

View file

@ -159,7 +159,7 @@ server/ Node.js + Express + Socket.IO backend: proxies ALL Emby
tsc to server/dist.
src/index.ts Express app + route wiring only - thin,
delegates to the services below
src/config.ts env-derived config (EMBY_SERVER_URL, PORT, DATA_DIR)
src/config.ts env-derived config (EMBY_SERVER_URL, PORT)
src/types.ts shared interfaces (Party, ItemDto, the
Socket.IO event maps, ...)
src/Logger.ts party/stream lifecycle logging
@ -245,14 +245,9 @@ build stage, so nothing needs building beforehand:
```bash
docker build -f server/Dockerfile -t watchparty .
docker run -d --name watchparty -p 3000:3000 -v "$(pwd)/data:/data" -e EMBY_SERVER_URL=https://theater.media.kyuno.fr watchparty
docker run -d --name watchparty -p 3000:3000 -e EMBY_SERVER_URL=https://theater.media.kyuno.fr watchparty
```
The `/data` volume holds the static (persistent) watch parties
(`DATA_DIR`, see [`spec/static-watch-party.md`](spec/static-watch-party.md)).
Without it they're lost whenever the container is recreated.
`docker-compose.yml` already mounts it.
Then put it behind your existing reverse proxy like everything else
(`theater.media.kyuno.fr`, `git.dev.kyuno.fr`, ...), e.g.
`watchparty.kyuno.fr` → `http://<container>:3000`.
@ -333,85 +328,6 @@ Then put it behind your existing reverse proxy like everything else
as its own always-on-top bar, duplicating the cast icon and
overlapping the new bottom bar in fullscreen) in favor of fully
custom play/pause, a seek bar, and mute — see docs/architecture.md
- [x] Sticky search bar on the library/artist/album pages, and letting
anyone seek within the current music track (same no-host-check
model pause/resume already had) — see docs/architecture.md
- [x] Skip intro / jump to next episode at end credits, using Emby's own
native marker detection (`EnableMarkerDetection` + its "Detect
Episode Intros" task) rather than building fingerprinting
ourselves — see docs/architecture.md
- [x] Report each party member's own playback to Emby's real Sessions
API (`/Sessions/Playing`, `/Progress`, `/Stopped`), so the admin
dashboard sees watch-party streams the same way it sees a native
client's — one session per member, under their own account — see
docs/architecture.md
- [x] Queue a whole album, or an artist's entire discography, in one
action — resolved server-side (one atomic operation, not one
request per track) the same way launching a playlist already
expands into everything it contains — see docs/architecture.md
- [x] Picture-in-picture for the video player (standard
`requestPictureInPicture`, confirmed present on Android Chrome) —
asked for directly for mobile, where there's no second window to
put this app in while switching apps — see docs/architecture.md
- [x] Installable as a PWA (manifest + icons, standalone display,
no service worker — this app has nothing worth caching offline)
— see docs/architecture.md
- [x] Mobile navigation redesigned as a bottom sheet (Accueil /
Bibliothèques / Listes de lecture) instead of a horizontally
scrolling sidebar — see docs/architecture.md
- [x] Music UI reworked to look like a music app instead of reusing
movie-poster tiles — circular artist tiles, square album tiles,
a real hero header, and a mobile "now playing" full-screen player
— see docs/architecture.md
- [x] Mobile menu's bottom sheet is now swipe-to-close (a real drag
handle, not just a decorative grabber) instead of fighting the
browser's own pull-to-refresh gesture — see docs/architecture.md
- [x] Every blocking `alert()` (host handoff, every "couldn't start/join
a party" error) replaced with a light, auto-dismissing toast — see
docs/architecture.md
- [x] Library/artist grid tiles shrink fluidly on a narrow phone instead
of a fixed 160px floor collapsing to one column per row — see
docs/architecture.md
- [x] Library page title now stays pinned alongside the search bar while
scrolling, instead of just the search bar — see docs/architecture.md
- [x] Library alphabet index: sticky per-letter section headings plus a
right-edge A-Z rail to jump straight to one, drag-to-scrub like
iOS's own Contacts — see docs/architecture.md
- [x] Fixed the alphabet index's headers landing in the wrong order —
it was bucketing by display name instead of Emby's own SortName,
so an article-stripped title ("The ...") threw a whole run off —
see docs/architecture.md
- [x] Fixed the music bar's queue popup — a missing `position` broke
the desktop layout when opened and left it unclosable on mobile
(its own close button was reachable now) — see docs/architecture.md
- [x] Fixed library tile buttons rendering on top of the sticky header
while scrolling — an equal z-index tie that a scrolling tile won
— see docs/architecture.md
- [x] Fixed the music bar's desktop layout for real — the progress bar
was missing a grid-column span, so it (and not just the queue
popup above) was still squeezed and wrapping controls onto a
second row — see docs/architecture.md
- [x] Fixed an album's track rows collapsing to 3 lines on mobile —
there was always room for one, the breakpoint just assumed
otherwise — see docs/architecture.md
- [x] A mobile banner nudging an install of the PWA — the real
Chrome/Android install prompt, manual instructions on iOS Safari
(no such prompt exists there) — see docs/architecture.md
- [x] Linked whatever's playing (video or music) to the OS's own media
controls (Windows' SMTC, macOS' Now Playing, ...) — title/artwork,
play/pause/seek/next — see docs/architecture.md
- [x] Fixed switching a party between devices leaving both "on the
session" with no working controls — the old device never reacted
to being kicked, and the leave route had no way to tell a stale
device apart from the current one — see docs/architecture.md
- [x] Fixed a track started directly (not via its album/artist) never
falling back to its album's artwork — see docs/architecture.md
(probably not the whole "images are hard to find" story — flagged
there)
- [x] Fixed the music bar's mobile now-playing screen not fitting a
phone rotated to landscape — the art now sits beside the title
instead of above it, sized off the viewport height instead of a
fixed width-based cap — see docs/architecture.md
- [ ] Try a full party end-to-end with two real accounts (still the main gap)
- [ ] Try casting to a real Chromecast (only the SDK's own bootstrap is
confirmed here — no real device/session)

View file

@ -11,7 +11,7 @@
// npm run watch -> rebuilds on save, for local dev (see README's
// Running locally section)
import * as esbuild from 'esbuild';
import { copyFileSync, cpSync, mkdirSync } from 'fs';
import { copyFileSync, mkdirSync } from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
@ -20,11 +20,6 @@ const outdir = path.join(here, 'dist');
mkdirSync(outdir, { recursive: true });
copyFileSync(path.join(here, 'src', 'index.html'), path.join(outdir, 'index.html'));
// PWA manifest + icons (manifest.webmanifest, icons/*) - see
// docs/architecture.md's "Progressive Web App" section. Copied wholesale
// rather than named file-by-file like index.html above, so adding another
// icon later needs no build.mjs change.
cpSync(path.join(here, 'src', 'public'), outdir, { recursive: true });
const buildOptions = {
entryPoints: [path.join(here, 'src', 'app.ts')],

View file

@ -1,81 +0,0 @@
/* ---- Alphabet index: sticky letter headings inside .grid + the
right-edge A-Z rail that jumps to one - see alphaIndex.ts. ---- */
/* Full-width row inside tiles.css's own .grid (still just
`repeat(auto-fill, minmax(...))` - unchanged), interspersed with
normal tile children rather than a separate nested grid per letter. */
.letterHeading {
grid-column: 1 / -1;
position: sticky;
top: var(--section-sticky-top, 0px);
/* Same z-index: 3 as views/library/Library.css's own .libraryHeader,
and for the identical reason: tiles.css's .tileOverlay is z-index:
2, and a tie there breaks toward whichever renders later in the
DOM - a tile, always after this heading. */
z-index: 3;
background: var(--bg);
color: var(--accent-bright);
font-size: 13px;
font-weight: 700;
padding: 6px 2px;
scroll-margin-top: var(--section-sticky-top, 0px);
}
.alphaRail {
position: fixed;
right: 2px;
z-index: 4;
width: 20px;
display: flex;
flex-direction: column;
justify-content: space-evenly;
align-items: center;
padding: 6px 0;
/* This app's own pointer handlers (alphaIndex.ts) drive the drag -
without this, the browser reads a vertical drag on a touch device
as its own gesture (scroll/pull-to-refresh) instead, the same
conflict shell/Shell.css's mobile nav sheet had to solve once
already for its own drag handle. */
touch-action: none;
}
.railLetter {
font-size: 9px;
font-weight: 700;
color: var(--muted);
line-height: 1;
width: 100%;
text-align: center;
user-select: none;
}
.railLetter.active { color: var(--accent-bright); }
.railLetterEmpty { color: rgba(255, 255, 255, .15); }
.alphaBubble {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) scale(.7);
width: 84px;
height: 84px;
border-radius: 50%;
background: rgba(20, 18, 22, .88);
border: 1px solid rgba(255, 255, 255, .12);
display: flex;
align-items: center;
justify-content: center;
font-size: 36px;
font-weight: 700;
color: var(--accent-bright);
opacity: 0;
pointer-events: none;
transition: opacity .12s ease, transform .12s ease;
z-index: 6;
}
.alphaBubble.show { opacity: 1; transform: translate(-50%, -50%) scale(1); }
@media (prefers-reduced-motion: reduce) {
.alphaBubble { transition: none; }
}

View file

@ -1,218 +0,0 @@
// Alphabet index for a library grid: sticky per-letter section headings
// inside the grid itself, plus a right-edge A-Z rail to jump straight to
// one - see docs/architecture.md. Library-only for now (that's the only
// page that asked), but nothing below assumes anything Library-specific
// beyond the two elements it measures (.libraryHeader, shell/Shell.html's
// .bottomStack) - reusable elsewhere later without change.
import './alphaIndex.css';
import { tileHtml } from './tiles';
import type { CleanupFn } from './router';
import type { ItemDto } from './types';
const ALL_BUCKETS = ['#', ...Array.from({ length: 26 }, (_, i) => String.fromCharCode(65 + i))];
/**
* An item's index bucket: the first letter of its `sortName` - NOT
* `name` - with accents folded to their base (so "Écho" lands on "E",
* matching how a French speaker would expect the index to group it, not
* off in its own "É"/"#" bucket). Anything else (a digit, empty) falls
* back to "#".
*
* `sortName`, deliberately, not the display name: `items` arrives in
* Emby's own SortName order (getLibraryItems/getArtists both request
* `SortBy: SortName`), and that order strips leading articles ("Le
* Voyage" sorts as "Voyage") - bucketing by the raw display name instead
* put a "L" heading in the middle of a "V" run the one time an article
* did that, reported directly as headers in no sensible order at all.
* Bucketing by the exact same key Emby used to order the array is what
* guarantees a bucket is always one contiguous run, not a guess that it
* happens to line up.
*/
function bucketFor(item: ItemDto): string {
const base = (item.sortName || item.name || '').trim().normalize('NFD').replace(/[̀-ͯ]/g, '');
const c = base.charAt(0).toUpperCase();
return c >= 'A' && c <= 'Z' ? c : '#';
}
/**
* Same job as tiles.ts's renderTilesInChunks (chunked insertAdjacentHTML,
* not one big innerHTML write - see that function's own comment for why
* that matters on a 1000+-item library), plus a `.letterHeading` inserted
* right before the first tile of each new bucket. `items` is assumed
* already ordered by `sortName` (see bucketFor) - a chunk boundary
* landing mid-bucket is fine, it's still one contiguous run either way.
*/
export function renderGroupedTilesInChunks(container: HTMLElement, items: ItemDto[], chunkSize = 40): void {
container.innerHTML = '';
let i = 0;
let lastBucket: string | null = null;
function renderNextChunk(): void {
const chunk = items.slice(i, i + chunkSize);
if (!chunk.length) return;
let html = '';
for (const item of chunk) {
const bucket = bucketFor(item);
if (bucket !== lastBucket) {
html += `<div class="letterHeading" data-letter-anchor="${bucket}">${bucket}</div>`;
lastBucket = bucket;
}
html += tileHtml(item);
}
container.insertAdjacentHTML('beforeend', html);
i += chunkSize;
if (i < items.length) setTimeout(renderNextChunk, 0);
}
renderNextChunk();
}
/**
* Hides a `.letterHeading` whenever every tile in its run is currently
* `.hide`'d by tiles.ts's wireSearchFilter - without this, searching
* down to matches in only one bucket would leave every other bucket's
* heading floating above nothing. Call once, after wireSearchFilter
* itself is wired to the same input (listener order = registration
* order, so this always runs after .hide has already been updated).
*/
export function wireSectionSearchSync(input: HTMLInputElement, grid: HTMLElement): void {
input.addEventListener('input', () => {
grid.querySelectorAll<HTMLElement>('.letterHeading').forEach((heading) => {
let sib = heading.nextElementSibling;
let anyVisible = false;
while (sib && !sib.classList.contains('letterHeading')) {
if (!sib.classList.contains('hide')) { anyVisible = true; break; }
sib = sib.nextElementSibling;
}
heading.classList.toggle('hide', !anyVisible);
});
});
}
/**
* The right-edge A-Z rail: tap or drag it to jump the page scroll to a
* letter's section heading, with a big center-screen bubble showing the
* current letter while doing so (fades out on release) - same
* pointerdown/move/up-on-a-dedicated-element and touch-action: none shape
* as shell/Shell.ts's own nav-sheet grabber handle, so a vertical drag
* here doesn't get read as the browser's pull-to-refresh either. Letters
* with nothing in `items` render dimmed and inert.
*
* The rail and every letterHeading's sticky offset are measured live off
* .libraryHeader and .bottomStack rather than hardcoded: .bottomStack
* (the music bar) can appear/resize while this page stays open - a
* listen party starting - and it floats above the grid on desktop too,
* not just mobile, so both need to stay clear of it regardless of
* breakpoint. `getComputedStyle(libraryHeader).top` picks up whichever
* of Library.css's own two breakpoint values is active without this
* module needing to duplicate that 720px rule itself.
*
* Returns a CleanupFn (see router.ts) - the two ResizeObservers and the
* window-level scroll/resize listeners this sets up would otherwise keep
* running after the user navigates away, the exact leak class
* docs/architecture.md already documents this app getting bitten by
* once before.
*/
export function wireAlphaRail(root: HTMLElement, grid: HTMLElement, items: ItemDto[]): CleanupFn {
const present = new Set(items.map((it) => bucketFor(it)));
const libraryHeader = root.querySelector('.libraryHeader') as HTMLElement;
const bottomStack = document.querySelector('.bottomStack') as HTMLElement | null;
const rail = document.createElement('div');
rail.className = 'alphaRail';
rail.innerHTML = ALL_BUCKETS.map((letter) => {
const has = present.has(letter);
return `<span class="railLetter${has ? '' : ' railLetterEmpty'}" data-letter="${letter}" role="button" aria-label="Aller à ${letter}" aria-disabled="${!has}">${letter}</span>`;
}).join('');
root.appendChild(rail);
const bubble = document.createElement('div');
bubble.className = 'alphaBubble';
root.appendChild(bubble);
function recomputeOffsets(): void {
const top = parseFloat(getComputedStyle(libraryHeader).top) || 0;
const stickyBottom = top + libraryHeader.offsetHeight;
grid.style.setProperty('--section-sticky-top', `${stickyBottom}px`);
rail.style.top = `${stickyBottom}px`;
rail.style.bottom = `${bottomStack ? bottomStack.offsetHeight : 0}px`;
}
recomputeOffsets();
const resizeObserver = new ResizeObserver(recomputeOffsets);
resizeObserver.observe(libraryHeader);
if (bottomStack) resizeObserver.observe(bottomStack);
window.addEventListener('resize', recomputeOffsets);
let bubbleFadeTimer: ReturnType<typeof setTimeout> | null = null;
function showBubble(letter: string): void {
bubble.textContent = letter;
bubble.classList.add('show');
if (bubbleFadeTimer) clearTimeout(bubbleFadeTimer);
}
function scheduleHideBubble(): void {
if (bubbleFadeTimer) clearTimeout(bubbleFadeTimer);
bubbleFadeTimer = setTimeout(() => bubble.classList.remove('show'), 260);
}
function setActive(letter: string): void {
rail.querySelectorAll('.railLetter').forEach((el) => {
el.classList.toggle('active', el.getAttribute('data-letter') === letter);
});
}
function letterAt(clientY: number): string {
const rect = rail.getBoundingClientRect();
const ratio = Math.min(1, Math.max(0, (clientY - rect.top) / rect.height));
return ALL_BUCKETS[Math.min(ALL_BUCKETS.length - 1, Math.floor(ratio * ALL_BUCKETS.length))] as string;
}
function jumpTo(letter: string): void {
setActive(letter);
showBubble(letter);
const target = grid.querySelector(`[data-letter-anchor="${letter}"]`);
if (target) target.scrollIntoView({ block: 'start' });
}
let dragging = false;
function onPointerDown(e: PointerEvent): void {
dragging = true;
rail.setPointerCapture(e.pointerId);
jumpTo(letterAt(e.clientY));
}
function onPointerMove(e: PointerEvent): void {
if (!dragging) return;
jumpTo(letterAt(e.clientY));
}
function endDrag(): void {
if (!dragging) return;
dragging = false;
scheduleHideBubble();
}
rail.addEventListener('pointerdown', onPointerDown);
rail.addEventListener('pointermove', onPointerMove);
rail.addEventListener('pointerup', endDrag);
rail.addEventListener('pointercancel', endDrag);
let scrollSpyScheduled = false;
function onWindowScroll(): void {
if (dragging || scrollSpyScheduled) return;
scrollSpyScheduled = true;
requestAnimationFrame(() => {
scrollSpyScheduled = false;
const stickyBottom = parseFloat(grid.style.getPropertyValue('--section-sticky-top')) || 0;
let current = ALL_BUCKETS[0] as string;
grid.querySelectorAll<HTMLElement>('[data-letter-anchor]').forEach((el) => {
if (el.getBoundingClientRect().top - stickyBottom <= 4) current = el.getAttribute('data-letter-anchor') as string;
});
setActive(current);
});
}
window.addEventListener('scroll', onWindowScroll, { passive: true });
setActive(ALL_BUCKETS[0] as string);
return () => {
resizeObserver.disconnect();
window.removeEventListener('resize', recomputeOffsets);
window.removeEventListener('scroll', onWindowScroll);
if (bubbleFadeTimer) clearTimeout(bubbleFadeTimer);
};
}

View file

@ -3,7 +3,7 @@
// module just wraps fetch calls to our own /api/* routes. See
// docs/architecture.md for why this changed from the first version (CORS,
// plus not wanting the browser to hold an Emby token at all).
import type { ItemDto, LibraryDto, PlaybackMarkersDto, PlaybackOptionsDto, SeasonDto } from '../types';
import type { ItemDto, LibraryDto, PlaybackOptionsDto, SeasonDto } from '../types';
async function apiFetch<T>(path: string, options: RequestInit = {}): Promise<T> {
const res = await fetch(path, {
@ -116,10 +116,6 @@ export function getPlaybackOptions(itemId: string): Promise<PlaybackOptionsDto>
return apiFetch(`/api/catalog/item/${itemId}/playback-options`);
}
export function getPlaybackMarkers(itemId: string): Promise<PlaybackMarkersDto> {
return apiFetch(`/api/catalog/item/${itemId}/markers`);
}
/** The party's own shared Emby stream (server/src/services/
* PartyStreamService.ts) - one negotiated PlaybackInfo session reused
* by every member, instead of each viewer's own <video>/<audio>

View file

@ -39,14 +39,8 @@ export function joinParty(partyId: string): Promise<PartyPublicState> {
return apiFetch(`/api/parties/${encodeURIComponent(partyId)}/join`, { method: 'POST' });
}
/** Sends this device's own deviceId so the server can tell a stale
* device (already replaced by a takeover on another one - see
* getDeviceId's own comment) apart from the currently-active one, the
* same way the socket "join"/"leave"/"disconnect" handlers already do
* via the live connection itself - a REST call has no connection to
* compare, so this is what lets the server apply that same guard here. */
export function leaveParty(partyId: string): Promise<{ ok: true }> {
return apiFetch(`/api/parties/${partyId}/leave`, { method: 'POST', body: JSON.stringify({ deviceId: getDeviceId() }) });
return apiFetch(`/api/parties/${partyId}/leave`, { method: 'POST' });
}
export function getParty(partyId: string): Promise<PartyPublicState> {
@ -67,21 +61,6 @@ export function addTrackToQueue(partyId: string, track: MusicTrackInput): Promis
return apiFetch(`/api/parties/${partyId}/tracks`, { method: 'POST', body: JSON.stringify(track) });
}
/**
* Music only: queues `itemId` into a party's shared queue, whatever it
* turns out to be - a single track queues itself, an album or an
* artist expands into every track inside it (see index.ts's
* resolveMusicTracksForItem) - the "add to queue" half of
* views/album's/views/artist's own whole-album/whole-discography
* button (createParty(itemId) is the other half, for when there's no
* party active yet - see Album.ts/Artist.ts). Distinct from
* addTrackToQueue above, which takes an already-built track object a
* caller has in hand rather than an id to resolve server-side.
*/
export function queueItem(partyId: string, itemId: string): Promise<PartyPublicState> {
return apiFetch(`/api/parties/${partyId}/queue`, { method: 'POST', body: JSON.stringify({ itemId }) });
}
/**
* Mints a short-lived, party-scoped token a Chromecast can use to reach
* this party's own shared stream routes, which otherwise only accept the
@ -150,9 +129,6 @@ export interface PartyConnection {
sendPlaystate(isPaused: boolean, positionTicks: number): void;
/** Host-only: advance the party's queue (e.g. to the next episode). */
next(): void;
/** Host-only: the last queued item played to its end - lets the server
* count it as watched (there's no "next" to send for it). */
itemEnded(): void;
/** Asks the host (and tells everyone else) "please pause". Video only -
* a music party has no host to ask, see voteSkip/setMusicPaused. */
requestPause(): void;
@ -164,10 +140,6 @@ export interface PartyConnection {
voteSkip(): void;
/** Music only: no host check server-side, unlike sendPlaystate above. */
setMusicPaused(isPaused: boolean): void;
/** Music only: jump to `positionSeconds` in the current track - same
* no-host-check treatment as setMusicPaused above (see
* MusicQueueService.seek's own comment for why). */
seekMusic(positionSeconds: number): void;
/** Re-sends "join" with force:true after the user confirms they want to
* disconnect their other device and take over here - call this from
* onDeviceConflict's handler once the user says yes. */
@ -233,9 +205,6 @@ export function connectParty(partyId: string, callbacks: PartyConnectionCallback
next() {
socket.emit('next');
},
itemEnded() {
socket.emit('itemEnded');
},
requestPause() {
socket.emit('requestPause');
},
@ -248,9 +217,6 @@ export function connectParty(partyId: string, callbacks: PartyConnectionCallback
setMusicPaused(isPaused) {
socket.emit('musicPlaystate', { isPaused });
},
seekMusic(positionSeconds) {
socket.emit('musicSeek', { positionSeconds });
},
confirmDeviceTakeover() {
socket.emit('join', { partyId, deviceId, force: true });
},

View file

@ -1,74 +0,0 @@
// Static (persistent) watch parties - REST only, same origin, session
// cookie auth (see embyApi.ts). A static party's live sessions are
// ordinary parties: once one of these calls returns a partyId, everything
// else goes through api/party.ts and the normal player, unchanged. See
// server/src/services/StaticPartyService.ts and spec/static-watch-party.md.
import type { StaticPartyDetailDto, StaticPartySummaryDto } from '../types';
/** Same contract as api/party.ts's own helper: JSON in/out, the server's
* `error` message thrown as-is (they're written to be shown to users). */
async function apiFetch<T>(path: string, options: RequestInit = {}): Promise<T> {
let res: Response;
try {
res = await fetch(path, {
credentials: 'same-origin',
...options,
headers: { 'Content-Type': 'application/json', ...(options.headers || {}) }
});
} catch (err) {
console.error('[staticParty] network error', path, err);
throw new Error('Serveur injoignable.');
}
if (!res.ok) {
const body = await res.json().catch(() => ({}));
console.warn('[staticParty] request failed', path, res.status, body.error);
throw new Error(body.error || `Request failed: ${res.status}`);
}
return res.json() as Promise<T>;
}
/** Every static watch party, with the viewer's own missed count. */
export async function listStaticParties(): Promise<StaticPartySummaryDto[]> {
const data = await apiFetch<{ staticParties: StaticPartySummaryDto[] }>('/api/static-parties');
return data.staticParties;
}
/** Creates one from a series or a video playlist (`sourceId`). */
export function createStaticParty(sourceId: string, name?: string): Promise<StaticPartyDetailDto> {
return apiFetch('/api/static-parties', { method: 'POST', body: JSON.stringify({ sourceId, name }) });
}
/** Everything about one static watch party, from the viewer's side. */
export function getStaticParty(id: string): Promise<StaticPartyDetailDto> {
return apiFetch(`/api/static-parties/${encodeURIComponent(id)}`);
}
/** Become a member. */
export function joinStaticParty(id: string): Promise<{ ok: true }> {
return apiFetch(`/api/static-parties/${encodeURIComponent(id)}/join`, { method: 'POST' });
}
/** Stop being a member (drops the viewer's own progress). */
export function leaveStaticParty(id: string): Promise<{ ok: true }> {
return apiFetch(`/api/static-parties/${encodeURIComponent(id)}/leave`, { method: 'POST' });
}
/** Delete it for everyone - creator only. */
export function deleteStaticParty(id: string): Promise<{ ok: true }> {
return apiFetch(`/api/static-parties/${encodeURIComponent(id)}`, { method: 'DELETE' });
}
/** "Marquer comme vu". */
export function markStaticItemWatched(id: string, itemId: string): Promise<{ ok: true }> {
return apiFetch(`/api/static-parties/${encodeURIComponent(id)}/items/${encodeURIComponent(itemId)}/watched`, { method: 'POST' });
}
/** Launches the group's next session, or joins the one running. */
export function startStaticSession(id: string): Promise<{ partyId: string }> {
return apiFetch(`/api/static-parties/${encodeURIComponent(id)}/session`, { method: 'POST' });
}
/** Catches up on `itemIds` - alone, or as a watch party others can join. */
export function startCatchUp(id: string, itemIds: string[], mode: 'solo' | 'party'): Promise<{ partyId: string }> {
return apiFetch(`/api/static-parties/${encodeURIComponent(id)}/catch-up`, { method: 'POST', body: JSON.stringify({ itemIds, mode }) });
}

View file

@ -10,8 +10,6 @@ import { renderArtist } from './views/artist/Artist';
import { renderAlbum } from './views/album/Album';
import { renderPlaylist } from './views/playlist/Playlist';
import { renderPartyPlayer } from './views/player/Player';
import { renderStaticParty } from './views/staticParty/StaticParty';
import { renderStaticParties } from './views/staticParties/StaticParties';
route('/', () => navigate('/home'));
route('/login', renderLogin);
@ -22,8 +20,6 @@ route('/artist/:id', renderArtist);
route('/album/:id', renderAlbum);
route('/playlist/:id', renderPlaylist);
route('/party/:id', renderPartyPlayer);
route('/static', renderStaticParties);
route('/static/:id', renderStaticParty);
/**
* Renders either the bare login screen or the full shell (sidebar +

View file

@ -4,24 +4,6 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Watch Party</title>
<!-- PWA - see docs/architecture.md's "Progressive Web App" section.
theme-color/background_color both come straight from base.css's
--bg-header/--bg (the manifest can't read CSS custom properties,
so these are duplicated by hand - if that palette ever changes,
update manifest.webmanifest to match). -->
<link rel="manifest" href="manifest.webmanifest" />
<meta name="theme-color" content="#221d24" />
<link rel="icon" type="image/png" href="icons/favicon.png" />
<!-- iOS ignores the manifest above for installed-app chrome - Safari
needs its own separate meta tags/link for the equivalent
standalone/home-screen-icon behavior. -->
<link rel="apple-touch-icon" href="icons/apple-touch-icon.png" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Watch Party" />
<link rel="stylesheet" href="app.css" />
<script src="https://cdnjs.cloudflare.com/ajax/libs/hls.js/1.5.17/hls.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.7.5/socket.io.min.js"></script>

View file

@ -1,123 +0,0 @@
// System Media Transport Controls integration - Windows' own media-key
// popup/taskbar overlay (also macOS' Now Playing widget, Android's
// notification) - see docs/architecture.md. A thin wrapper around the
// standard Media Session API, not a new abstraction of its own: every
// function here is a one-line guard (`'mediaSession' in navigator`)
// around the exact browser call, since Firefox has never implemented
// this at all and this safely no-ops everywhere else it's missing too.
// Shared by views/player/Player.ts (video) and musicBar/MusicBar.ts
// (a listen party) - the two can never be active at once (starting/
// joining either already leaves the other first), so there's no risk
// of them fighting over this single global.
export interface MediaSessionActions {
onPlay: () => void;
onPause: () => void;
/** Host-only for video (Player.ts checks isHost itself before acting -
* this just delivers the OS's own seek gesture), unrestricted for
* music (no host concept there). */
onSeek?: (seconds: number) => void;
}
/** Title/artist/album/art shown in the OS's own now-playing UI - artist
* and album are meaningless for a video (pass '' for both), matching
* how a movie has no equivalent fields in this app's own UI either. */
export function setMediaSessionMetadata(title: string, artist: string, album: string, artworkUrl: string): void {
if (!('mediaSession' in navigator)) return;
navigator.mediaSession.metadata = new MediaMetadata({
title,
artist,
album,
artwork: artworkUrl ? [{ src: artworkUrl, sizes: '512x512', type: 'image/jpeg' }] : []
});
}
export function setMediaSessionPlaybackState(playing: boolean): void {
if (!('mediaSession' in navigator)) return;
navigator.mediaSession.playbackState = playing ? 'playing' : 'paused';
}
/** The OS's own scrubber - harmless to skip (not every browser/OS
* combination renders one) but adds real functionality where it does.
* Chrome throws if `duration` isn't a positive finite number, or if
* `position` doesn't satisfy `0 <= position <= duration` - both
* momentarily true the instant an item/track changes but the ticker
* driving this hasn't caught up yet, so this is caught rather than
* guarded field-by-field (the next tick corrects it either way). */
export function setMediaSessionPositionState(duration: number, position: number): void {
if (!('mediaSession' in navigator) || !navigator.mediaSession.setPositionState) return;
if (!(duration > 0) || !isFinite(duration)) return;
try {
navigator.mediaSession.setPositionState({ duration, position: Math.max(0, Math.min(position, duration)), playbackRate: 1 });
} catch {
// See this function's own comment - a transient mismatch, not a real error.
}
}
/**
* Wired once per party lifetime (Player.ts's init(), MusicBar.ts's
* initMusicBar()) - the handlers themselves close over whatever local
* state they need (isHost, conn, ...) and read it fresh on every
* OS-triggered call, so this never needs re-wiring just because that
* state changed later (see Player.ts's own isHost, which does exactly
* that mid-session on a host handoff).
*/
export function setMediaSessionActionHandlers(actions: MediaSessionActions): void {
if (!('mediaSession' in navigator)) return;
navigator.mediaSession.setActionHandler('play', actions.onPlay);
navigator.mediaSession.setActionHandler('pause', actions.onPause);
if (actions.onSeek) {
const onSeek = actions.onSeek;
navigator.mediaSession.setActionHandler('seekto', (details) => {
if (details.seekTime != null) onSeek(details.seekTime);
});
}
}
/** Shows/hides the OS's own "next" control - called whenever this app's
* own equivalent (Player.ts's #nextBtn, a queue advance in general)
* would also change visibility. A *registered* handler is what makes
* Windows show the button at all, regardless of whether clicking it
* would currently do anything - so this has to track "is there
* actually a next" the same way the in-page button does, not just be
* set once and left there. */
export function setMediaSessionNextAction(onNext: (() => void) | null): void {
if (!('mediaSession' in navigator)) return;
try {
navigator.mediaSession.setActionHandler('nexttrack', onNext);
} catch {
// An unrecognized action name on an older browser - safe to skip.
}
}
/** Drops the OS's own now-playing card without touching the play/pause/
* seek handlers underneath it - for "still in the party, just nothing
* queued right now" (MusicBar.ts's own `!music.current`), where those
* handlers stay meaningful (playback can resume the moment a track
* does) and so shouldn't need re-wiring the instant one does. `null`,
* not an empty MediaMetadata - that's the documented way to tell the
* OS there's genuinely nothing loaded, not a track with a blank title. */
export function setMediaSessionIdle(): void {
if (!('mediaSession' in navigator)) return;
navigator.mediaSession.metadata = null;
navigator.mediaSession.playbackState = 'none';
}
/** Called when leaving a party/party page entirely - an OS-level "now
* playing" card that outlives the actual party would be actively
* wrong, not just stale, so this clears every piece the functions
* above ever set (not just the now-playing card - see
* setMediaSessionIdle above for that alone) rather than leaving it to
* be silently overwritten by whatever starts next. */
export function clearMediaSession(): void {
if (!('mediaSession' in navigator)) return;
setMediaSessionIdle();
try {
navigator.mediaSession.setActionHandler('play', null);
navigator.mediaSession.setActionHandler('pause', null);
navigator.mediaSession.setActionHandler('nexttrack', null);
navigator.mediaSession.setActionHandler('seekto', null);
} catch {
// Same tolerance as setMediaSessionNextAction above.
}
}

View file

@ -6,14 +6,13 @@
able to browse/add tracks while it plays). Sits above .mainContent, at
a lower z-index than .playerWrap (20) so a video party's takeover still
correctly covers it - moot in practice since joining a video party
calls leaveMusicParty() first (see Player.ts), but cheap to get right.
The fixed-to-viewport positioning itself lives one level up, on
Shell.css's .bottomStack wrapper - not here - so that on a narrow
screen the mobile nav trigger right below it in that wrapper always
sits flush above this bar's own actual rendered height (its content
can wrap taller on a narrow screen), never a hardcoded guess at it. */
calls leaveMusicParty() first (see Player.ts), but cheap to get right. */
.musicBar {
position: relative; /* containing block for .musicBarQueue's own position: absolute - see its comment */
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 15;
background: var(--bg-header);
border-top: 1px solid rgba(255, 255, 255, .08);
box-shadow: 0 -4px 16px rgba(0, 0, 0, .3);
@ -21,139 +20,26 @@
/* A thin strip along the bar's own top edge, not inside .musicBarRow -
the same "compact player" convention Spotify/YouTube Music use for
their mini-players. Click/drag to seek (see MusicBar.ts) - the
generous ::before hit-area below (not just the 3px visual track)
matters most for drag on a touch screen, same trick
views/player/Player.css's own .progressBar::before uses.
`position: relative` is what gives that absolutely-positioned
::before something to size itself against.
`grid-column: 1 / -1` is the fix, not decoration: #trackProgress is
.musicBarPanel's first child, and without an explicit span a 3-column
grid places it in column 1 alone - squeezing it down to the width of
.musicBarPanelTrack's own column and wrapping .musicBarPanelSide onto
a second row underneath it, instead of the intended full-width strip
with everything else in one row below. Reported directly - never
looked obviously wrong at a glance since .musicBarPanelTrack's own
content (a small image + short text) left column 1 wide enough to
mostly hide the squeeze. Spanning this row is what pushes every
sibling after it into row 2, across all 3 columns, with no change
needed to them. */
their mini-players. Read-only (see MusicBar.ts's own comment) - no
click/drag handling, just a visual position indicator. */
.musicBarProgressTrack {
grid-column: 1 / -1;
position: relative;
height: 3px;
background: rgba(255, 255, 255, .12);
cursor: pointer;
touch-action: none; /* this app's own pointer handlers drive the drag, not the browser's default touch scroll/zoom gestures */
}
.musicBarProgressTrack::before {
content: '';
position: absolute;
top: -8px;
bottom: -8px;
left: 0;
right: 0;
}
.musicBarProgressFill {
height: 100%;
width: 0%;
background: var(--accent);
pointer-events: none;
transition: width .25s linear;
}
/* ---- Collapsed mobile preview (see MusicBar.html) - display:none by
default since #musicBarPanel below is already always visible on
desktop, nothing to collapse into a preview of there. Turned on
under the 720px breakpoint further down. ---- */
.mobileMiniBar {
display: none;
align-items: center;
gap: 10px;
padding: 8px 12px;
position: relative;
cursor: pointer;
}
/* Passive - not draggable, unlike #trackProgress inside the panel below.
Glanceable progress while collapsed is enough; dragging is one tap
away once expanded, same as most real players' own mini-bar. */
.mobileMiniProgress {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 2px;
background: rgba(255, 255, 255, .12);
}
.mobileMiniProgressFill {
height: 100%;
width: 0%;
background: var(--accent-bright);
}
.mobileMiniArt {
width: 36px;
height: 36px;
object-fit: cover;
border-radius: 6px;
background: var(--panel);
flex-shrink: 0;
}
.mobileMiniArt.noImage { visibility: hidden; }
.mobileMiniMeta {
display: flex;
flex-direction: column;
gap: 1px;
min-width: 0;
flex: 1;
}
.mobileMiniMeta strong {
font-size: 13px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ---- The actual controls - always in place on desktop (this IS the
bar, same as before); a full-screen "now playing" overlay on mobile,
opened via #mobileMiniBar above and closed via #panelCloseBtn - see
the 720px breakpoint further down for the fixed-overlay/transform
part of this, unchanged here. ---- */
.musicBarPanel {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 16px;
padding: 8px 16px;
}
.panelCloseBtn { display: none; }
.musicBarPanelTrack {
.musicBarRow {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.musicBarPanelTransport {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
}
.musicBarPanelSide {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
padding: 8px 16px;
flex-wrap: wrap;
}
/* Smaller than the video player's presence row (Player.css/presence.css's
@ -193,60 +79,6 @@
.musicBarPosition { font-variant-numeric: tabular-nums; white-space: nowrap; }
/* The one primary transport action, bigger and filled so it clearly
outranks the plain small .iconBtn (skip-vote/queue) either side of
it - same "obviously the main button" treatment
views/player/Player.ts's own controls already get, instead of
blending into a row of equally-weighted buttons. Shared between
#playPauseBtn (inside .musicBarPanel) and #miniPlayPauseBtn (the
collapsed mobile preview) - same look, same size, both wired to the
exact same toggle in MusicBar.ts. */
/* .iconBtn.musicBarPlayBig, not just .musicBarPlayBig: both classes are
always present together on these two buttons (see MusicBar.html), so
this compound selector's higher specificity is what reliably beats
.iconBtn's own background/border-radius regardless of which of the
two files esbuild happens to bundle later - two plain single-class
selectors at equal specificity would otherwise depend on that bundle
order, which isn't something to build a visual guarantee on. */
.iconBtn.musicBarPlayBig {
width: 34px;
height: 34px;
border-radius: 50%;
background: var(--text);
color: var(--bg);
display: flex;
align-items: center;
justify-content: center;
padding: 0;
flex-shrink: 0;
}
.iconBtn.musicBarPlayBig:hover { background: var(--accent-bright); color: #fff; }
.musicBarPlayBig svg { width: 14px; height: 14px; display: block; }
/* #voteSkipBtn's own vote tally (see MusicBar.ts's updateVoteSkipLabel) -
a badge instead of the old "Passer (vote) - X/Y" text label, now that
the button itself is icon-only like every other transport control
here. Hidden (see MusicBar.html) until there's an actual tally to
show - matches every other conditionally-shown badge/pill elsewhere
in this app rather than showing "0/1" before anyone's voted. */
#voteSkipBtn { position: relative; }
.voteBadge {
position: absolute;
top: -4px;
right: -6px;
background: var(--accent);
color: #fff;
font-size: 9px;
font-weight: 700;
border-radius: 99px;
padding: 1px 4px;
min-width: 14px;
text-align: center;
line-height: 1.4;
}
/* #castBtn while actively casting - same "clearly-on" treatment as
views/player/Player.css's own .iconBtn[aria-pressed="true"] (ID-scoped
here rather than a shared .iconBtn rule since #playPauseBtn's own
@ -259,26 +91,14 @@
}
/* Pops up ABOVE the bar (same pattern as Player.css's .settingsPanel above
its gear button - position: absolute against .musicBar's own
position: relative, bottom: 100% instead of that one's top: 100%/
right: 0, since this floats up from a bottom bar rather than down from
a top-corner icon) - collapsed by default, see MusicBar.ts's
toggleQueueBtn handler. `position: absolute` (not left in normal flow)
is what stops opening this from silently growing .bottomStack's own
fixed-to-viewport height and shoving the actual controls upward
instead of floating over them - reported directly as the desktop bar
looking broken the moment the queue was opened. Spans the full bar
width (no max-width) rather than a fixed popup box - a party's queue
can run long, and a narrow column wastes the width a desktop viewport
already has going spare. display:flex/flex-direction:column here
(rather than on .pageHeader/#queueList individually) is what lets
#queueList be the only child that scrolls below - see #queueList's own
comment. */
its gear button) - collapsed by default, see MusicBar.ts's toggleQueueBtn
handler. Spans the full bar width (no max-width) rather than a fixed
popup box - a party's queue can run long, and a narrow column wastes
the width a desktop viewport already has going spare.
display:flex/flex-direction:column here (rather than on .pageHeader/
#queueList individually) is what lets #queueList be the only child
that scrolls below - see #queueList's own comment. */
.musicBarQueue {
position: absolute;
left: 0;
right: 0;
bottom: 100%;
max-height: 50vh;
margin: 0 16px;
padding: 14px 14px 0;
@ -292,23 +112,6 @@
.musicBarQueue .pageHeader { flex-shrink: 0; }
/* Always reachable regardless of z-index/positioning below (see
MusicBar.ts's own click-outside handler for the other way to close
this on desktop) - reported directly that, in mobile's full-screen
layout, the *only* previous way to close this (tap the same
#toggleQueueBtn again) sat visually underneath the now-open queue
panel itself once it covered the screen, making it unreachable. */
.queueCloseBtn {
background: none;
color: var(--muted);
font-size: 12px;
font-weight: 400;
padding: 2px;
flex-shrink: 0;
}
.queueCloseBtn:hover { background: none; color: var(--text); }
/* The only scrolling region inside .musicBarQueue - "File d'attente"
above it stays put instead of scrolling away with the tracks. */
#queueList {
@ -316,111 +119,9 @@
}
@media (max-width: 720px) {
.mobileMiniBar { display: flex; }
/* Same fixed-overlay/slide-up-from-bottom pattern as Shell.css's
.mobileNavSheet - see docs/architecture.md's "Mobile navigation".
display stays flex unconditionally (not toggled) so the transform
transition below actually animates instead of jumping. */
.musicBarPanel {
position: fixed;
inset: 0;
z-index: 25;
display: flex;
flex-direction: column;
padding: 20px 20px calc(20px + env(safe-area-inset-bottom, 0px));
background: var(--bg);
transform: translateY(100%);
transition: transform .25s ease;
overflow-y: auto;
}
.musicBarPanel.open { transform: translateY(0); }
.panelCloseBtn {
display: flex;
color: var(--muted);
margin-bottom: 24px;
}
.musicBarPanelTrack {
flex-direction: column;
text-align: center;
margin-bottom: 24px;
}
.musicBarPanelTrack .musicBarImage {
width: min(220px, 60vw);
height: min(220px, 60vw);
border-radius: 12px;
}
.musicBarInfo { margin-right: 0; align-items: center; }
.musicBarInfo strong { font-size: 19px; white-space: normal; }
.musicBarPosition { margin-top: 6px; }
.musicBarPanelTransport { gap: 28px; margin-bottom: 28px; }
.musicBarPanelTransport .musicBarPlayBig { width: 56px; height: 56px; }
.musicBarPanelTransport .musicBarPlayBig svg { width: 22px; height: 22px; }
.musicBarPanelSide { margin-top: auto; justify-content: center; gap: 20px; }
/* A full-screen overlay of its own here, not desktop's floating popup
(there's no "above the bar" to float over once .musicBarPanel is
itself a full-screen overlay) - z-index: 26 (above .musicBarPanel's
25) is still what puts it on top, but `position: relative` used to
leave it in .musicBar's own normal flow, sized by `max-height: 50vh`
inherited from the base rule above and positioned whichever the
surrounding layout happened to allow - overlapping the very
#toggleQueueBtn used to open it, with no other way to close a
`position: relative` sibling that isn't just a small popup here.
`inset: 0` + `max-height: none` makes it genuinely full-screen like
.musicBarPanel itself, so its own #queueCloseBtn is always reachable
regardless of queue length. */
.musicBarQueue {
position: fixed;
inset: 0;
z-index: 26;
max-height: none;
margin: 0;
border-radius: 0;
padding: 20px 20px calc(20px + env(safe-area-inset-bottom, 0px));
}
}
/* Rotated: the block above stacks the art on top of everything else and
sizes it off the viewport's WIDTH (60vw) - reported directly as
overflowing/needing to scroll once width and height swap roles in
landscape, where width is the one dimension this screen never lacks.
Same fixed-overlay itself, just laid out differently - the art moves
beside the title/artist instead of above them (freeing most of the
vertical stack this screen otherwise spends on it), and every size
below is capped by `vh`, the scarce dimension here, not `vw`. Button
sizes are deliberately left alone (56px .musicBarPlayBig, the same
above) - a touch target sizes to a finger, not the screen, in any
orientation. */
@media (max-width: 720px) and (orientation: landscape) {
.musicBarPanel {
padding-top: 10px;
padding-bottom: calc(10px + env(safe-area-inset-bottom, 0px));
}
.panelCloseBtn { margin-bottom: 6px; }
.musicBarPanelTrack {
flex-direction: row;
text-align: left;
margin-bottom: 8px;
}
.musicBarPanelTrack .musicBarImage {
width: min(120px, 20vh);
height: min(120px, 20vh);
}
.musicBarInfo { align-items: flex-start; }
.musicBarPanelTransport { margin-bottom: 8px; }
.musicBarRow { justify-content: center; }
.musicBarInfo { margin-right: 0; flex-basis: 100%; order: -1; text-align: center; }
.musicBarQueue { margin: 0; border-radius: 0; }
}
/* One queue entry - a plain flex row (image, then a shrinking/truncating

View file

@ -1,79 +1,36 @@
<div id="musicBarQueue" class="musicBarQueue hide">
<div class="pageHeader">
<h2>File d'attente</h2>
<button class="queueCloseBtn" id="queueCloseBtn" aria-label="Fermer la file d'attente">
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true">
<path d="M6 9l6 6 6-6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>
</button>
</div>
<div id="queueList" class="trackList"><p class="hint">Rien en attente.</p></div>
</div>
<!-- Mobile-only collapsed preview (see MusicBar.css's 720px breakpoint) -
#musicBarPanel below is already always visible on desktop, so this
only ever renders there. Tapping anywhere on it except the play
button opens the panel - see MusicBar.ts. -->
<div id="mobileMiniBar" class="mobileMiniBar">
<div class="mobileMiniProgress"><div id="miniProgressFill" class="mobileMiniProgressFill"></div></div>
<img id="miniTrackImage" class="mobileMiniArt" alt="" onerror="this.classList.add('noImage')" />
<div class="mobileMiniMeta">
<strong id="miniTrackName">-</strong>
<span class="hint" id="miniTrackArtist"></span>
</div>
<button class="iconBtn musicBarPlayBig" id="miniPlayPauseBtn" title="Lecture / pause" aria-label="Lecture / pause"></button>
</div>
<!-- Read-only - see MusicBar.ts's own comment on why this doesn't seek
(no host to arbitrate a jump, and MusicQueueService has no "seek"
concept server-side today - just play/pause/advance). -->
<div id="trackProgress" class="musicBarProgressTrack"><div id="trackProgressFill" class="musicBarProgressFill"></div></div>
<!-- Always in place on desktop (the bar itself, unconditionally visible
like before); a full-screen "now playing" overlay on mobile,
opened via #mobileMiniBar above and closed via #panelCloseBtn. -->
<div id="musicBarPanel" class="musicBarPanel">
<button class="linklike panelCloseBtn" id="panelCloseBtn" aria-label="Réduire">
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true">
<path d="M6 9l6 6 6-6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
<div class="musicBarRow">
<div id="presenceOverlay" class="presenceOverlay musicBarPresence"></div>
<img id="trackImage" class="musicBarImage" alt="" onerror="this.classList.add('noImage')" />
<div class="musicBarInfo">
<strong id="trackName">-</strong>
<span class="hint" id="trackArtist"></span>
</div>
<span id="trackPosition" class="hint musicBarPosition">0:00 / 0:00</span>
<button class="iconBtn" id="playPauseBtn" title="Lecture / pause" aria-label="Lecture / pause">&#9654;</button>
<button class="iconBtn hide" id="castBtn" title="Caster sur un Chromecast" aria-label="Caster sur un Chromecast" aria-pressed="false">
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
<rect x="1" y="4" width="22" height="16" rx="2" fill="none" stroke="currentColor" stroke-width="2"></rect>
<path d="M4 15a5 5 0 0 1 5 5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"></path>
<path d="M4 11a9 9 0 0 1 9 9" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"></path>
<circle cx="4" cy="20" r="1.5" fill="currentColor"></circle>
</svg>
</button>
<!-- Click/drag to seek - see MusicBar.ts's pointerdown/move/up
handlers. No host check needed to enable this, unlike the video
player's own seek bar: a listen party has no host at all - see
MusicQueueService.seek's own comment for why that's the right
model for this, not voteSkip's majority gate. -->
<div id="trackProgress" class="musicBarProgressTrack"><div id="trackProgressFill" class="musicBarProgressFill"></div></div>
<div class="musicBarPanelTrack">
<img id="trackImage" class="musicBarImage" alt="" onerror="this.classList.add('noImage')" />
<div class="musicBarInfo">
<strong id="trackName">-</strong>
<span class="hint" id="trackArtist"></span>
</div>
<span id="trackPosition" class="hint musicBarPosition">0:00 / 0:00</span>
</div>
<div class="musicBarPanelTransport">
<button class="iconBtn" id="voteSkipBtn" title="Passer (vote)" aria-label="Passer (vote)">
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path d="M5 5v14l10-7Z" fill="currentColor"></path><rect x="17" y="5" width="2.5" height="14" rx="1" fill="currentColor"></rect></svg>
<span id="voteBadge" class="voteBadge hide"></span>
</button>
<button class="iconBtn musicBarPlayBig" id="playPauseBtn" title="Lecture / pause" aria-label="Lecture / pause"></button>
<button class="iconBtn" id="toggleQueueBtn" title="File d'attente" aria-label="File d'attente">
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><circle cx="4.5" cy="7" r="1.3" fill="currentColor"></circle><circle cx="4.5" cy="12" r="1.3" fill="currentColor"></circle><circle cx="4.5" cy="17" r="1.3" fill="currentColor"></circle><line x1="9" y1="7" x2="20" y2="7" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"></line><line x1="9" y1="12" x2="20" y2="12" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"></line><line x1="9" y1="17" x2="20" y2="17" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"></line></svg>
</button>
</div>
<div class="musicBarPanelSide">
<div id="presenceOverlay" class="presenceOverlay musicBarPresence"></div>
<button class="iconBtn hide" id="castBtn" title="Caster sur un Chromecast" aria-label="Caster sur un Chromecast" aria-pressed="false">
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
<rect x="1" y="4" width="22" height="16" rx="2" fill="none" stroke="currentColor" stroke-width="2"></rect>
<path d="M4 15a5 5 0 0 1 5 5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"></path>
<path d="M4 11a9 9 0 0 1 9 9" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"></path>
<circle cx="4" cy="20" r="1.5" fill="currentColor"></circle>
</svg>
</button>
<button class="iconBtn" id="leaveBtn" title="Quitter" aria-label="Quitter">
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path d="M9 4.5H6a1.5 1.5 0 0 0-1.5 1.5v12A1.5 1.5 0 0 0 6 19.5h3" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"></path><path d="M13 8l4 4-4 4M9.5 12H20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"></path></svg>
</button>
</div>
<button class="linklike" id="voteSkipBtn">Passer (vote)</button>
<button class="linklike" id="toggleQueueBtn">File d'attente</button>
<button class="linklike" id="leaveBtn">Quitter</button>
</div>
<audio id="barAudio"></audio>

View file

@ -6,11 +6,9 @@ import { partyStreamUrl } from '../api/embyApi';
import { connectParty, issueCastToken, leaveParty, revokeCastToken } from '../api/party';
import type { PartyConnection } from '../api/party';
import { colorForName, escapeHtml, formatDuration, initialsFor } from '../tiles';
import { showToast } from '../toast';
import type { MusicCurrentPublic, MusicPublicState, PartyPublicState, SocketMember } from '../types';
import { initCast, localDriver, onCastAvailabilityChanged, onCastSessionEnded, startCast, stopCast } from '../cast';
import type { PlaybackDriver } from '../cast';
import { clearMediaSession, setMediaSessionActionHandlers, setMediaSessionIdle, setMediaSessionMetadata, setMediaSessionNextAction, setMediaSessionPlaybackState, setMediaSessionPositionState } from '../mediaSession';
/**
* Persistent, app-lifetime "listen party" bar - mounted ONCE by Shell.ts
@ -36,12 +34,6 @@ import { clearMediaSession, setMediaSessionActionHandlers, setMediaSessionIdle,
* for music parties.
*/
// #playPauseBtn/#miniPlayPauseBtn's two states - real SVGs (not the old
// '▶'/'⏸' text glyphs, which never matched Player.ts's own icon-button
// convention used everywhere else this app shows a play/pause control).
const PLAY_ICON_SVG = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 4.5v15l14-7.5Z" fill="currentColor"></path></svg>';
const PAUSE_ICON_SVG = '<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="5" y="4" width="5" height="16" rx="1" fill="currentColor"></rect><rect x="14" y="4" width="5" height="16" rx="1" fill="currentColor"></rect></svg>';
// See views/player/Player.ts's own HLS_CONFIG for why these are raised
// well above hls.js's defaults (10s manifest/level/frag load timeouts) -
// every track here also goes through CatalogService.proxyStream into a
@ -102,102 +94,21 @@ export function initMusicBar(root: HTMLElement): void {
const audio = barEl.querySelector('#barAudio') as HTMLAudioElement;
const playPauseBtn = barEl.querySelector('#playPauseBtn') as HTMLButtonElement;
const miniPlayPauseBtn = barEl.querySelector('#miniPlayPauseBtn') as HTMLButtonElement;
const castBtn = barEl.querySelector('#castBtn') as HTMLButtonElement;
const voteSkipBtn = barEl.querySelector('#voteSkipBtn') as HTMLButtonElement;
const toggleQueueBtn = barEl.querySelector('#toggleQueueBtn')!;
const queuePanel = barEl.querySelector('#musicBarQueue')!;
const queueCloseBtn = barEl.querySelector('#queueCloseBtn')!;
const leaveBtn = barEl.querySelector('#leaveBtn')!;
const trackProgress = barEl.querySelector('#trackProgress') as HTMLElement;
const mobileMiniBar = barEl.querySelector('#mobileMiniBar')!;
const musicBarPanel = barEl.querySelector('#musicBarPanel')!;
const panelCloseBtn = barEl.querySelector('#panelCloseBtn')!;
activeDriver = localDriver(audio);
audio.addEventListener('play', () => { if (!suppressLocalEvents && conn) conn.setMusicPaused(false); });
audio.addEventListener('pause', () => { if (!suppressLocalEvents && conn) conn.setMusicPaused(true); });
playPauseBtn.addEventListener('click', () => conn?.setMusicPaused(!activeDriver!.paused));
miniPlayPauseBtn.addEventListener('click', () => conn?.setMusicPaused(!activeDriver!.paused));
voteSkipBtn.addEventListener('click', () => conn?.voteSkip());
toggleQueueBtn.addEventListener('click', () => queuePanel.classList.toggle('hide'));
queueCloseBtn.addEventListener('click', () => queuePanel.classList.add('hide'));
// Desktop-popover convention (same as views/player/Player.ts's own
// settingsPanel) - closes on any click that lands neither inside the
// panel nor on the button that opens it. Harmless on mobile's
// full-screen layout too (there's no "outside" to click there, but
// #queueCloseBtn above is the real fix for that case - see
// MusicBar.css's own comment on why the old position: relative left
// that button unreachable once the queue was open).
document.addEventListener('click', (e) => {
if (queuePanel.classList.contains('hide')) return;
const target = e.target as Node;
if (!queuePanel.contains(target) && !toggleQueueBtn.contains(target)) {
queuePanel.classList.add('hide');
}
});
leaveBtn.addEventListener('click', () => leaveMusicParty());
// Mobile only (see MusicBar.css's 720px breakpoint - .musicBarPanel is
// already always visible on desktop, so these two are simply never
// clicked/reachable there). The mini bar itself is the tap target
// (not just an icon on it) so there's a generous hit area on a touch
// screen - #miniPlayPauseBtn's own click above stops that one
// specific tap from also opening the panel via this listener.
mobileMiniBar.addEventListener('click', (e) => {
if ((e.target as HTMLElement).closest('#miniPlayPauseBtn')) return;
musicBarPanel.classList.add('open');
});
panelCloseBtn.addEventListener('click', () => musicBarPanel.classList.remove('open'));
// Click/drag to seek - no host check, unlike the video player's own
// seek bar, since a listen party has no host at all (see activeDriver's
// own comment) - anyone dragging this updates their own driver
// immediately (for instant visual feedback, same as the video player's
// own seekFromPointerEvent) and broadcasts it, same "anyone, straight
// away" treatment play/pause already has (see MusicQueueService.seek).
// Pointer events (not click/drag) so mouse and touch share one
// implementation, and setPointerCapture keeps the drag tracking even if
// the pointer leaves the track's own bounds mid-gesture - same pattern
// as Player.ts's progressBar.
let isDraggingProgress = false;
function seekTo(seconds: number): void {
if (currentDurationSeconds <= 0) return;
activeDriver!.currentTime = Math.min(Math.max(0, seconds), currentDurationSeconds);
updatePositionDisplay();
conn?.seekMusic(activeDriver!.currentTime);
}
function seekFromPointerEvent(e: PointerEvent): void {
if (currentDurationSeconds <= 0) return;
const rect = trackProgress.getBoundingClientRect();
const fraction = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width));
seekTo(fraction * currentDurationSeconds);
}
trackProgress.addEventListener('pointerdown', (e) => {
isDraggingProgress = true;
trackProgress.setPointerCapture(e.pointerId);
seekFromPointerEvent(e);
});
trackProgress.addEventListener('pointermove', (e) => {
if (!isDraggingProgress) return;
seekFromPointerEvent(e);
});
trackProgress.addEventListener('pointerup', () => { isDraggingProgress = false; });
trackProgress.addEventListener('pointercancel', () => { isDraggingProgress = false; });
// Wired once, like Player.ts's own equivalent - no host concept here
// (see activeDriver's own comment) so, unlike there, play/pause never
// need to branch into a request-instead-of-act path.
setMediaSessionActionHandlers({
onPlay: () => conn?.setMusicPaused(false),
onPause: () => conn?.setMusicPaused(true),
onSeek: seekTo
});
// Visible to any member the moment a Chromecast is on the network - not
// gated on anything party-related, same as Player.ts's own castBtn
// (see its comment: casting is available to any member, and this bar
@ -215,25 +126,15 @@ export function initMusicBar(root: HTMLElement): void {
// server updates, same "derive it client-side from a snapshot" approach
// as Home's party-elapsed labels and the old MusicPlayer.ts - runs
// continuously rather than only while a party is active (cheap: a
// no-op while !currentItemId). Also called directly right after a local
// seek (see seekFromPointerEvent above), for instant feedback instead
// of waiting up to 500ms for this same tick to catch up.
positionTicker = window.setInterval(updatePositionDisplay, 500);
}
function updatePositionDisplay(): void {
if (!currentItemId) return;
const currentTime = activeDriver!.currentTime;
const position = barEl!.querySelector('#trackPosition')!;
position.textContent = `${formatDuration(currentTime)} / ${formatDuration(currentDurationSeconds)}`;
const percent = currentDurationSeconds > 0 ? `${Math.min(100, (currentTime / currentDurationSeconds) * 100)}%` : '0%';
(barEl!.querySelector('#trackProgressFill') as HTMLElement).style.width = percent;
// Same passive indicator on the collapsed mobile preview - see
// MusicBar.css's .mobileMiniProgress. Kept a plain width sync rather
// than its own pointer-seek wiring: dragging is one tap (expand the
// panel) away, see #mobileMiniBar's own click handler.
(barEl!.querySelector('#miniProgressFill') as HTMLElement).style.width = percent;
setMediaSessionPositionState(currentDurationSeconds, currentTime);
// no-op while !currentItemId).
positionTicker = window.setInterval(() => {
if (!currentItemId) return;
const currentTime = activeDriver!.currentTime;
const position = barEl!.querySelector('#trackPosition')!;
position.textContent = `${formatDuration(currentTime)} / ${formatDuration(currentDurationSeconds)}`;
const fill = barEl!.querySelector('#trackProgressFill') as HTMLElement;
fill.style.width = currentDurationSeconds > 0 ? `${Math.min(100, (currentTime / currentDurationSeconds) * 100)}%` : '0%';
}, 500);
}
/**
@ -265,25 +166,8 @@ export function joinMusicParty(partyId: string, party: PartyPublicState): void {
if (takeOver) conn?.confirmDeviceTakeover();
else leaveMusicParty();
},
onError: (message) => showToast(message, { variant: 'error' }),
// Fires for every disconnect, intentional or not - leaveMusicParty
// itself already clears activePartyId synchronously, well before its
// own conn.leave() actually closes the socket, so seeing it still set
// here means this device's connection died some other way (most
// often: replaced by a takeover on another device - see
// SocketGateway's "join" handler). Without this, the bar just sat
// there looking/sounding fully live forever on the old device, on a
// conn that was actually already dead underneath it - reported
// directly as "both devices end up on the session, controls never
// transfer." leaveMusicParty() is safe to call here even though the
// party already moved on: leaveParty's own deviceId check (see
// api/party.ts) makes its REST call a no-op for a stale device
// instead of ending the CURRENT device's membership by mistake.
onClose: () => {
if (!activePartyId) return;
showToast('Connexion à la party interrompue.', { variant: 'error' });
leaveMusicParty();
}
onError: (message) => alert(message),
onClose: () => {}
});
}
@ -315,14 +199,12 @@ export function leaveMusicParty(): void {
currentItemId = null;
lastMusicState = null;
latestMembers = [];
clearMediaSession();
conn?.leave();
conn = null;
barEl!.classList.add('hide');
barEl!.querySelector('#musicBarQueue')!.classList.add('hide');
barEl!.querySelector('#musicBarPanel')!.classList.remove('open');
leaveParty(partyId).catch(() => {});
}
@ -356,37 +238,11 @@ async function castTrack(title: string): Promise<void> {
* pause listeners already do (see initMusicBar), since casting here has
* no host gate to check first (see activeDriver's own comment). */
function onCastStateChanged(): void {
setPlayIcon(!activeDriver!.paused);
const playPauseBtn = barEl!.querySelector('#playPauseBtn') as HTMLButtonElement;
playPauseBtn.textContent = activeDriver!.paused ? '▶' : '⏸';
if (!suppressLocalEvents) conn?.setMusicPaused(activeDriver!.paused);
}
/** Both play/pause buttons - the main one inside .musicBarPanel and the
* collapsed mobile preview's own - always show the same state, so
* every call site that used to touch just #playPauseBtn's textContent
* updates both here instead of duplicating the innerHTML swap at each
* one. */
function setPlayIcon(isPlaying: boolean): void {
const icon = isPlaying ? PAUSE_ICON_SVG : PLAY_ICON_SVG;
(barEl!.querySelector('#playPauseBtn') as HTMLButtonElement).innerHTML = icon;
(barEl!.querySelector('#miniPlayPauseBtn') as HTMLButtonElement).innerHTML = icon;
setMediaSessionPlaybackState(isPlaying);
}
/** Same pairing as setPlayIcon above, for the track metadata shown in
* both .musicBarPanel and the collapsed mobile preview. */
function setTrackMeta(imageUrl: string, name: string, artist: string): void {
const trackImage = barEl!.querySelector('#trackImage') as HTMLImageElement;
const miniTrackImage = barEl!.querySelector('#miniTrackImage') as HTMLImageElement;
trackImage.src = imageUrl;
trackImage.classList.remove('noImage');
miniTrackImage.src = imageUrl;
miniTrackImage.classList.remove('noImage');
barEl!.querySelector('#trackName')!.textContent = name;
barEl!.querySelector('#miniTrackName')!.textContent = name;
barEl!.querySelector('#trackArtist')!.textContent = artist;
barEl!.querySelector('#miniTrackArtist')!.textContent = artist;
}
/**
* Starts casting - available to any listener, not just... there is no
* "just", a listen party has no host (see activeDriver's own comment).
@ -460,13 +316,16 @@ function endCastAndResync(): void {
* (isCasting) delegated to castTrack/loadTrackLocal below.
*/
function loadTrack(track: MusicCurrentPublic, elapsedSeconds: number, isPaused: boolean): void {
const trackImage = barEl!.querySelector('#trackImage') as HTMLImageElement;
const trackName = barEl!.querySelector('#trackName')!;
const trackArtist = barEl!.querySelector('#trackArtist')!;
currentItemId = track.itemId;
currentDurationSeconds = track.durationSeconds || 0;
setTrackMeta(track.itemImageUrl || '', track.itemName || '', [track.artistName, track.albumName].filter(Boolean).join(' - '));
// Artist/album kept separate here, unlike setTrackMeta's own combined
// display line above - the OS's own now-playing UI has real fields for
// both, no need to pre-join them into one string for it.
setMediaSessionMetadata(track.itemName || '', track.artistName || '', track.albumName || '', track.itemImageUrl || '');
trackImage.src = track.itemImageUrl || '';
trackImage.classList.remove('noImage');
trackName.textContent = track.itemName || '';
trackArtist.textContent = [track.artistName, track.albumName].filter(Boolean).join(' - ');
if (isCasting) {
castTrack([track.itemName, track.artistName].filter(Boolean).join(' - ')).catch((err) => {
@ -544,43 +403,37 @@ function loadTrackLocal(track: MusicCurrentPublic, elapsedSeconds: number, isPau
*/
function updateVoteSkipLabel(): void {
const voteSkipBtn = barEl!.querySelector('#voteSkipBtn') as HTMLButtonElement;
const voteBadge = barEl!.querySelector('#voteBadge') as HTMLElement;
const current = lastMusicState?.current;
voteSkipBtn.disabled = !current;
if (!current) {
voteBadge.classList.add('hide');
voteSkipBtn.textContent = 'Passer (vote)';
return;
}
const connectedCount = latestMembers.filter((m) => m.connected).length || 1;
const needed = Math.floor(connectedCount / 2) + 1;
voteBadge.textContent = `${lastMusicState!.skipVotes}/${needed}`;
voteBadge.classList.remove('hide');
voteSkipBtn.textContent = `Passer (vote) - ${lastMusicState!.skipVotes}/${needed}`;
}
function applyMusicState(music: MusicPublicState): void {
lastMusicState = music;
const playPauseBtn = barEl!.querySelector('#playPauseBtn') as HTMLButtonElement;
const miniPlayPauseBtn = barEl!.querySelector('#miniPlayPauseBtn') as HTMLButtonElement;
const castBtn = barEl!.querySelector('#castBtn') as HTMLButtonElement;
const queueList = barEl!.querySelector('#queueList')!;
const trackName = barEl!.querySelector('#trackName')!;
const trackArtist = barEl!.querySelector('#trackArtist')!;
const trackImage = barEl!.querySelector('#trackImage') as HTMLImageElement;
const audio = barEl!.querySelector('#barAudio') as HTMLAudioElement;
// Queue/vote-button/play-icon updates happen first, unconditionally,
// deliberately BEFORE the track-loading branch below (see loadTrack's
// own comment on why - same ordering as Player.ts/the old MusicPlayer.ts).
setPlayIcon(Boolean(music.current && !music.current.isPaused));
playPauseBtn.textContent = music.current && !music.current.isPaused ? '⏸' : '▶';
playPauseBtn.disabled = !music.current;
miniPlayPauseBtn.disabled = !music.current;
// Nothing for startCasting to actually cast with an empty queue - same
// "disable, don't just silently no-op" treatment as playPauseBtn above.
castBtn.disabled = !music.current && !isCasting;
updateVoteSkipLabel();
// A *registered* nexttrack handler is what makes the OS show the
// control at all (see setMediaSessionNextAction's own comment) - same
// "disable, don't just silently no-op" case as playPauseBtn/castBtn
// above, just with no disabled state of its own to set instead.
setMediaSessionNextAction(music.current ? () => conn?.voteSkip() : null);
// Its own .queueRow, NOT views/album's shared .trackRow (see
// styles/trackList.css) - that one is a CSS grid whose 4 columns
@ -603,13 +456,13 @@ function applyMusicState(music: MusicPublicState): void {
if (!music.current) {
currentItemId = null;
setTrackMeta('', 'Rien ne joue pour le moment.', '');
setMediaSessionIdle();
trackName.textContent = 'Rien ne joue pour le moment.';
trackArtist.textContent = '';
trackImage.src = '';
if (hls) { hls.destroy(); hls = null; }
activeDriver!.pause();
audio.removeAttribute('src'); // harmless no-op while casting - the local element is already srcless then, see startCasting
(barEl!.querySelector('#trackProgressFill') as HTMLElement).style.width = '0%';
(barEl!.querySelector('#miniProgressFill') as HTMLElement).style.width = '0%';
} else if (music.current.itemId !== currentItemId) {
loadTrack(music.current, music.current.elapsedSeconds, music.current.isPaused);
} else {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1,003 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

View file

@ -1,16 +0,0 @@
{
"name": "Watch Party",
"short_name": "Watch Party",
"description": "Regardez et écoutez en même temps que les autres utilisateurs de votre serveur Emby.",
"lang": "fr",
"start_url": "/",
"display": "standalone",
"background_color": "#151216",
"theme_color": "#221d24",
"categories": ["entertainment", "multimedia"],
"icons": [
{ "src": "icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
{ "src": "icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
{ "src": "icons/icon-512-maskable.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
]
}

View file

@ -1,46 +0,0 @@
/* ---- Mobile-only "install this as an app" banner - see pwaInstall.ts.
A normal-flow element at the very top of <body>, before #app, not a
fixed/sticky overlay - pushes the app down by its own height instead
of needing to coordinate an offset with shell/Shell.css's own
.mobileTopBar/.libraryHeader (already stacked on top of each other at
the same breakpoint) or alphaIndex.css's rail. Scrolls away with the
page like any other content above the sticky chrome, same as a
browser's own native install infobar would. ---- */
.pwaInstallBanner {
display: none;
align-items: center;
gap: 10px;
padding: 10px 14px;
background: var(--accent);
color: #fff;
font-size: 13px;
line-height: 1.35;
}
.pwaInstallText { flex: 1; min-width: 0; }
.pwaInstallBtn {
flex-shrink: 0;
background: #fff;
color: var(--accent);
font-size: 12px;
font-weight: 700;
padding: 6px 12px;
}
.pwaInstallBtn:hover { background: rgba(255, 255, 255, .85); }
.pwaInstallClose {
flex-shrink: 0;
background: none;
color: #fff;
font-size: 20px;
line-height: 1;
padding: 0 2px;
}
.pwaInstallClose:hover { background: none; opacity: .8; }
@media (max-width: 720px) {
.pwaInstallBanner { display: flex; }
}

View file

@ -1,108 +0,0 @@
// Mobile-only "install this as an app" nudge - see docs/architecture.md.
// Self-mounting like toast.ts (no per-route/per-party state to attach),
// but the beforeinstallprompt listener below is attached the moment
// this module is first imported - app.ts does that at the very top,
// well before boot()'s async login check - rather than lazily on first
// use like toast.ts: the browser only ever fires this event once per
// page load, often before any UI exists to react to it, and there's no
// way to ask for a replay if a listener attaches too late to catch it.
import './pwaInstall.css';
/** Chrome/Edge/Android-WebView's own install-prompt event - not in
* lib.dom.d.ts (Safari/Firefox never implement it at all). */
interface BeforeInstallPromptEvent extends Event {
prompt(): Promise<void>;
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
}
const DISMISSED_KEY = 'watchparty:pwaInstallDismissed';
const MOBILE_QUERY = '(max-width: 720px)';
let deferredPrompt: BeforeInstallPromptEvent | null = null;
let bannerEl: HTMLElement | null = null;
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault(); // suppress the browser's own mini-infobar - this banner replaces it
deferredPrompt = e as BeforeInstallPromptEvent;
renderIfEligible();
});
window.addEventListener('appinstalled', () => {
deferredPrompt = null;
removeBanner();
});
function isStandalone(): boolean {
return window.matchMedia('(display-mode: standalone)').matches
|| (navigator as unknown as { standalone?: boolean }).standalone === true; // iOS Safari's own flag - no display-mode query there
}
/** No feature-detection exists for "is this iOS Safari" - beforeinstallprompt
* simply never fires there, which is the actual fact this module cares
* about, but by the time that's observable it's too late to show
* install instructions instead. Checked only as a fallback once
* deferredPrompt has had a real chance to arrive - see renderIfEligible. */
function isIOS(): boolean {
return /iphone|ipad|ipod/i.test(navigator.userAgent)
|| (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1); // iPadOS 13+ reports as a Mac
}
function removeBanner(): void {
bannerEl?.remove();
bannerEl = null;
}
function dismiss(): void {
localStorage.setItem(DISMISSED_KEY, '1');
removeBanner();
}
async function install(): Promise<void> {
if (!deferredPrompt) return;
const prompt = deferredPrompt;
deferredPrompt = null; // a captured prompt can only ever be used once
await prompt.prompt();
const { outcome } = await prompt.userChoice;
// 'dismissed' here means the user closed the browser's own native
// picker without installing - not the same signal as our own close
// button (dismiss() above), so this banner stays up rather than
// remembering "never ask again" over a single not-right-now.
if (outcome === 'accepted') removeBanner();
}
function renderIfEligible(): void {
if (bannerEl) return;
if (isStandalone()) return;
if (localStorage.getItem(DISMISSED_KEY)) return;
if (!window.matchMedia(MOBILE_QUERY).matches) return;
const ios = isIOS();
if (!deferredPrompt && !ios) return; // no install path this module actually knows how to help with
bannerEl = document.createElement('div');
bannerEl.className = 'pwaInstallBanner';
bannerEl.innerHTML = ios
? `<span class="pwaInstallText">Installe Watch Party : appuie sur <strong>Partager</strong> puis « Sur l'écran d'accueil ».</span>
<button type="button" class="pwaInstallClose" aria-label="Fermer">&times;</button>`
: `<span class="pwaInstallText">Installe Watch Party sur ton écran d'accueil pour y accéder plus vite.</span>
<button type="button" class="pwaInstallBtn">Installer</button>
<button type="button" class="pwaInstallClose" aria-label="Fermer">&times;</button>`;
bannerEl.querySelector('.pwaInstallClose')!.addEventListener('click', dismiss);
bannerEl.querySelector('.pwaInstallBtn')?.addEventListener('click', () => { install().catch(() => {}); });
document.body.insertBefore(bannerEl, document.body.firstChild);
}
/**
* Called once by Shell.ts after a successful login - covers both the
* case where beforeinstallprompt already fired before Shell existed to
* react to it (deferredPrompt sitting ready, banner never actually
* shown yet) and iOS, which has no event at all to trigger this from.
* Also re-checked on resize: crossing the 720px breakpoint live (an
* unfolding or rotated device) shouldn't need a reload to notice.
*/
export function initPwaInstallPrompt(): void {
renderIfEligible();
window.addEventListener('resize', renderIfEligible);
}

View file

@ -62,190 +62,20 @@
padding: 24px clamp(16px, 3vw, 48px) 80px;
}
/* ==== Mobile navigation (<=720px) ====
Replaces the sidebar outright - see docs/architecture.md's "Mobile
navigation" section for why a bottom sheet was chosen over reshaping
the sidebar into a horizontal bar. Every rule below the base
`display: none` only ever applies once the 720px media query turns
it back on, so none of this affects desktop. */
.mobileTopBar,
.mobileNavTrigger,
.mobileNavScrim,
.mobileNavSheet {
display: none;
}
.mobileTopBar {
position: sticky;
top: 0;
z-index: 3;
height: var(--mobile-topbar-h);
padding: 0 16px;
background: var(--bg-header);
}
.mobileBrand { font-size: 16px; font-weight: 700; }
/* position:fixed, not sticky - #musicBar below already relies on a
fixed wrapper to stay docked regardless of how far #mainContent's
own (whole-page, not internally-scrolled) content runs. */
.bottomStack {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 15;
display: flex;
flex-direction: column;
}
.mobileNavTrigger {
align-items: center;
justify-content: center;
gap: 8px;
background: var(--bg-header);
border: none;
border-top: 1px solid rgba(255, 255, 255, .08);
color: var(--text);
font-family: inherit;
font-size: 13px;
font-weight: 600;
padding: 13px;
padding-bottom: calc(13px + env(safe-area-inset-bottom, 0px));
}
.mobileNavScrim {
position: fixed;
inset: 0;
z-index: 20;
background: rgba(0, 0, 0, .5);
opacity: 0;
pointer-events: none;
transition: opacity .2s;
/* A touch starting here has no scrollable content of its own to
"contain" (overscroll-behavior wouldn't apply) - this is what
stops the browser reading a drag over the dimmed backdrop as a
page-level gesture (pull-to-refresh) instead of just dismissing
the sheet via the existing tap-to-close below. */
touch-action: none;
}
.mobileNavScrim.open { opacity: 1; pointer-events: auto; }
.mobileNavSheet {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 21;
max-height: 75vh;
overflow-y: auto;
/* Stops a drag that hits the top of the sheet's OWN scroll (its
library grid can be taller than 75vh) from chaining past this
element to the page underneath and surfacing as pull-to-refresh -
complements touch-action:none on the dedicated grabber handle
below, which instead covers a drag that never scrolls anything. */
overscroll-behavior: contain;
background: var(--bg-header);
border-radius: 16px 16px 0 0;
padding: 8px 16px calc(20px + env(safe-area-inset-bottom, 0px));
transform: translateY(100%);
transition: transform .22s ease;
box-shadow: 0 -8px 24px rgba(0, 0, 0, .3);
}
.mobileNavSheet.open { transform: translateY(0); }
/* Dragging (see Shell.ts) suspends the transition above for 1:1
tracking under the finger, then this class restores it for the
snap-back/close animation - same "instant while dragging, animated
on release" split views/player/Player.css's own seek bar uses. */
.mobileNavSheet.dragging { transition: none; }
/* Bigger drag/touch target than the pill it contains (36x4px is far
below a real touch-target minimum) - see Shell.html/Shell.ts. */
.sheetGrabberHandle {
display: flex;
justify-content: center;
padding: 14px 0 10px;
touch-action: none;
cursor: grab;
}
.sheetGrabber {
width: 36px;
height: 4px;
border-radius: 99px;
background: var(--muted);
}
/* Accueil and the (conditional) Listes de lecture row both use this -
same visual weight, bookending the "Bibliothèques" group between
them, so the one part whose size actually varies (however many
libraries Emby returns) reads as visually distinct from the two
fixed rows around it. */
.sheetPrimary {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
background: var(--panel);
border: none;
border-radius: 10px;
color: var(--text);
font-family: inherit;
font-size: 14px;
font-weight: 600;
padding: 12px;
margin: 4px 0;
text-decoration: none;
}
.sheetPrimary.active { background: var(--accent); color: #fff; }
.sheetGrid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin: 4px 0 10px;
}
.sheetItem {
display: flex;
align-items: center;
gap: 9px;
background: var(--panel);
border-radius: 10px;
color: var(--text);
font-size: 13px;
padding: 12px 10px;
text-decoration: none;
}
.sheetItem.active { background: var(--accent); color: #fff; font-weight: 600; }
.navIcon { width: 20px; height: 20px; flex-shrink: 0; display: block; }
.navIcon svg { width: 100%; height: 100%; }
.sheetLogout {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
margin-top: 8px;
padding: 12px;
border: 1px solid rgba(255, 255, 255, .15);
border-radius: 10px;
color: var(--muted);
font-size: 13px;
}
/* Sidebar becomes a horizontal scrollable topbar on narrow screens. */
@media (max-width: 720px) {
.appShell { flex-direction: column; }
.sidebar { display: none; }
.mobileTopBar { display: flex; align-items: center; }
.mobileNavTrigger { display: flex; }
.mobileNavScrim { display: block; }
.mobileNavSheet { display: block; }
.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; }
}

View file

@ -3,49 +3,11 @@
<div class="sidebarBrand">Watch Party</div>
<nav class="sidebarNav" id="sidebarNav">
<a href="#/home" class="navItem">Accueil</a>
<a href="#/static" class="navItem">Lounge parties</a>
<div class="navSectionLabel">Bibliothèques</div>
<p class="hint" id="libStatus"><span class="spinner"></span>Chargement...</p>
</nav>
<button class="linklike sidebarLogout logoutBtn" id="logout"></button>
<button class="linklike sidebarLogout" id="logout"></button>
</aside>
<!-- Mobile-only (see Shell.css's 720px breakpoint) - the sidebar
above is display:none there instead of reshaping into a
horizontal bar. See docs/architecture.md's "Mobile navigation". -->
<header class="mobileTopBar">
<span class="mobileBrand">Watch Party</span>
</header>
<main id="mainContent" class="mainContent"></main>
<!-- Wraps #musicBar so the trigger below always sits directly above
whatever height the bar actually renders at - see Shell.css. -->
<div class="bottomStack">
<div id="musicBar" class="musicBar"></div>
<button class="mobileNavTrigger" id="mobileNavTrigger" aria-expanded="false" aria-controls="mobileNavSheet">
Parcourir
<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">
<path d="M6 9l6 6 6-6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>
</button>
</div>
<div class="mobileNavScrim" id="mobileNavScrim"></div>
<div class="mobileNavSheet" id="mobileNavSheet">
<!-- Bigger invisible drag target than the visible pill itself (see
.sheetGrabberHandle) - touch-action:none so this app's own
pointer handlers (Shell.ts) drive the drag instead of the
browser's default touch handling, which otherwise reads a
downward drag here as pull-to-refresh (the sheet sits right at
the top of the page's own scroll position, same conflict a
plain scrim tap-only close used to leave unsolved). -->
<div class="sheetGrabberHandle" id="sheetGrabberHandle"><div class="sheetGrabber"></div></div>
<a href="#/home" class="navItem sheetPrimary"><span class="navIcon" data-icon="home"></span>Accueil</a>
<a href="#/static" class="navItem sheetPrimary"><span class="navIcon" data-icon="static"></span>Lounge parties</a>
<div class="navSectionLabel">Bibliothèques</div>
<div class="sheetGrid" id="mobileLibGrid"></div>
<div id="mobilePlaylistsSlot"></div>
<button class="linklike sheetLogout logoutBtn" id="mobileLogout"></button>
</div>
<div id="musicBar" class="musicBar"></div>
</div>

View file

@ -5,169 +5,47 @@ import './Shell.css';
import html from './Shell.html';
import { auth, getLibraries, logout } from '../api/embyApi';
import { initMusicBar } from '../musicBar/MusicBar';
import { initPwaInstallPrompt } from '../pwaInstall';
import type { LibraryDto } from '../types';
// One icon per Emby CollectionType - only used by the mobile bottom
// sheet (see docs/architecture.md's "Mobile navigation"); the desktop
// sidebar stays plain text, unchanged. Same small inline-SVG-constant
// convention as views/player/Player.ts's own icon strings.
const HOME_ICON_SVG = '<svg viewBox="0 0 24 24"><path d="M3.5 11.5 12 4l8.5 7.5" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/><path d="M5.5 10v9a1 1 0 0 0 1 1H10v-5.5a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1V20h3.5a1 1 0 0 0 1-1v-9" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>';
const MOVIES_ICON_SVG = '<svg viewBox="0 0 24 24"><rect x="3.5" y="6" width="17" height="13" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.8"/><path d="M3.5 6 7 3h3l-2.5 3M11 6l2.5-3h3L14 6m3.5 0 2.5-3" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>';
const SHOWS_ICON_SVG = '<svg viewBox="0 0 24 24"><rect x="3" y="5.5" width="18" height="12" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.8"/><path d="M9 20.5h6M12 17.5v3" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>';
const MUSIC_ICON_SVG = '<svg viewBox="0 0 24 24"><circle cx="8" cy="17" r="2.6" fill="none" stroke="currentColor" stroke-width="1.8"/><path d="M10.5 17V5.5l7-1.5v10" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/><circle cx="17.5" cy="14" r="2.6" fill="none" stroke="currentColor" stroke-width="1.8"/></svg>';
const MUSICVIDEOS_ICON_SVG = '<svg viewBox="0 0 24 24"><rect x="3" y="5.5" width="18" height="13" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.8"/><path d="M10 9.5 15.5 12 10 14.5Z" fill="currentColor"/></svg>';
const PLAYLISTS_ICON_SVG = '<svg viewBox="0 0 24 24"><circle cx="4.5" cy="7" r="1.3" fill="currentColor"/><circle cx="4.5" cy="12" r="1.3" fill="currentColor"/><circle cx="4.5" cy="17" r="1.3" fill="currentColor"/><line x1="9" y1="7" x2="20" y2="7" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/><line x1="9" y1="12" x2="20" y2="12" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/><line x1="9" y1="17" x2="20" y2="17" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>';
// The "Lounge parties" tab (static watch parties) - a circular "resume" arrow.
const STATIC_PARTIES_ICON_SVG = '<svg viewBox="0 0 24 24"><path d="M19.5 12a7.5 7.5 0 1 1-2.2-5.3" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/><path d="M19.5 4v4h-4" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/><path d="M10 9l5 3-5 3Z" fill="currentColor"/></svg>';
// Emby could in principle add a CollectionType this app doesn't map yet
// (server/src/services/CatalogService.ts's VISIBLE_COLLECTION_TYPES
// would need updating first either way) - falls back to a plain folder
// glyph rather than rendering an empty icon slot.
const GENERIC_LIBRARY_ICON_SVG = '<svg viewBox="0 0 24 24"><rect x="3.5" y="5" width="17" height="14" rx="2" fill="none" stroke="currentColor" stroke-width="1.8"/></svg>';
const COLLECTION_ICONS: Record<string, string> = {
movies: MOVIES_ICON_SVG,
tvshows: SHOWS_ICON_SVG,
music: MUSIC_ICON_SVG,
musicvideos: MUSICVIDEOS_ICON_SVG,
playlists: PLAYLISTS_ICON_SVG,
};
/** One `<a>` per library, shared by the desktop sidebar (plain text)
* and the mobile sheet (icon + text) - `withIcon` is the only
* difference between the two call sites below. */
function createLibraryLink(lib: LibraryDto, className: string, withIcon: boolean): HTMLAnchorElement {
const a = document.createElement('a');
a.href = `#/library/${lib.id}`;
a.className = className;
if (withIcon) {
const icon = document.createElement('span');
icon.className = 'navIcon';
icon.innerHTML = COLLECTION_ICONS[lib.collectionType] || GENERIC_LIBRARY_ICON_SVG;
a.appendChild(icon);
}
a.appendChild(document.createTextNode(lib.name));
return a;
}
export async function renderShell(appEl: HTMLElement): Promise<HTMLElement> {
appEl.innerHTML = html;
appEl.querySelector('[data-icon="home"]')!.innerHTML = HOME_ICON_SVG;
appEl.querySelector('[data-icon="static"]')!.innerHTML = STATIC_PARTIES_ICON_SVG;
appEl.querySelector('#logout')!.textContent = `Se déconnecter (${auth.userName || ''})`;
// Mounted once, outside #mainContent, so a listen party survives
// navigating the router - see musicBar/MusicBar.ts's header comment.
initMusicBar(appEl.querySelector('#musicBar') as HTMLElement);
initPwaInstallPrompt();
// Both the sidebar's and the mobile sheet's logout button - same
// label, same handler, one line each instead of a shared DOM node
// fought over by two very differently-laid-out containers.
appEl.querySelectorAll<HTMLButtonElement>('.logoutBtn').forEach((btn) => {
btn.textContent = `Se déconnecter (${auth.userName || ''})`;
btn.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();
});
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')!;
const mobileLibGrid = appEl.querySelector('#mobileLibGrid')!;
const mobilePlaylistsSlot = appEl.querySelector('#mobilePlaylistsSlot')!;
try {
const libraries = await getLibraries();
status.remove();
libraries.forEach((lib) => {
nav.appendChild(createLibraryLink(lib, 'navItem', false));
// 'playlists' gets pulled out of the "Bibliothèques" group into
// its own fixed row - see docs/architecture.md. Everything else
// (movies/tvshows/music/musicvideos, whatever mix this server
// actually has) lands in the grid, unchanged in count or order.
if (lib.collectionType === 'playlists') {
mobilePlaylistsSlot.appendChild(createLibraryLink(lib, 'navItem sheetPrimary', true));
} else {
mobileLibGrid.appendChild(createLibraryLink(lib, 'navItem sheetItem', true));
}
const a = document.createElement('a');
a.href = `#/library/${lib.id}`;
a.className = 'navItem';
a.textContent = lib.name;
nav.appendChild(a);
});
} catch {
status.textContent = 'Bibliothèques indisponibles.';
}
const highlight = () => {
appEl.querySelectorAll('.navItem').forEach((a) => {
const href = a.getAttribute('href');
const isHome = href === '#/home';
// The static-parties tab stays highlighted on one party's own page too.
const isStaticSection = href === '#/static' && location.hash.startsWith('#/static/');
a.classList.toggle('active', href === location.hash || (isHome && !location.hash) || isStaticSection);
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);
// ---- Mobile bottom sheet - see Shell.css's "Mobile navigation". ----
const navTrigger = appEl.querySelector('#mobileNavTrigger') as HTMLButtonElement;
const navSheet = appEl.querySelector('#mobileNavSheet') as HTMLElement;
const navScrim = appEl.querySelector('#mobileNavScrim')!;
const closeMobileNav = () => {
navSheet.classList.remove('open');
navScrim.classList.remove('open');
navTrigger.setAttribute('aria-expanded', 'false');
};
navTrigger.addEventListener('click', () => {
const opening = !navSheet.classList.contains('open');
navSheet.classList.toggle('open', opening);
navScrim.classList.toggle('open', opening);
navTrigger.setAttribute('aria-expanded', String(opening));
});
navScrim.addEventListener('click', closeMobileNav);
// Any of the three zones (Accueil, a library, Listes de lecture) -
// the sheet itself has no other clickable children, so this one
// delegated listener covers all of them without a per-link handler.
navSheet.addEventListener('click', (e) => {
if ((e.target as HTMLElement).closest('.navItem')) closeMobileNav();
});
window.addEventListener('hashchange', closeMobileNav);
// Drag-to-dismiss on the grabber handle only - not the whole sheet,
// which would fight the library grid's own scroll/tap targets (see
// docs/architecture.md's "Mobile navigation, take two"). Reported
// directly: without this, a scrim tap was the ONLY way to close the
// sheet, and the instinctive swipe-it-down gesture instead fell
// through to the browser's own pull-to-refresh (see Shell.css's
// touch-action/overscroll-behavior on the scrim/sheet themselves -
// this is the other half, giving that swipe something to actually do).
const CLOSE_DRAG_THRESHOLD_PX = 90;
const grabberHandle = appEl.querySelector('#sheetGrabberHandle') as HTMLElement;
let dragStartY: number | null = null;
let dragDeltaY = 0;
grabberHandle.addEventListener('pointerdown', (e) => {
dragStartY = e.clientY;
dragDeltaY = 0;
grabberHandle.setPointerCapture(e.pointerId);
navSheet.classList.add('dragging');
});
grabberHandle.addEventListener('pointermove', (e) => {
if (dragStartY === null) return;
dragDeltaY = Math.max(0, e.clientY - dragStartY); // only follows a downward drag - closing is the only thing this gesture does
navSheet.style.transform = `translateY(${dragDeltaY}px)`;
});
function endDrag() {
if (dragStartY === null) return;
dragStartY = null;
navSheet.classList.remove('dragging');
navSheet.style.transform = ''; // hands back to the .open class's own rule - either translateY(0) (snap back) or the closeMobileNav() call below immediately overrides it anyway
if (dragDeltaY > CLOSE_DRAG_THRESHOLD_PX) closeMobileNav();
}
grabberHandle.addEventListener('pointerup', endDrag);
grabberHandle.addEventListener('pointercancel', endDrag);
return appEl.querySelector('#mainContent') as HTMLElement;
}

View file

@ -14,7 +14,6 @@
--accent: #52b54b; /* Emby's theme-primary-color (hsl(116, 41.7%, 50.2%)) */
--accent-bright: #6ccf65; /* Emby's --theme-accent-text-color-darkbg */
--danger: #ff6b6b;
--mobile-topbar-h: 49px; /* shell/Shell.css's .mobileTopBar - a fixed height so any other sticky element (e.g. views/library's .libraryHeader) can stack directly below it instead of guessing its rendered height */
}
* { box-sizing: border-box; }

View file

@ -20,25 +20,9 @@
.trackIndex { color: var(--muted); font-size: 13px; text-align: center; }
.trackName { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.trackDuration { color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums; }
.trackRow button { flex-shrink: 0; padding: 6px 10px; font-size: 12px; }
/* A compact circle, not the wide text button this used to be (views/album's
own "Écouter ensemble"/"Ajouter à la file") - with a whole album's worth
of these stacked in a list, one word-length pill per row read as
noisier than the same action shown as a plain play/plus icon, the
same density real track lists use. */
.trackRow button.trackIconBtn {
flex-shrink: 0;
width: 28px;
height: 28px;
padding: 0;
border-radius: 50%;
background: none;
border: 1px solid rgba(255, 255, 255, .18);
color: var(--muted);
display: flex;
align-items: center;
justify-content: center;
@media (max-width: 600px) {
.trackRow { grid-template-columns: 24px 1fr; }
.trackRow .trackDuration, .trackRow button { grid-column: 2; justify-self: start; }
}
.trackRow button.trackIconBtn:hover { border-color: var(--text); color: var(--text); background: none; }
.trackRow button.trackIconBtn svg { width: 12px; height: 12px; }

View file

@ -4,34 +4,17 @@
/* base.css's global `input {}` rule already gives this the right look
(padding/border/background) - this just bounds its width and spaces it
from the grid below, shared by views/library, views/artist, views/album -
see tiles.ts's wireSearchFilter. Sticky, not just top-of-page: asked
for directly, so it stays reachable while scrolling a long grid
instead of scrolling away with the page title above it. Its own
background (base.css's input rule already gives it var(--panel), not
transparent) is what keeps tiles scrolling underneath from showing
through its own bounds once stuck. */
see tiles.ts's wireSearchFilter. */
.searchBar {
max-width: 320px;
margin-bottom: 16px;
position: sticky;
top: 0;
z-index: 2;
}
.searchEmpty { margin-top: 8px; }
/* The 160px floor was too wide for a real phone once .mainContent's own
side padding is subtracted - reported directly: a ~360-393px-wide
viewport (a common real Android width, not a rare edge case) only
ever fit one column. clamp(), not a max-width media query breakpoint:
this scales the minimum continuously with the viewport instead of
jumping at one arbitrary width, so it's already right at every size
in between, not just "mobile" and "desktop" - 28vw lands close to
100px on a narrow phone and hits the 160px ceiling (today's exact
desktop value, unchanged) well before any actual desktop width. */
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(clamp(100px, 28vw, 160px), 1fr));
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 20px 16px;
}
@ -144,33 +127,3 @@
}
.tileBtn { font-size: 11px; padding: 6px 4px; }
}
/* Artist/album tiles (tiles.ts's artistTileHtml/albumTileHtml) - an <a>
directly, not a <div> with a separate absolutely-positioned
.tileLink/.tileOverlay on top of it: there's only ever the one
action (open the artist/album page), so the whole tile just being
the link is simpler than reusing machinery built for a second,
hover-revealed set of actions these two never have. */
.tile.tileArtist,
.tile.tileAlbum {
text-decoration: none;
color: var(--text);
}
.tile.tileArtist img,
.tile.tileAlbum img {
aspect-ratio: 1 / 1;
}
.tile.tileArtist img {
border-radius: 50%;
}
.tile.tileArtist .tileLabel {
text-align: center;
}
.tile.tileArtist:hover .tileLabel,
.tile.tileAlbum:hover .tileLabel {
text-decoration: underline;
}

View file

@ -2,7 +2,6 @@
// grid, and the artist page, so they all stay visually/behaviorally
// identical instead of drifting.
import './tiles.css';
import { showToast } from './toast';
import type { ItemDto } from './types';
export function escapeHtml(str: string): string {
@ -50,20 +49,10 @@ const DRILL_DOWN: Record<string, { route: string; label: string }> = {
Playlist: { route: 'playlist', label: 'Voir la playlist' }
};
/**
* A poster tile - movies/shows/musicvideos/playlists, anything that
* still gets Emby's own portrait artwork. MusicArtist/MusicAlbum get a
* completely different shape below (a circle/a square, no hover
* overlay) since a movie poster's proportions and "hover for actions"
* pattern never fit an artist photo or an album cover - see
* docs/architecture.md's "Music UI rework".
*/
function posterTileHtml(item: ItemDto): string {
export function tileHtml(item: ItemDto): string {
const drill = item.type ? DRILL_DOWN[item.type] : undefined;
const drillHref = drill ? `#/${drill.route}/${item.id}` : null;
// Video -> "watch party", audio -> "listen party" - see the party kinds
// in docs/architecture.md (the third, "lounge party", is the static one).
const partyLabel = item.type === 'Audio' ? 'Lancer une listen party' : 'Lancer une watch party';
const partyLabel = item.type === 'Audio' ? 'Écouter ensemble' : 'Lancer une watch party';
return `
<div class="tile">
${drillHref ? `<a href="${drillHref}" class="tileLink" aria-label="${escapeHtml(item.name)}"></a>` : ''}
@ -78,47 +67,7 @@ function posterTileHtml(item: ItemDto): string {
}
/**
* An artist tile - a circle, the one shape every music app uses for a
* person/act, never a rectangle. No hover overlay at all: unlike a
* poster tile, there's only ever one thing to do with an artist photo
* (open the artist page), so the whole tile is just that one link -
* see DRILL_DOWN's own MusicArtist entry.
*/
function artistTileHtml(item: ItemDto): string {
return `
<a href="#/artist/${item.id}" class="tile tileArtist" aria-label="${escapeHtml(item.name)}">
<img src="${item.imageUrl}" alt="" loading="lazy" onerror="this.classList.add('noImage')" />
<span class="tileLabel">${escapeHtml(item.name)}</span>
</a>`;
}
/** An album tile - square, matching the cover art's own real proportions
* (a movie-poster 2:3 box only ever stretched/cropped it oddly). Same
* "whole tile is the one link" reasoning as artistTileHtml above. */
function albumTileHtml(item: ItemDto): string {
return `
<a href="#/album/${item.id}" class="tile tileAlbum" aria-label="${escapeHtml(item.name)}">
<img src="${item.imageUrl}" alt="" loading="lazy" onerror="this.classList.add('noImage')" />
<span class="tileLabel">${escapeHtml(item.name)}${item.year ? ` (${item.year})` : ''}</span>
</a>`;
}
/**
* Renders one item as a tile - the shape depends entirely on its Emby
* type. Dispatching here, rather than making every call site
* (home carousels, views/library, views/artist) pick a renderer itself,
* is what keeps them all visually/behaviorally identical instead of
* drifting - same reasoning this module's own header comment already
* gives for being shared in the first place.
*/
export function tileHtml(item: ItemDto): string {
if (item.type === 'MusicArtist') return artistTileHtml(item);
if (item.type === 'MusicAlbum') return albumTileHtml(item);
return posterTileHtml(item);
}
/**
* Wires up every "Lancer une watch/listen party" button inside `container`, now
* Wires up every "Lancer une watch party" button inside `container`, now
* and for any tile added to it later. `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 api/party.ts/router.ts itself.
@ -134,15 +83,14 @@ export function wireTileButtons(container: ParentNode, onStart: (itemId: string)
const btn = (e.target as HTMLElement).closest<HTMLButtonElement>('.startPartyBtn');
if (!btn || btn.disabled) return;
e.preventDefault();
const idleLabel = btn.textContent; // "watch party" or "listen party" - put back as-is on failure
btn.disabled = true;
btn.textContent = 'Démarrage...';
try {
await onStart(btn.dataset.itemId as string);
} catch (err) {
showToast((err as Error).message, { variant: 'error' });
alert((err as Error).message);
btn.disabled = false;
btn.textContent = idleLabel;
btn.textContent = 'Lancer une watch party';
}
});
}

View file

@ -1,46 +0,0 @@
/* ---- Global toast notifications - see toast.ts. Same light-pill,
auto-dismiss visual language as views/player/Player.css's own
.requestBanner, generalized to work from any page (position: fixed
against the viewport, not the player specifically) since this is
used well beyond it now (host handoff, every "couldn't start/join a
party" error that used to be a blocking alert()). ---- */
.toastContainer {
position: fixed;
top: max(16px, env(safe-area-inset-top, 0px));
left: 50%;
transform: translateX(-50%);
z-index: 100;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
max-width: calc(100% - 32px);
pointer-events: none;
}
.toast {
max-width: 100%;
background: var(--bg-header);
color: var(--text);
padding: 10px 18px;
border-radius: 20px;
font-size: 13px;
text-align: center;
box-shadow: 0 4px 16px rgba(0, 0, 0, .35);
opacity: 0;
transform: translateY(-8px);
transition: opacity .2s ease, transform .2s ease;
}
.toast.toastIn { opacity: 1; transform: translateY(0); }
.toast.toastOut { opacity: 0; }
/* Only variant beyond the default light/neutral pill above - a plain
left accent, not a loud red fill: still meant to be noticed without
the app suddenly shouting at you (see toast.ts's own comment on why
this stays this restrained even for errors). */
.toast--error { border-left: 3px solid var(--danger); }
@media (prefers-reduced-motion: reduce) {
.toast { transition: none; transform: none; }
}

View file

@ -1,66 +0,0 @@
import './toast.css';
/**
* Global, light-touch notifications - replaces the blocking `alert()`
* this app used to reach for everywhere, from "you're now the host"
* (a status update, not something to interrupt anyone for) down to
* "couldn't start that party" (still just a message, not worth
* freezing the whole page over). Self-mounting (appends its own
* container to `document.body` on first use) rather than something
* Shell.ts wires up like musicBar/MusicBar - there's no per-party
* state to attach here, just a place to drop text that outlives
* whichever view happened to trigger it, so a plain module-level
* singleton is all this needs.
*/
let container: HTMLElement | null = null;
function getContainer(): HTMLElement {
if (!container) {
container = document.createElement('div');
container.className = 'toastContainer';
document.body.appendChild(container);
}
return container;
}
export type ToastVariant = 'info' | 'error';
export interface ToastOptions {
/** 'error' adds a left accent - still the same restrained pill, not
* a red banner, since even a failed "start a party" click doesn't
* warrant this app suddenly shouting. Default 'info'. */
variant?: ToastVariant;
/** Milliseconds before it fades back out on its own - there's no
* manual dismiss button, same as Player.ts's own .requestBanner. */
duration?: number;
}
const DEFAULT_DURATION_MS = 4500;
export function showToast(message: string, options: ToastOptions = {}): void {
const { variant = 'info', duration = DEFAULT_DURATION_MS } = options;
const el = document.createElement('div');
el.className = `toast${variant === 'error' ? ' toast--error' : ''}`;
el.textContent = message;
getContainer().appendChild(el);
// Two rAFs, not one: the element needs a full layout/style pass in
// its *starting* state (opacity 0) before the class add is what
// triggers the transition - adding it in the very same frame the
// node was inserted can get coalesced with the insert itself, and
// the "fade in" never visibly happens.
requestAnimationFrame(() => requestAnimationFrame(() => el.classList.add('toastIn')));
setTimeout(() => {
el.classList.remove('toastIn');
el.classList.add('toastOut');
el.addEventListener('transitionend', () => el.remove(), { once: true });
// Belt-and-suspenders for prefers-reduced-motion (transition: none
// there - see toast.css) - 'transitionend' never fires with no
// transition running, which would otherwise leave this in the DOM
// forever instead of just skipping straight to removed.
setTimeout(() => el.remove(), 300);
}, duration);
}

View file

@ -9,10 +9,6 @@
export interface ItemDto {
id: string;
name: string;
/** Emby's own sort key (articles stripped) when the server requested
* it, the display name otherwise - see server/src/types.ts's own
* ItemDto.sortName for why alphaIndex.ts buckets by this, not name. */
sortName: string;
overview?: string;
year?: number;
type?: string;
@ -54,16 +50,6 @@ export interface PlaybackOptionsDto {
subtitleTracks: PlaybackTrackOption[];
}
/** Emby's own native intro/credits detection - see server/src/types.ts's
* own copy of this shape for where the field names come from. Every
* field is independently nullable ("not detected", never an error) -
* see views/player/Player.ts's updateSkipButton, the only consumer. */
export interface PlaybackMarkersDto {
introStartTicks: number | null;
introEndTicks: number | null;
creditsStartTicks: number | null;
}
export type MediaType = 'video' | 'music';
export interface PublicMember {
@ -171,42 +157,6 @@ export interface LobbyPartyDto {
positionTicks: number | null;
isPaused: boolean | null;
positionUpdatedUtc: number | null;
/** The static watch party this live session belongs to, if any. */
staticPartyId: string | null;
staticPartyName: string | null;
}
/** Mirrors server/src/types.ts's StaticPartySummaryDto - one static
* (persistent) watch party, as the home page lists it. */
export interface StaticPartySummaryDto {
id: string;
name: string;
sourceType: 'series' | 'season' | 'playlist';
imageUrl: string;
itemCount: number;
groupWatchedCount: number;
nextItemName: string | null;
memberCount: number;
youAreMember: boolean;
youAreCreator: boolean;
missedCount: number;
livePartyId: string | null;
}
/** One item of a static watch party - mirrors StaticPartyItemDto. */
export interface StaticPartyItemDto {
itemId: string;
itemName: string;
itemImageUrl: string;
groupState: 'watched' | 'next' | 'upcoming';
viewerWatched: boolean;
}
/** Mirrors server/src/types.ts's StaticPartyDetailDto. */
export interface StaticPartyDetailDto extends StaticPartySummaryDto {
items: StaticPartyItemDto[];
missedItemIds: string[];
members: Array<{ userId: string; userName: string; watchedCount: number; isCreator: boolean }>;
}
export interface ItemChangedMsg {

View file

@ -1,9 +1,3 @@
/* .albumHeader's own two children - .seriesInfo (Series.css) and
.playlistActions (Playlist.css) - are reused as-is, same precedent
Album.css already set for .seriesInfo itself: every route's CSS ends
up in the one bundle regardless of which view imports it, so there's
no need to duplicate a rule this generic (a flex row of actions under
a title) just because it was first written for a different page. */
.albumHeader {
display: flex;
gap: 20px;

View file

@ -2,13 +2,9 @@
<div class="albumHeader">
<img class="albumCover" id="albumCover" alt="" loading="lazy" onerror="this.classList.add('noImage')" />
<div class="seriesInfo">
<span class="heroKicker" id="albumKicker">Album</span>
<h1 id="albumTitle"></h1>
<p class="hint hide" id="albumArtist"></p>
<div class="heroActions">
<button id="launchBtn" class="heroPlayBtn"><span class="heroPlayIcon" id="launchBtnIcon"></span><span id="launchBtnLabel">Écouter cet album</span></button>
<a class="linklike" id="embyLink" target="_blank" rel="noopener">Voir sur Emby</a>
</div>
<a class="linklike" id="embyLink" target="_blank" rel="noopener">Voir sur Emby</a>
</div>
</div>
<input id="searchInput" class="searchBar" type="search" placeholder="Rechercher un morceau..." />

View file

@ -2,23 +2,15 @@ import './Album.css';
import '../../styles/trackList.css';
import html from './Album.html';
import { getAlbumTracks, getItem } from '../../api/embyApi';
import { addTrackToQueue, createParty, queueItem } from '../../api/party';
import { addTrackToQueue, createParty } from '../../api/party';
import { getActiveMusicPartyId, joinMusicParty } from '../../musicBar/MusicBar';
import { escapeHtml, formatDuration, wireSearchFilter } from '../../tiles';
import { LOADING_HINT } from '../../loading';
import { showToast } from '../../toast';
import type { ItemDto, MusicTrackInput } from '../../types';
import type { RouteParams } from '../../router';
const TICKS_PER_SECOND = 10000000; // Emby/.NET convention: 1 tick = 100ns, same as everywhere else this app touches durations/positions
// Same "small local constant" convention as musicBar/MusicBar.ts's own
// icon strings - PLAY_ICON_SVG is duplicated from views/artist/Artist.ts
// rather than shared, see that file's own comment on why.
const PLAY_ICON_SVG = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 4.5v15l14-7.5Z" fill="currentColor"></path></svg>';
const PLUS_ICON_SVG = '<svg viewBox="0 0 24 24" aria-hidden="true"><line x1="12" y1="5" x2="12" y2="19" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"></line><line x1="5" y1="12" x2="19" y2="12" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"></line></svg>';
const CHECK_ICON_SVG = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 12.5 9 17.5 20 6.5" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"></path></svg>';
/** Route `/album/:id` - the last level of the music hierarchy: one album's
* tracks, each startable as its own listen party (mirrors views/series's
* episode list, but flat - an album has no further sub-level the way a
@ -39,9 +31,9 @@ export async function renderAlbum(root: HTMLElement, params: RouteParams): Promi
root.innerHTML = html;
root.querySelector('#back')!.addEventListener('click', () => history.back());
(root.querySelector('#albumCover') as HTMLImageElement).src = album.imageUrl;
root.querySelector('#albumKicker')!.textContent = album.year ? `Album · ${album.year}` : 'Album';
root.querySelector('#albumTitle')!.textContent = album.name;
root.querySelector('#launchBtnIcon')!.innerHTML = PLAY_ICON_SVG;
root.querySelector('#albumTitle')!.innerHTML = album.year
? `${escapeHtml(album.name)} <span class="seriesYear">(${album.year})</span>`
: escapeHtml(album.name);
if (album.artistName) {
const artistEl = root.querySelector('#albumArtist')!;
artistEl.textContent = album.artistName;
@ -50,36 +42,27 @@ export async function renderAlbum(root: HTMLElement, params: RouteParams): Promi
(root.querySelector('#embyLink') as HTMLAnchorElement).href = album.embyUrl;
const listEl = root.querySelector('#trackList')!;
const launchBtn = root.querySelector('#launchBtn') as HTMLButtonElement;
const launchBtnLabel = root.querySelector('#launchBtnLabel')!;
if (!tracks.length) {
listEl.innerHTML = '<p class="hint">Aucun morceau dans cet album.</p>';
root.querySelector('#searchInput')!.remove();
launchBtn.disabled = true;
return;
}
// aria-label reflects whether a listen party is already active *at
// render time* - a harmless, occasionally-stale label (see
// refreshLabels() below for when it's corrected). The actual behavior
// on click always re-checks getActiveMusicPartyId() fresh, so clicking
// never does the wrong thing even if the label hasn't caught up yet
// (e.g. someone hit "Quitter" on the bar while this page was already
// open). Icon-only buttons (play vs plus, mirroring the same two
// states) - the label lives in aria-label/title for accessibility
// rather than as visible text, now that there's one per track instead
// of one per page (see docs/architecture.md's "Music UI rework").
const startLabel = 'Lancer une listen party';
// Label reflects whether a listen party is already active *at render
// time* - a harmless, occasionally-stale label (see refreshLabels()
// below for when it's corrected). The actual behavior on click always
// re-checks getActiveMusicPartyId() fresh, so clicking never does the
// wrong thing even if the label itself hasn't caught up yet (e.g.
// someone hit "Quitter" on the bar while this page was already open).
const startLabel = 'Écouter ensemble';
const addLabel = 'Ajouter à la file';
const startAlbumLabel = 'Écouter cet album';
const addAlbumLabel = "Ajouter l'album à la file";
listEl.innerHTML = tracks.map((t) => `
<div class="trackRow">
<span class="trackIndex">${t.indexNumber != null ? t.indexNumber : ''}</span>
<span class="trackName">${escapeHtml(t.name)}</span>
<span class="trackDuration">${t.durationTicks ? formatDuration(t.durationTicks / TICKS_PER_SECOND) : ''}</span>
<button class="startPartyBtn trackIconBtn" data-item-id="${t.id}" title="${getActiveMusicPartyId() ? addLabel : startLabel}" aria-label="${getActiveMusicPartyId() ? addLabel : startLabel}">${getActiveMusicPartyId() ? PLUS_ICON_SVG : PLAY_ICON_SVG}</button>
<button class="startPartyBtn" data-item-id="${t.id}">${getActiveMusicPartyId() ? addLabel : startLabel}</button>
</div>
`).join('');
@ -94,22 +77,14 @@ export async function renderAlbum(root: HTMLElement, params: RouteParams): Promi
};
}
// Re-labels every track button (and the whole-album one) from current
// state, not just the one just clicked - starting a party from track 3
// means tracks 1, 2 and 4 can now also be queued instead of starting a
// party of their own.
// Re-labels every track button from current state, not just the one
// just clicked - starting a party from track 3 means tracks 1, 2 and 4
// can now also be queued instead of starting a party of their own.
function refreshLabels(): void {
const active = getActiveMusicPartyId();
listEl.querySelectorAll<HTMLButtonElement>('.startPartyBtn').forEach((b) => {
if (b.disabled) return;
const label = active ? addLabel : startLabel;
b.innerHTML = active ? PLUS_ICON_SVG : PLAY_ICON_SVG;
b.title = label;
b.setAttribute('aria-label', label);
if (!b.disabled) b.textContent = getActiveMusicPartyId() ? addLabel : startLabel;
});
if (!launchBtn.disabled) launchBtnLabel.textContent = active ? addAlbumLabel : startAlbumLabel;
}
refreshLabels(); // sets launchBtn's own initial label - the per-track buttons already get theirs from the template above
listEl.querySelectorAll<HTMLButtonElement>('.startPartyBtn').forEach((btn, i) => {
const track = tracks[i] as ItemDto;
@ -118,8 +93,10 @@ export async function renderAlbum(root: HTMLElement, params: RouteParams): Promi
const activePartyId = getActiveMusicPartyId();
try {
if (activePartyId) {
btn.textContent = 'Ajout...';
await addTrackToQueue(activePartyId, trackInputFor(track));
} else {
btn.textContent = 'Démarrage...';
// Attaches the persistent bottom bar directly and stays right
// here - no navigate() - so adding a few more tracks from this
// same album is just clicking their buttons too. See
@ -128,42 +105,15 @@ export async function renderAlbum(root: HTMLElement, params: RouteParams): Promi
const party = await createParty(track.id);
joinMusicParty(party.partyId, party);
}
btn.innerHTML = CHECK_ICON_SVG;
btn.textContent = 'Ajouté !';
setTimeout(() => { btn.disabled = false; refreshLabels(); }, 1500);
} catch (err) {
showToast((err as Error).message, { variant: 'error' });
alert((err as Error).message);
btn.disabled = false;
refreshLabels();
}
});
});
// Whole-album equivalent of the per-track buttons above - asked for
// directly, alongside the same action for a whole artist (see
// Artist.ts). itemId is the album itself, not any one track - the
// server expands it into every track inside, in order (see index.ts's
// resolveMusicTracksForItem/createMusicPartyFromTracks), the same way
// launching a playlist already expands into everything it contains.
launchBtn.addEventListener('click', async () => {
launchBtn.disabled = true;
const activePartyId = getActiveMusicPartyId();
try {
if (activePartyId) {
launchBtnLabel.textContent = 'Ajout...';
await queueItem(activePartyId, albumId);
} else {
launchBtnLabel.textContent = 'Démarrage...';
const party = await createParty(albumId);
joinMusicParty(party.partyId, party);
}
launchBtnLabel.textContent = 'Ajouté !';
setTimeout(() => { launchBtn.disabled = false; refreshLabels(); }, 1500);
} catch (err) {
showToast((err as Error).message, { variant: 'error' });
launchBtn.disabled = false;
refreshLabels();
}
});
wireSearchFilter(root.querySelector('#searchInput') as HTMLInputElement, root, '.trackRow');
}

View file

@ -1,89 +0,0 @@
/* ---- Artist hero - see Artist.ts. A circular photo, never a poster:
the same reasoning tiles.ts's artistTileHtml already gives for the
artist grid applies just as much to the one photo at the top of the
artist's own page. ---- */
.artistHero {
display: flex;
align-items: flex-end;
gap: 20px;
margin-bottom: 24px;
}
.artistHeroPhoto {
width: 132px;
height: 132px;
border-radius: 50%;
object-fit: cover;
background: var(--panel);
flex-shrink: 0;
}
.artistHeroPhoto.noImage { visibility: hidden; }
.artistHeroInfo {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.artistHeroInfo h1 { margin: 0; font-size: 26px; line-height: 1.15; }
/* ---- Shared with views/album's own hero (Album.css reuses these three
classes directly rather than redefining them - same "one rule,
whichever file needs it first" precedent Album.css already set for
.seriesInfo/.playlistActions). ---- */
.heroKicker {
font-size: 12px;
color: var(--muted);
text-transform: uppercase;
letter-spacing: .05em;
font-weight: 600;
}
.heroActions {
display: flex;
align-items: center;
gap: 14px;
margin-top: 10px;
flex-wrap: wrap;
}
/* A pill, not a plain rectangular button - the one visual actually
borrowed from real music apps' big circular play button, while
keeping the explicit text label this app already standardized on
for every request/action control (see docs/architecture.md's
player-rework section) - nothing here is icon-only. */
.heroPlayBtn {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 18px 6px 6px;
border-radius: 99px;
background: var(--panel);
color: var(--text);
font-size: 14px;
font-weight: 600;
}
.heroPlayBtn:hover { background: #333; }
.heroPlayIcon {
width: 34px;
height: 34px;
border-radius: 50%;
background: var(--accent);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.heroPlayIcon svg { width: 14px; height: 14px; display: block; }
@media (max-width: 600px) {
.artistHero { flex-direction: column; align-items: center; text-align: center; }
.artistHeroPhoto { width: 104px; height: 104px; }
.artistHeroInfo { align-items: center; }
}

View file

@ -1,15 +1,5 @@
<button class="linklike" id="back">&larr; Retour</button>
<div class="artistHero">
<img class="artistHeroPhoto" id="artistPhoto" alt="" loading="lazy" onerror="this.classList.add('noImage')" />
<div class="artistHeroInfo">
<span class="heroKicker">Artiste</span>
<h1></h1>
<span class="hint" id="albumCount"></span>
<div class="heroActions">
<button id="launchBtn" class="heroPlayBtn"><span class="heroPlayIcon" id="launchBtnIcon"></span><span id="launchBtnLabel">Écouter toute la discographie</span></button>
</div>
</div>
</div>
<div class="pageHeader"><h1></h1></div>
<input id="searchInput" class="searchBar" type="search" placeholder="Rechercher un album..." />
<p class="hint searchEmpty hide">Aucun résultat.</p>
<div id="grid" class="grid"></div>

View file

@ -1,28 +1,16 @@
import './Artist.css';
import html from './Artist.html';
import { getArtistAlbums, getItem } from '../../api/embyApi';
import { createParty, queueItem } from '../../api/party';
import { getActiveMusicPartyId, joinMusicParty } from '../../musicBar/MusicBar';
import type { RouteParams } from '../../router';
import { tileHtml, wireSearchFilter } from '../../tiles';
import { LOADING_HINT } from '../../loading';
import { showToast } from '../../toast';
// Same icon, same "small local constant" convention as musicBar/MusicBar.ts's
// own PLAY_ICON_SVG - not shared from there since neither file otherwise
// depends on the other, and duplicating one SVG string is cheaper than a
// shared icons module for exactly two current call sites (here and
// views/album/Album.ts).
const PLAY_ICON_SVG = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 4.5v15l14-7.5Z" fill="currentColor"></path></svg>';
/** Route `/artist/:id` - one artist's albums, reached from a music
* library's top-level grid (see views/library) via a MusicArtist tile's
* "Voir les albums" link (tiles.ts's DRILL_DOWN table). Structurally the
* same "detail header + grid" shape as views/series, just one level up
* the music hierarchy (Artist -> Album, not Series -> Season/Episode).
* #launchBtn is the one action available at this level itself (every
* album's own tracks, in one go) - drilling into a specific album is
* still the only way to queue less than the whole discography. */
* the music hierarchy (Artist -> Album, not Series -> Season/Episode) and
* with no per-item action beyond drilling further into an album (an
* artist has nothing itself to start a party from). */
export async function renderArtist(root: HTMLElement, params: RouteParams): Promise<void> {
const artistId = params.id as string;
root.innerHTML = LOADING_HINT;
@ -38,58 +26,14 @@ export async function renderArtist(root: HTMLElement, params: RouteParams): Prom
root.innerHTML = html;
root.querySelector('h1')!.textContent = artist.name;
(root.querySelector('#artistPhoto') as HTMLImageElement).src = artist.imageUrl;
root.querySelector('#launchBtnIcon')!.innerHTML = PLAY_ICON_SVG;
root.querySelector('#back')!.addEventListener('click', () => history.back());
const launchBtn = root.querySelector('#launchBtn') as HTMLButtonElement;
const launchBtnLabel = root.querySelector('#launchBtnLabel')!;
const grid = root.querySelector('#grid')!;
root.querySelector('#albumCount')!.textContent = `${albums.length} album${albums.length > 1 ? 's' : ''}`;
if (!albums.length) {
grid.innerHTML = '<p class="hint">Aucun album trouvé pour cet artiste.</p>';
root.querySelector('#searchInput')!.remove();
launchBtn.disabled = true;
return;
}
grid.innerHTML = albums.map(tileHtml).join('');
wireSearchFilter(root.querySelector('#searchInput') as HTMLInputElement, root, '.tile');
// Label reflects whether a listen party is already active - same
// "harmless, occasionally-stale until the next refresh" reasoning as
// views/album's own equivalent buttons (the click handler below
// always re-checks getActiveMusicPartyId() fresh regardless).
const startLabel = 'Écouter toute la discographie';
const addLabel = 'Ajouter la discographie à la file';
function refreshLabel(): void {
if (!launchBtn.disabled) launchBtnLabel.textContent = getActiveMusicPartyId() ? addLabel : startLabel;
}
refreshLabel();
// itemId is the artist itself - the server expands it into every
// album's tracks, in order, the same mechanism a whole-album launch
// uses one level down (see views/album/Album.ts, and index.ts's
// resolveMusicTracksForItem/createMusicPartyFromTracks).
launchBtn.addEventListener('click', async () => {
launchBtn.disabled = true;
const activePartyId = getActiveMusicPartyId();
try {
if (activePartyId) {
launchBtnLabel.textContent = 'Ajout...';
await queueItem(activePartyId, artistId);
} else {
launchBtnLabel.textContent = 'Démarrage...';
// Same "attach the bar, stay put" pattern as views/album - see
// its own comment.
const party = await createParty(artistId);
joinMusicParty(party.partyId, party);
}
launchBtnLabel.textContent = 'Ajouté !';
setTimeout(() => { launchBtn.disabled = false; refreshLabel(); }, 1500);
} catch (err) {
showToast((err as Error).message, { variant: 'error' });
launchBtn.disabled = false;
refreshLabel();
}
});
}

View file

@ -60,18 +60,3 @@
color: var(--accent-bright);
font-weight: 600;
}
/* Which kind of party a card is - "Watch party" (video) or "Listen party"
(audio). Lounge parties (static) have their own tab, never this list. */
.partyKind {
align-self: flex-start;
font-size: 10px;
font-weight: 700;
letter-spacing: .06em;
text-transform: uppercase;
padding: 1px 6px;
border-radius: 4px;
}
.partyKind--video { color: var(--accent-bright); background: rgba(82, 181, 75, .15); }
.partyKind--music { color: #7fb4ff; background: rgba(127, 180, 255, .15); }

View file

@ -3,7 +3,7 @@
</div>
<section class="section" id="partiesSection" hidden>
<h2>Parties en cours</h2>
<h2>Watch parties en cours</h2>
<div id="partiesCarousel" class="carousel"></div>
</section>

View file

@ -6,7 +6,6 @@ import { getActiveMusicPartyId } from '../../musicBar/MusicBar';
import { navigate } from '../../router';
import { escapeHtml, tileHtml, wireTileButtons } from '../../tiles';
import { LOADING_HINT } from '../../loading';
import { showToast } from '../../toast';
import type { LobbyPartyDto } from '../../types';
export function renderHome(root: HTMLElement): () => void {
@ -18,9 +17,7 @@ export function renderHome(root: HTMLElement): () => void {
// label is the one piece that ticks purely client-side (from
// createdUtc) - no point re-pushing from the server once a second just
// for a clock.
// A static watch party's sessions live in their own tab
// (views/staticParties), not here - this list is classic parties only.
const lobby = connectLobby((parties) => renderParties(root, parties.filter((p) => !p.staticPartyId)));
const lobby = connectLobby((parties) => renderParties(root, parties));
const elapsedTimer = window.setInterval(() => updateElapsedLabels(root), 1000);
loadRecentSections(root);
@ -59,7 +56,6 @@ function renderParties(root: HTMLElement, parties: LobbyPartyDto[]): void {
<div class="partyCard">
<img class="${p.mediaType === 'music' ? 'partyCardArt--square' : ''}" src="${p.itemImageUrl || ''}" alt="" onerror="this.classList.add('noImage')" />
<div class="partyCardInfo">
<span class="partyKind partyKind--${p.mediaType}">${p.mediaType === 'music' ? 'Listen party' : 'Watch party'}</span>
<strong>${escapeHtml(p.itemName || '')}</strong>
<span class="hint">
${escapeHtml(p.hostUserName || '')} · ${p.memberCount} participant${p.memberCount > 1 ? 's' : ''}
@ -87,7 +83,7 @@ function renderParties(root: HTMLElement, parties: LobbyPartyDto[]): void {
await joinParty(btn.dataset.partyId as string);
navigate(`/party/${btn.dataset.partyId}`);
} catch (err) {
showToast((err as Error).message, { variant: 'error' });
alert((err as Error).message);
btn.disabled = false;
}
});

View file

@ -1,35 +0,0 @@
/* ---- Library page header (title + search bar) - see Library.ts/html. ----
Wraps .pageHeader and tiles.css's .searchBar (already sticky on its own)
in one sticky block so the title stays put too, instead of scrolling
away while the search bar stays behind - reported directly. Needs its
own opaque background since .pageHeader's <h1> has none - without it,
grid tiles scrolling underneath would show through the title text once
stuck. */
.libraryHeader {
position: sticky;
top: 0;
/* Above 2, not just tiles.css's own .searchBar precedent: a tile's
.tileOverlay action buttons are ALSO z-index: 2, and (unlike the
slim .searchBar this header replaced) this header is tall enough
that a scrolling tile's own overlay - always-visible on touch, see
tiles.css's (hover: none) block - reaches it well before it's fully
scrolled past. Equal z-index ties break on paint order, and a tile
(inside #grid, after this header in the DOM) wins that tie -
reported directly as buttons rendering over the header while
scrolling. z-index: 3 matches shell/Shell.css's own .mobileTopBar,
the other sticky header this one already has to stack correctly
under on mobile. */
z-index: 3;
background: var(--bg);
padding-top: 4px;
}
/* Below 720px, shell/Shell.css's own sticky .mobileTopBar already occupies
top:0 - stacking this header at top:0 too would have both fighting over
the same spot, with this one (later in paint order) covering the app's
brand bar. Stick it right below instead. */
@media (max-width: 720px) {
.libraryHeader {
top: var(--mobile-topbar-h);
}
}

View file

@ -1,6 +1,4 @@
<div class="libraryHeader">
<div class="pageHeader"><h1></h1></div>
<input id="searchInput" class="searchBar" type="search" placeholder="Rechercher dans cette bibliothèque..." />
</div>
<div class="pageHeader"><h1></h1></div>
<input id="searchInput" class="searchBar" type="search" placeholder="Rechercher dans cette bibliothèque..." />
<p class="hint searchEmpty hide">Aucun résultat.</p>
<div id="grid" class="grid"><p class="hint"><span class="spinner"></span>Chargement...</p></div>

View file

@ -1,11 +1,9 @@
import './Library.css';
import html from './Library.html';
import { getArtists, getLibraries, getLibraryItems } from '../../api/embyApi';
import { createParty } from '../../api/party';
import { navigate } from '../../router';
import type { CleanupFn, RouteParams } from '../../router';
import { renderGroupedTilesInChunks, wireAlphaRail, wireSectionSearchSync } from '../../alphaIndex';
import { wireSearchFilter, wireTileButtons } from '../../tiles';
import type { RouteParams } from '../../router';
import { renderTilesInChunks, wireSearchFilter, wireTileButtons } from '../../tiles';
import { LOADING_HINT } from '../../loading';
/**
@ -19,73 +17,48 @@ import { LOADING_HINT } from '../../loading';
* endpoint, not a generic library listing - see
* GET /api/catalog/libraries/:id/artists) - Album -> Tracks is one
* level further in (views/artist, then views/album).
*
* Synchronous wrapper around an async IIFE, not `async function`
* directly - router.ts's cleanup contract needs a CleanupFn back
* *synchronously*, which an async function returning a Promise can't
* do (see router.ts's own comment, and views/player/Player.ts's
* renderPartyPlayer for the same shape). `cancelled` guards every
* resume-after-await point so a still-in-flight fetch from a library
* the user already navigated away from can't clobber whatever route
* rendered after it.
*/
export function renderLibrary(root: HTMLElement, params: RouteParams): CleanupFn {
export async function renderLibrary(root: HTMLElement, params: RouteParams): Promise<void> {
root.innerHTML = LOADING_HINT;
let cancelled = false;
let cleanup: CleanupFn = () => {};
(async () => {
let libraries;
try {
libraries = await getLibraries();
} catch {
if (!cancelled) root.innerHTML = '<p class="error">Impossible de charger les bibliothèques.</p>';
return;
}
if (cancelled) return;
let libraries;
try {
libraries = await getLibraries();
} catch {
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;
}
const library = libraries.find((l) => l.id === params.id);
if (!library) {
root.innerHTML = '<p class="error">Bibliothèque introuvable.</p>';
return;
}
root.innerHTML = html;
root.querySelector('h1')!.textContent = library.name;
root.innerHTML = html;
root.querySelector('h1')!.textContent = library.name;
const grid = root.querySelector('#grid') as HTMLElement;
let items;
try {
items = library.collectionType === 'music'
? await getArtists(library.id)
: await getLibraryItems(library.id);
} catch {
if (!cancelled) {
grid.innerHTML = '<p class="error">Impossible de charger cette bibliothèque.</p>';
root.querySelector('#searchInput')!.remove();
}
return;
}
if (cancelled) return;
if (!items.length) {
grid.innerHTML = '<p class="hint">Bibliothèque vide.</p>';
root.querySelector('#searchInput')!.remove();
return;
}
const grid = root.querySelector('#grid') as HTMLElement;
let items;
try {
items = library.collectionType === 'music'
? await getArtists(library.id)
: await getLibraryItems(library.id);
} catch {
grid.innerHTML = '<p class="error">Impossible de charger cette bibliothèque.</p>';
root.querySelector('#searchInput')!.remove();
return;
}
if (!items.length) {
grid.innerHTML = '<p class="hint">Bibliothèque vide.</p>';
root.querySelector('#searchInput')!.remove();
return;
}
renderGroupedTilesInChunks(grid, items);
wireTileButtons(grid, async (itemId) => {
const party = await createParty(itemId);
navigate(`/party/${party.partyId}`);
});
const searchInput = root.querySelector('#searchInput') as HTMLInputElement;
wireSearchFilter(searchInput, root, '.tile');
wireSectionSearchSync(searchInput, grid);
cleanup = wireAlphaRail(root, grid, items);
})();
return () => {
cancelled = true;
cleanup();
};
renderTilesInChunks(grid, items);
wireTileButtons(grid, async (itemId) => {
const party = await createParty(itemId);
navigate(`/party/${party.partyId}`);
});
wireSearchFilter(root.querySelector('#searchInput') as HTMLInputElement, root, '.tile');
}

View file

@ -196,26 +196,6 @@
transition: padding .25s ease, background-color .25s ease, border-color .25s ease;
}
/* Floating above #bottomBar (higher z-index, positioned clear of its
tallest/expanded height) rather than living inside it - see
Player.html's own comment on why: it must stay reachable through the
bar's own auto-hide/.minimal collapse, not disappear along with it. */
.skipMarkerBtn {
position: absolute;
right: 10px;
bottom: 84px;
z-index: 7;
background: rgba(20, 18, 22, .85);
color: white;
border: 1px solid rgba(255, 255, 255, .25);
padding: 10px 18px;
border-radius: 6px;
font-size: 13px;
font-weight: 600;
}
.skipMarkerBtn:hover { background: rgba(20, 18, 22, .95); border-color: rgba(255, 255, 255, .4); }
/* Collapsed down to just the seek bar after a stretch of no activity
(see Player.ts's scheduleControlsHide) - asked for directly: the
full bar, always visible, sits right where a native <track> subtitle
@ -366,15 +346,6 @@
.fsBtn:hover { background: rgba(255, 255, 255, .18); }
/* #pipBtn while active - same "clearly-on" treatment as .iconBtn's own
[aria-pressed="true"] rule (#castBtn), just scoped to .fsBtn instead -
the two aren't combined onto one shared class since .iconBtn and
.fsBtn otherwise disagree on padding/border-radius/background. */
.fsBtn[aria-pressed="true"] {
color: #ff9800;
background: rgba(255, 152, 0, .18);
}
/* Replaces the video content while casting - the local <video> is
paused/torn down (see Player.ts's startCasting/cast.ts), so leaving
its last frame frozen with no explanation would just look stuck. */

View file

@ -63,17 +63,6 @@
notification rather than a fourth thing bolted onto a fixed layout. -->
<div id="requestBanner" class="requestBanner hide"></div>
<!-- Emby's own native intro detection (see Player.ts's updateSkipButton/
CatalogService.getPlaybackMarkers) - shown independently of #bottomBar
below, and NOT subject to its own auto-hide/.minimal collapse, since
this needs to stay reachable the moment the intro starts even if
nobody's touched the player yet. Host-only, like every other seek -
see updateSkipButton's own comment for why a follower can't get this
button at all. Explicit label, not just an icon - see
docs/architecture.md's player-rework section for why this app
standardized on that for every request-shaped control. -->
<button id="skipIntroBtn" class="skipMarkerBtn hide">Passer l'intro</button>
<!-- One bar, bottom-anchored, replacing what used to be three separate
floating pieces (a centered banner/notice column, a request
toolbar cornered bottom-right, an "Épisode suivant" link up in the
@ -127,17 +116,6 @@
<span id="ambientNotice" class="ambientNotice hide"></span>
</div>
<div class="bottomBarZone right">
<!-- Hidden by default, shown only once Player.ts confirms
document.pictureInPictureEnabled - not every browser supports
this (see its own comment), and there's no live
"availability changed" event to react to the way the cast
button has, since this never changes mid-session. -->
<button id="pipBtn" class="fsBtn hide" title="Picture-in-picture" aria-label="Picture-in-picture" aria-pressed="false">
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
<rect x="3" y="4" width="18" height="14" rx="2" fill="none" stroke="currentColor" stroke-width="2"></rect>
<rect x="12" y="11" width="7" height="5" rx="1" fill="currentColor"></rect>
</svg>
</button>
<button id="fullscreenBtn" class="fsBtn" title="Plein écran" aria-label="Plein écran"></button>
</div>
</div>

View file

@ -1,19 +1,17 @@
import '../../styles/presence.css';
import './Player.css';
import html from './Player.html';
import { auth, burnInStreamUrl, getPlaybackMarkers, getPlaybackOptions, partyStreamUrl, subtitleUrl } from '../../api/embyApi';
import { auth, burnInStreamUrl, getPlaybackOptions, partyStreamUrl, subtitleUrl } from '../../api/embyApi';
import { getParty, leaveParty, connectParty, issueCastToken, revokeCastToken } from '../../api/party';
import type { PartyConnection } from '../../api/party';
import { navigate } from '../../router';
import type { CleanupFn, RouteParams } from '../../router';
import { colorForName, escapeHtml, formatDuration, initialsFor } from '../../tiles';
import { LOADING_HINT } from '../../loading';
import { showToast } from '../../toast';
import type { BurnInChangedMsg, ItemChangedMsg, PartyPublicState, PauseRequestedMsg, PlaybackMarkersDto, PlaybackOptionsDto, ResumeRequestedMsg, SocketMember, StreamOptionsChangedMsg, TextSubtitleChangedMsg } from '../../types';
import type { BurnInChangedMsg, ItemChangedMsg, PartyPublicState, PauseRequestedMsg, PlaybackOptionsDto, ResumeRequestedMsg, SocketMember, StreamOptionsChangedMsg, TextSubtitleChangedMsg } from '../../types';
import { joinMusicParty, leaveMusicParty } from '../../musicBar/MusicBar';
import { initCast, localDriver, onCastAvailabilityChanged, onCastSessionEnded, startCast, stopCast } from '../../cast';
import type { PlaybackDriver } from '../../cast';
import { clearMediaSession, setMediaSessionActionHandlers, setMediaSessionMetadata, setMediaSessionNextAction, setMediaSessionPlaybackState, setMediaSessionPositionState } from '../../mediaSession';
const TICKS_PER_SECOND = 10000000; // Emby/.NET convention: 1 tick = 100ns
const DRIFT_TOLERANCE_SECONDS = 1.5;
@ -151,7 +149,7 @@ export function renderPartyPlayer(root: HTMLElement, params: RouteParams): Clean
// rendering bug in renderVideoPlayer itself, not just a genuine "party
// not found", so the stack trace matters here.
console.error('[watchparty player] failed to open party', err);
showToast(err.message, { variant: 'error' });
alert(err.message);
navigate('/home');
});
@ -198,7 +196,6 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
const qualitySelect = root.querySelector('#qualitySelect') as HTMLSelectElement;
const burnInHint = root.querySelector('#burnInHint')!;
const castBtn = root.querySelector('#castBtn') as HTMLButtonElement;
const pipBtn = root.querySelector('#pipBtn') as HTMLButtonElement;
const fullscreenBtn = root.querySelector('#fullscreenBtn') as HTMLButtonElement;
const bottomBar = root.querySelector('#bottomBar') as HTMLElement;
const progressBar = root.querySelector('#progressBar') as HTMLElement;
@ -206,7 +203,6 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
const playPauseBtn = root.querySelector('#playPauseBtn') as HTMLButtonElement;
const muteBtn = root.querySelector('#muteBtn') as HTMLButtonElement;
const timeLabel = root.querySelector('#timeLabel')!;
const skipIntroBtn = root.querySelector('#skipIntroBtn') as HTMLButtonElement;
qualitySelect.innerHTML = QUALITY_PRESETS.map((q) => `<option value="${q.value}">${q.label}</option>`).join('');
@ -229,14 +225,6 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
let isHost = false;
let hasNextQueued = false;
// Set at the top of cleanup() below, which every intentional way of
// leaving this route (the "Quitter" button, a sidebar link, browser
// back, ...) already runs synchronously before the socket actually
// closes - so by the time the async "disconnect" this connection's
// own onClose reacts to actually arrives, this is already true for
// every one of those. Still false there only means an unprompted
// close (most often: a device takeover elsewhere - see onClose below).
let leftIntentionally = false;
let conn: PartyConnection | null = null;
let hls: HlsInstance | null = null;
let currentItemId: string | null = null;
@ -274,15 +262,6 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
// - see its own comment) can re-render the subtitle picker's host-only
// gating for burn-in options without a redundant playback-options fetch.
let lastPlaybackOptions: PlaybackOptionsDto | null = null;
// Emby's own native intro/credits detection for currentItemId, if any -
// see loadMarkers/updateSkipButton below and CatalogService
// .getPlaybackMarkers. Null fields (or this whole thing being null,
// while still loading) just mean no skip button, never an error.
let currentMarkers: PlaybackMarkersDto | null = null;
// One-shot per item - see updateSkipButton's own comment for why this
// must NOT re-fire (force the bar open) on every tick for as long as
// credits are running.
let creditsControlsShown = false;
// "The player" sendLocalState/applyRemoteState actually read and write -
// the local <video> by default, swapped for a Chromecast's RemotePlayer
// while casting (see toggleCast/cast.ts). Casting is available to any
@ -423,7 +402,6 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
* onDriverStateChanged below. */
function updatePlayPauseBtn() {
playPauseBtn.innerHTML = activeDriver.paused ? PLAY_BTN_ICON_SVG : PAUSE_BTN_ICON_SVG;
setMediaSessionPlaybackState(!activeDriver.paused);
}
/** #muteBtn - deliberately reads/writes `video.muted` directly, never
@ -447,43 +425,6 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
const current = activeDriver.currentTime;
progressFill.style.width = duration > 0 ? `${Math.min(100, (current / duration) * 100)}%` : '0%';
timeLabel.textContent = duration > 0 ? `${formatDuration(current)} / ${formatDuration(duration)}` : '';
setMediaSessionPositionState(duration, current);
}
/**
* Shows/hides #skipIntroBtn from activeDriver's current position against
* currentMarkers - called continuously (progressTicker, below), same as
* updateProgressBar. Host-only: a follower's own seek never broadcasts
* (see sendLocalState's own gate), so a local jump here would just get
* overwritten by the next correction from the real host - same
* reasoning as progressBar being read-only for a follower.
*
* Credits get no button of their own: #nextBtn already does exactly
* "go to the next queued item" and is already shown whenever
* hasNextQueued is true (see onItemChanged) - detection's only real
* contribution there is making sure it's actually reachable the moment
* credits start, by un-minimizing the bar once, rather than requiring
* the host to have touched the player recently. The `creditsControlsShown`
* guard is what keeps this a one-shot nudge instead of fighting anyone
* who deliberately re-minimizes the bar during a long credits scroll.
*/
function updateSkipButton() {
if (!isHost || !currentMarkers) {
skipIntroBtn.classList.add('hide');
return;
}
const current = activeDriver.currentTime;
const { introStartTicks, introEndTicks, creditsStartTicks } = currentMarkers;
const inIntro = introStartTicks != null && introEndTicks != null
&& current >= introStartTicks / TICKS_PER_SECOND && current < introEndTicks / TICKS_PER_SECOND;
skipIntroBtn.classList.toggle('hide', !inIntro);
const inCredits = creditsStartTicks != null && current >= creditsStartTicks / TICKS_PER_SECOND;
if (inCredits && hasNextQueued && !creditsControlsShown) {
creditsControlsShown = true;
showControls();
}
}
/** Shared by every discrete play/pause/seek event this player reacts
@ -528,23 +469,13 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
let isDraggingProgress = false;
/** Host-only in effect (both call sites already gate on isHost before
* reaching here - the progress bar's own pointerdown handler below,
* and the Media Session seekto action) - this itself just clamps and
* applies, same shape as MusicBar.ts's own seekTo. */
function seekTo(seconds: number) {
const duration = activeDriver.duration;
if (!duration) return;
activeDriver.currentTime = Math.min(Math.max(0, seconds), duration);
onDriverStateChanged();
}
function seekFromPointerEvent(e: PointerEvent) {
const duration = activeDriver.duration;
if (!duration) return;
const rect = progressBar.getBoundingClientRect();
const fraction = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width));
seekTo(fraction * duration);
activeDriver.currentTime = fraction * duration;
onDriverStateChanged();
}
// Host-only (checked here, not just via the readOnly class - that one
@ -574,12 +505,6 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
updateMuteBtn();
});
skipIntroBtn.addEventListener('click', () => {
if (currentMarkers?.introEndTicks == null) return;
activeDriver.currentTime = currentMarkers.introEndTicks / TICKS_PER_SECOND;
onDriverStateChanged();
});
// Ticks the seek bar/time label between discrete events - currentTime
// moves continuously while playing, with no event of its own for
// "still playing, time passed" the way play/pause/seeked have. Runs
@ -590,7 +515,6 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
progressTicker = window.setInterval(() => {
if (!currentItemId) return;
updateProgressBar();
updateSkipButton();
}, 500);
updatePlayPauseBtn();
updateMuteBtn();
@ -683,7 +607,6 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
settingsWrap.classList.remove('hide');
requestToolbar.classList.add('hide'); // the host acts directly - no requests to make once promoted mid-session
nextBtn.classList.toggle('hide', !hasNextQueued);
setMediaSessionNextAction(hasNextQueued ? goToNextItem : null);
playPauseBtn.classList.remove('hide');
progressBar.classList.remove('readOnly');
updateAmbientNotice();
@ -691,10 +614,7 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
video.addEventListener('play', onDriverStateChanged);
video.addEventListener('pause', onDriverStateChanged);
video.addEventListener('seeked', onDriverStateChanged);
// The last item ending still counts as watched (Emby Played, a static
// watch party's progress) - "itemEnded" tells the server so, since
// there's no "next" to send for it.
video.addEventListener('ended', () => { if (hasNextQueued) conn?.next(); else conn?.itemEnded(); });
video.addEventListener('ended', () => { if (hasNextQueued) conn?.next(); });
if (!heartbeat) heartbeat = window.setInterval(reportHeartbeat, 3000);
// Burn-in (image) subtitle options are host-only (see
@ -705,7 +625,7 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
renderSubtitleOptions();
applyStreamOptionsGating();
if (notify) showToast("L'hôte précédent a quitté la party - tu es maintenant l'hôte.");
if (notify) alert("L'hôte précédent a quitté la party - tu es maintenant l'hôte.");
}
async function loadTrackOptions(itemId: string) {
@ -736,24 +656,6 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
}
}
/** Emby's own native intro/credits markers for `itemId`, if the library
* it's from has detection enabled and the task has already reached it
* - see CatalogService.getPlaybackMarkers. Reset first, synchronously,
* not just on a fetch failure: without this, the OLD item's markers
* (and skip button) would keep showing for however long this fetch
* takes on the new one. */
async function loadMarkers(itemId: string) {
currentMarkers = null;
skipIntroBtn.classList.add('hide');
creditsControlsShown = false;
try {
currentMarkers = await getPlaybackMarkers(itemId);
} catch (err) {
console.error('[watchparty player] playback markers failed', err);
// Non-fatal, same as loadTrackOptions above - playback works fine with no skip button.
}
}
/**
* (Re)builds the subtitle <select> from the last-fetched playback
* options, called both on a genuine item load and whenever burn-in/
@ -1023,50 +925,10 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
});
updateFullscreenBtn();
/**
* Picture-in-picture - asked for directly, mobile in particular: the
* one way to keep watching in a small floating window while switching
* to another app, since a phone has no second window/screen to put
* this app in the way a desktop might. Feature-detected once at mount,
* not live like #castBtn's own onCastAvailabilityChanged - unlike a
* Chromecast appearing/disappearing on the network mid-session,
* browser support for this is fixed for the lifetime of the page, so
* there's nothing to react to after this first check. video itself
* needs no new attribute for this to work - unlike disableremoteplayback
* (added specifically to suppress Chrome's own auto cast icon), there's
* no equivalent auto-injected native PiP control this app needs to
* fight with.
*/
if (document.pictureInPictureEnabled && !video.disablePictureInPicture) {
pipBtn.classList.remove('hide');
pipBtn.addEventListener('click', () => {
if (document.pictureInPictureElement) {
document.exitPictureInPicture().catch(() => {});
} else {
video.requestPictureInPicture().catch((err) => {
// Can genuinely fail even when supported (no user gesture in
// some browsers' stricter reading of that, video not yet
// ready) - same "try it, log it, don't crash anything else"
// instinct as every other best-effort browser API this app
// calls (video.play().catch, requestFullscreen().catch, ...).
console.error('[watchparty player] picture-in-picture failed', err);
});
}
});
// Reflects state via the same [aria-pressed] treatment #castBtn
// uses (see Player.css's .fsBtn[aria-pressed="true"]) - covers the
// browser's own PiP window being closed directly (its native close
// button, or the OS), not just this button's own click.
video.addEventListener('enterpictureinpicture', () => pipBtn.setAttribute('aria-pressed', 'true'));
video.addEventListener('leavepictureinpicture', () => pipBtn.setAttribute('aria-pressed', 'false'));
}
async function onItemChanged(msg: ItemChangedMsg) {
itemTitleEl.textContent = msg.itemName || '';
setMediaSessionMetadata(msg.itemName || '', '', '', `/api/catalog/image/${msg.itemId}`);
hasNextQueued = !!msg.hasNext;
nextBtn.classList.toggle('hide', !(isHost && hasNextQueued));
setMediaSessionNextAction(isHost && hasNextQueued ? goToNextItem : null);
// A new item's own stream indices/subtitle pick have nothing to do
// with the old one's - PartyService.advanceQueue already resets this
// server-side too (see its own comment); this just keeps this client
@ -1078,7 +940,6 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
burnInAudioIndex = null;
textSubtitleIndex = null;
streamAudioIndex = null;
loadMarkers(msg.itemId); // not awaited - never needed before playback can start, see loadVideo below
await loadTrackOptions(msg.itemId);
loadVideo(msg.itemId, 0, true);
}
@ -1214,42 +1075,22 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
// cooldown (mirroring the server's own independent per-request
// throttle in SocketGateway) purely so a rapid double-click doesn't
// even bother emitting the second request.
function requestPause() {
requestPauseBtn.addEventListener('click', () => {
const now = Date.now();
if (now < pauseRequestCooldownUntil || !conn) return;
pauseRequestCooldownUntil = now + REQUEST_COOLDOWN_MS;
conn.requestPause();
}
});
function requestResume() {
requestResumeBtn.addEventListener('click', () => {
const now = Date.now();
if (now < resumeRequestCooldownUntil || !conn) return;
resumeRequestCooldownUntil = now + REQUEST_COOLDOWN_MS;
conn.requestResume();
}
requestPauseBtn.addEventListener('click', requestPause);
requestResumeBtn.addEventListener('click', requestResume);
function goToNextItem() {
conn?.next();
}
});
async function init() {
itemTitleEl.textContent = party.itemName || '';
setMediaSessionMetadata(party.itemName || '', '', '', `/api/catalog/image/${party.itemId}`);
// Play/pause/seek are wired exactly once - see setMediaSessionActionHandlers'
// own comment for why they never need re-wiring on a host handoff.
// "next" isn't included here: unlike play/pause (always meaningful,
// just routed differently for a follower - see requestPause/Resume
// above), a *registered* nexttrack handler is what makes Windows show
// the control at all, so it has to track hasNextQueued/isHost the
// same way #nextBtn's own visibility does - see becomeHost/onItemChanged.
setMediaSessionActionHandlers({
onPlay: () => { if (isHost) activeDriver.play(); else requestResume(); },
onPause: () => { if (isHost) activeDriver.pause(); else requestPause(); },
onSeek: (seconds) => { if (isHost) seekTo(seconds); }
});
hasNextQueued = !!party.hasNext;
updateAmbientNotice();
requestToolbar.classList.toggle('hide', party.youAreHost);
@ -1261,22 +1102,8 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
// image-subtitle option as disabled, as if they were a follower.
if (party.youAreHost) becomeHost(false);
loadMarkers(party.itemId); // not awaited - see onItemChanged's own comment
await loadTrackOptions(party.itemId);
// Unlike onItemChanged's own loadVideo(..., 0, ...) - genuinely correct
// there since a fresh item has no position yet - mounting mid-party
// (a joining guest, or the host's own page reload) must NOT hardcode 0:
// the party's shared Emby transcode (PartyStreamService) is keyed by
// partyId alone, one PlaySessionId for every member, so a hls.js
// instance requesting segments from 0 against a session that already
// progressed well past that forces Emby to seek/restart the shared
// transcode - stalling it for every other member, host included, right
// as the newcomer joins. Seeding from the host's last-reported position
// (already in party.members, sent to every mount) lands the first
// segment request near where the shared session already is instead.
const hostMember = party.members.find((m) => m.isHost);
const initialResumeSeconds = hostMember ? hostMember.positionTicks / TICKS_PER_SECOND : 0;
loadVideo(party.itemId, initialResumeSeconds, false);
loadVideo(party.itemId, 0, false);
conn = connectParty(partyId, {
onMembers: renderMembers,
@ -1300,28 +1127,12 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
if (takeOver) conn?.confirmDeviceTakeover();
else navigate('/home');
},
onError: (message) => showToast(message, { variant: 'error' }),
// Fires for every disconnect, intentional or not - leftIntentionally
// is already true by the time this runs for any of those (see its
// own comment), so still false here means an unprompted close, most
// often a device takeover elsewhere (SocketGateway's "join" handler)
// - without this, the video/hls.js instance just kept running
// forever on the old device, on a conn that was actually already
// dead underneath it, reported directly as "both devices end up on
// the session, controls never transfer." navigate() runs this same
// route's cleanup() below, which is now safe to do even though the
// party already moved on - leaveParty's own deviceId check (see
// api/party.ts) makes its REST call a no-op for a stale device
// instead of ending the CURRENT device's membership by mistake.
onClose: () => {
if (leftIntentionally) return;
showToast('Connexion à la party interrompue.', { variant: 'error' });
navigate('/home');
}
onError: (message) => alert(message),
onClose: () => {}
});
}
nextBtn.addEventListener('click', goToNextItem);
nextBtn.addEventListener('click', () => { conn?.next(); });
(root.querySelector('#leaveBtn') as HTMLButtonElement).addEventListener('click', () => navigate('/home'));
init();
@ -1331,13 +1142,11 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
// other way (sidebar link, browser back, ...). This is the fix for the
// actual leak: previously, only the "Quitter" button ran any of this.
return function cleanup() {
leftIntentionally = true;
if (heartbeat) clearInterval(heartbeat);
if (progressTicker) clearInterval(progressTicker);
if (requestBannerTimer) clearTimeout(requestBannerTimer);
clearControlsHideTimer();
if (hls) hls.destroy();
clearMediaSession();
document.removeEventListener('click', onDocumentClick);
document.removeEventListener('fullscreenchange', onFullscreenChange);
playerWrap.removeEventListener('pointermove', showControls);

View file

@ -25,9 +25,8 @@
.playlistActions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px 16px;
gap: 16px;
margin-top: 4px;
}

View file

@ -6,7 +6,6 @@
<p class="hint" id="playlistMeta"></p>
<div class="playlistActions">
<button id="launchBtn">Lancer cette playlist</button>
<button class="hide" id="createStaticBtn">Créer une lounge party</button>
<a class="linklike" id="embyLink" target="_blank" rel="noopener">Voir sur Emby</a>
</div>
</div>

View file

@ -2,13 +2,11 @@ import './Playlist.css';
import html from './Playlist.html';
import { getItem, getPlaylistItems } from '../../api/embyApi';
import { createParty } from '../../api/party';
import { wireCreateStaticButton } from '../staticParty/createStaticButton';
import { joinMusicParty } from '../../musicBar/MusicBar';
import { navigate } from '../../router';
import type { RouteParams } from '../../router';
import { escapeHtml, formatDuration } from '../../tiles';
import { LOADING_HINT } from '../../loading';
import { showToast } from '../../toast';
import type { ItemDto } from '../../types';
const TICKS_PER_SECOND = 10000000; // Emby/.NET convention: 1 tick = 100ns, same as everywhere else this app touches durations
@ -57,15 +55,6 @@ export async function renderPlaylist(root: HTMLElement, params: RouteParams): Pr
return;
}
// Video playlists only - a static watch party has no music equivalent
// (the server refuses one too). Same "first item decides" rule as
// CatalogService.splitPlaylistByMediaType.
if (items[0]!.type !== 'Audio') {
const createStaticBtn = root.querySelector('#createStaticBtn') as HTMLButtonElement;
createStaticBtn.classList.remove('hide');
wireCreateStaticButton(createStaticBtn, () => playlistId);
}
listEl.innerHTML = items.map((item, i) => {
const meta = item.overview || [item.artistName, item.albumName].filter(Boolean).join(' · ');
const duration = item.durationTicks ? formatDuration(item.durationTicks / TICKS_PER_SECOND) : '';
@ -104,7 +93,7 @@ export async function renderPlaylist(root: HTMLElement, params: RouteParams): Pr
navigate(`/party/${party.partyId}`);
}
} catch (err) {
showToast((err as Error).message, { variant: 'error' });
alert((err as Error).message);
btn.disabled = false;
btn.textContent = idleLabel;
}

View file

@ -17,20 +17,6 @@
.seriesPoster.noImage { visibility: hidden; }
.seriesActions,
.seasonActions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px 16px;
margin-top: 8px;
}
/* The selected season's own launch buttons, between the tabs and its
episodes. */
.seasonActions { margin: 0 0 16px; }
.seasonActions button { padding: 8px 14px; font-size: 13px; }
@media (max-width: 600px) {
.seriesHeader { flex-direction: column; }
.seriesPoster { width: 120px; }

View file

@ -4,16 +4,8 @@
<div class="seriesInfo">
<h1 id="seriesTitle"></h1>
<p class="seriesOverview hide" id="seriesOverview"></p>
<div class="seriesActions">
<button id="launchSeriesBtn">Lancer toute la série</button>
<button class="secondaryBtn" id="createStaticBtn">Créer une lounge party</button>
<a class="linklike" id="embyLink" target="_blank" rel="noopener">Voir sur Emby</a>
</div>
<a class="linklike" id="embyLink" target="_blank" rel="noopener">Voir sur Emby</a>
</div>
</div>
<nav id="seasonTabs" class="tabs"></nav>
<div class="seasonActions hide" id="seasonActions">
<button id="launchSeasonBtn">Lancer cette saison</button>
<button class="secondaryBtn" id="createSeasonStaticBtn">Lounge party de cette saison</button>
</div>
<div id="episodeList" class="episodeList"><p class="hint">Chargement...</p></div>

View file

@ -2,12 +2,10 @@ import './Series.css';
import html from './Series.html';
import { getItem, getSeriesEpisodes, getSeriesSeasons } from '../../api/embyApi';
import { createParty } from '../../api/party';
import { wireCreateStaticButton } from '../staticParty/createStaticButton';
import { navigate } from '../../router';
import type { RouteParams } from '../../router';
import { escapeHtml } from '../../tiles';
import { LOADING_HINT } from '../../loading';
import { showToast } from '../../toast';
import type { ItemDto, SeasonDto } from '../../types';
export async function renderSeries(root: HTMLElement, params: RouteParams): Promise<void> {
@ -36,25 +34,11 @@ export async function renderSeries(root: HTMLElement, params: RouteParams): Prom
}
(root.querySelector('#embyLink') as HTMLAnchorElement).href = series.embyUrl;
// Whole series: a watch party over every season from S1E1, or a lounge
// party over the same (see the server's seriesQueueFrom).
wireLaunchButton(root.querySelector('#launchSeriesBtn') as HTMLButtonElement, () => seriesId);
wireCreateStaticButton(root.querySelector('#createStaticBtn') as HTMLButtonElement, () => seriesId);
if (!seasons.length) {
(root.querySelector('#launchSeriesBtn') as HTMLButtonElement).disabled = true;
(root.querySelector('#createStaticBtn') as HTMLButtonElement).disabled = true;
root.querySelector('#episodeList')!.innerHTML = '<p class="hint">Aucune saison trouvée.</p>';
return;
}
// Just the selected season - both buttons read whichever tab is active
// at click time, so they're wired once rather than per tab switch.
let selectedSeasonId = seasons[0]!.id;
root.querySelector('#seasonActions')!.classList.remove('hide');
wireLaunchButton(root.querySelector('#launchSeasonBtn') as HTMLButtonElement, () => selectedSeasonId);
wireCreateStaticButton(root.querySelector('#createSeasonStaticBtn') as HTMLButtonElement, () => selectedSeasonId);
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>`)
@ -65,35 +49,13 @@ export async function renderSeries(root: HTMLElement, params: RouteParams): Prom
if (tab.classList.contains('active')) return;
tabsEl.querySelectorAll('.tab').forEach((t) => t.classList.remove('active'));
tab.classList.add('active');
selectedSeasonId = tab.dataset.seasonId as string;
loadEpisodes(root, seriesId, selectedSeasonId);
loadEpisodes(root, seriesId, tab.dataset.seasonId as string);
});
});
loadEpisodes(root, seriesId, seasons[0]!.id);
}
/**
* A button that launches a watch party over whatever `itemId()` returns
* at click time - the series (every season) or the selected season (see
* the server's POST /api/parties Series/Season branches) - then opens it.
*/
function wireLaunchButton(btn: HTMLButtonElement, itemId: () => string): void {
const idleLabel = btn.textContent || '';
btn.addEventListener('click', () => {
btn.disabled = true;
btn.textContent = 'Démarrage...';
createParty(itemId())
.then((party) => navigate(`/party/${party.partyId}`))
.catch((err: unknown) => {
console.error('[series] launch failed', err);
showToast((err as Error).message, { variant: 'error' });
btn.disabled = false;
btn.textContent = idleLabel;
});
});
}
async function loadEpisodes(root: HTMLElement, seriesId: string, seasonId: string): Promise<void> {
const listEl = root.querySelector('#episodeList')!;
listEl.innerHTML = LOADING_HINT;
@ -133,7 +95,7 @@ async function loadEpisodes(root: HTMLElement, seriesId: string, seasonId: strin
const party = await createParty(btn.dataset.itemId as string);
navigate(`/party/${party.partyId}`);
} catch (err) {
showToast((err as Error).message, { variant: 'error' });
alert((err as Error).message);
btn.disabled = false;
btn.textContent = 'Lancer une watch party';
}

View file

@ -1,81 +0,0 @@
/* ---- "Lounge parties" tab: one card per static party ---- */
.staticGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 14px;
}
/* The whole card is the link to /static/:id. */
.staticCard {
display: flex;
align-items: center;
gap: 14px;
background: var(--bg-header);
border-radius: 10px;
padding: 12px;
color: inherit;
text-decoration: none;
}
.staticCard:hover { background: #2b252e; }
.staticCard img {
width: 60px;
height: 90px;
object-fit: cover;
border-radius: 6px;
background: var(--panel);
flex-shrink: 0;
}
.staticCard img.noImage { visibility: hidden; }
.staticCardInfo {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.staticCardInfo strong { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.staticCardInfo .hint { margin: 0; }
.staticCardNext {
font-size: 12px;
color: var(--muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.staticCardBar {
height: 4px;
border-radius: 2px;
background: var(--panel);
overflow: hidden;
}
.staticCardBar > div { height: 100%; background: var(--accent); }
.staticCardLive {
color: var(--accent-bright);
font-size: 12px;
font-weight: 600;
}
.staticCardMissed {
flex-shrink: 0;
align-self: flex-start;
padding: 4px 10px;
border-radius: 12px;
background: var(--danger);
color: #fff;
font-size: 12px;
font-weight: 600;
}
@media (max-width: 400px) {
.staticGrid { grid-template-columns: 1fr; }
}

View file

@ -1,9 +0,0 @@
<div class="pageHeader">
<h1>Lounge parties</h1>
</div>
<p class="hint">
Une série ou une playlist regardée en groupe sur plusieurs séances : la progression du groupe
et celle de chacun sont gardées en mémoire. Pour en créer une, ouvre une série ou une playlist
vidéo et choisis « Créer une lounge party ».
</p>
<div class="staticGrid" id="staticGrid"></div>

View file

@ -1,77 +0,0 @@
import './StaticParties.css';
import html from './StaticParties.html';
import { connectLobby } from '../../api/party';
import { listStaticParties } from '../../api/staticParty';
import { escapeHtml } from '../../tiles';
import { SOURCE_LABELS } from '../staticParty/StaticParty';
import { LOADING_HINT } from '../../loading';
import type { StaticPartySummaryDto } from '../../types';
/**
* Route `/static` - the "Lounge parties" tab: every static
* watch party, kept apart from the home page's classic live parties
* (whose lobby list leaves static sessions out - see Home.ts).
*
* A REST list, re-fetched whenever the set of live static-party sessions
* in the lobby changes (a session starting or ending is what moves a
* card's "séance en cours"/progress state). Not on every lobby snapshot:
* those also fire on every pause/join, and each list fetch reads Emby
* once per static party (see server/src/index.ts's syncStaticSource).
*/
export function renderStaticParties(root: HTMLElement): () => void {
root.innerHTML = html;
const grid = root.querySelector('#staticGrid') as HTMLElement;
grid.innerHTML = LOADING_HINT;
let sessionsKey: string | null = null;
const lobby = connectLobby((parties) => {
const key = parties.map((p) => p.staticPartyId).filter(Boolean).sort().join(',');
if (key === sessionsKey) return;
sessionsKey = key;
load(grid);
});
// Same leak-prevention contract as Home.ts: the router calls this before
// rendering the next view.
return function cleanup() {
lobby.disconnect();
};
}
/** Fetches and renders every static party into `grid`. */
function load(grid: HTMLElement): void {
listStaticParties()
.then((parties) => {
if (!grid.isConnected) return; // navigated away mid-flight
render(grid, parties);
})
.catch((err: unknown) => {
console.error('[staticParties] list failed to load', err);
if (grid.isConnected) grid.innerHTML = `<p class="error">${escapeHtml((err as Error).message)}</p>`;
});
}
/** One card per static party: progress, next item, the viewer's missed
* count, and whether a session is running right now. */
function render(grid: HTMLElement, parties: StaticPartySummaryDto[]): void {
if (!parties.length) {
grid.innerHTML = '<p class="hint">Aucune lounge party pour le moment.</p>';
return;
}
grid.innerHTML = parties.map((p) => {
const percent = p.itemCount ? Math.round((p.groupWatchedCount / p.itemCount) * 100) : 0;
return `
<a class="staticCard" href="#/static/${escapeHtml(p.id)}">
<img src="${escapeHtml(p.imageUrl)}" alt="" loading="lazy" onerror="this.classList.add('noImage')" />
<div class="staticCardInfo">
<strong>${escapeHtml(p.name)}</strong>
<span class="hint">${SOURCE_LABELS[p.sourceType]} · ${p.groupWatchedCount}/${p.itemCount} vus · ${p.memberCount} membre${p.memberCount > 1 ? 's' : ''}</span>
<div class="staticCardBar"><div style="width:${percent}%"></div></div>
${p.livePartyId
? '<span class="staticCardLive">● Séance en cours</span>'
: `<span class="staticCardNext">${p.nextItemName ? `Prochain : ${escapeHtml(p.nextItemName)}` : 'Le groupe a tout vu.'}</span>`}
</div>
${p.missedCount ? `<span class="staticCardMissed" title="Éléments manqués">${p.missedCount} manqué${p.missedCount > 1 ? 's' : ''}</span>` : ''}
</a>`;
}).join('');
}

View file

@ -1,138 +0,0 @@
/* ---- Static (persistent) watch party page ----
.seriesInfo/.episodeList/.episodeRow/.episodeInfo are reused as-is from
views/series/Series.css (always in the bundle - same precedent as
Playlist.css/Album.css). Only what's specific to this page lives here. */
.staticHeader {
display: flex;
gap: 20px;
margin-bottom: 28px;
}
.staticCover {
width: 160px;
aspect-ratio: 2 / 3;
object-fit: cover;
border-radius: 8px;
background: var(--panel);
flex-shrink: 0;
}
.staticCover.noImage { visibility: hidden; }
.staticBadge {
display: inline-block;
font-size: 11px;
font-weight: 700;
letter-spacing: .06em;
text-transform: uppercase;
color: var(--accent-bright);
margin-bottom: 6px;
}
.staticMeta { margin: 6px 0 10px; }
.staticProgress {
height: 6px;
max-width: 360px;
border-radius: 3px;
background: var(--panel);
overflow: hidden;
}
.staticProgressFill {
height: 100%;
width: 0;
background: var(--accent);
transition: width .3s;
}
.staticNext { margin: 10px 0 14px; font-size: 14px; }
.staticActions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px 16px;
}
/* A quieter variant of the global green button - for actions next to a
primary one (joining, catching up) that shouldn't compete with it. */
.secondaryBtn {
background: var(--panel);
color: var(--text);
}
.secondaryBtn:hover { background: #333; }
.staticSectionHead {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 8px;
}
.staticSectionHead h2 { margin: 0; }
.staticCount {
display: inline-block;
min-width: 22px;
padding: 1px 7px;
margin-left: 6px;
border-radius: 11px;
background: var(--danger);
color: #fff;
font-size: 12px;
text-align: center;
}
.staticBulkActions { display: flex; flex-wrap: wrap; gap: 8px; }
.staticBulkActions button,
.staticRowActions button { padding: 8px 12px; font-size: 13px; }
.staticMissed .episodeRow { border-left: 3px solid var(--danger); }
.staticCheck {
width: 18px;
height: 18px;
margin: 0;
flex-shrink: 0;
accent-color: var(--accent);
}
.staticRowActions { display: flex; flex-wrap: wrap; gap: 8px; flex-shrink: 0; }
/* Group-progress list: what the group has seen fades back, the next item
stands out. */
.episodeRow.isWatched { opacity: .55; }
.episodeRow.isNext { outline: 2px solid var(--accent); }
.staticState {
flex-shrink: 0;
font-size: 12px;
font-weight: 600;
color: var(--muted);
text-align: right;
}
.staticState .mine { display: block; color: var(--accent-bright); font-weight: 400; }
.episodeRow.isNext .staticState { color: var(--accent-bright); }
.staticMembers { display: flex; flex-wrap: wrap; gap: 8px; }
.staticMember {
background: var(--bg-header);
border-radius: 16px;
padding: 6px 12px;
font-size: 13px;
}
.staticMember .hint { margin: 0 0 0 6px; font-size: 12px; }
@media (max-width: 600px) {
.staticHeader { flex-direction: column; }
.staticCover { width: 120px; }
.staticRowActions { flex-basis: 100%; order: 4; }
}

View file

@ -1,39 +0,0 @@
<button class="linklike" id="back">&larr; Retour</button>
<div class="staticHeader">
<img class="staticCover" id="staticCover" alt="" loading="lazy" onerror="this.classList.add('noImage')" />
<div class="seriesInfo">
<span class="staticBadge">Lounge party</span>
<h1 id="staticTitle"></h1>
<p class="hint staticMeta" id="staticMeta"></p>
<div class="staticProgress" aria-hidden="true"><div class="staticProgressFill" id="staticProgressFill"></div></div>
<p class="staticNext" id="staticNext"></p>
<div class="staticActions">
<button id="sessionBtn">Lancer la séance</button>
<button class="secondaryBtn hide" id="joinBtn">Rejoindre cette lounge party</button>
<button class="linklike hide" id="leaveBtn">Quitter</button>
<button class="linklike hide" id="deleteBtn">Supprimer</button>
</div>
</div>
</div>
<section class="section staticMissed" id="missedSection" hidden>
<div class="staticSectionHead">
<h2>Tu as manqué <span class="staticCount" id="missedCount"></span></h2>
<div class="staticBulkActions">
<button class="secondaryBtn" id="catchUpSoloBtn">Rattraper en solo</button>
<button class="secondaryBtn" id="catchUpPartyBtn">Lancer une watch party de rattrapage</button>
</div>
</div>
<p class="hint">Décoche ce que tu ne veux pas rattraper tout de suite.</p>
<div class="episodeList" id="missedList"></div>
</section>
<section class="section">
<h2>Membres</h2>
<div class="staticMembers" id="memberList"></div>
</section>
<section class="section">
<h2>Progression du groupe</h2>
<div class="episodeList" id="itemList"></div>
</section>

View file

@ -1,227 +0,0 @@
import './StaticParty.css';
import html from './StaticParty.html';
import {
deleteStaticParty,
getStaticParty,
joinStaticParty,
leaveStaticParty,
markStaticItemWatched,
startCatchUp,
startStaticSession
} from '../../api/staticParty';
import { navigate } from '../../router';
import type { RouteParams } from '../../router';
import { escapeHtml } from '../../tiles';
import { LOADING_HINT } from '../../loading';
import { showToast } from '../../toast';
import type { StaticPartyDetailDto, StaticPartyItemDto } from '../../types';
/** How each lounge party source is named in the UI - shared with the
* "Lounge parties" tab (views/staticParties). */
export const SOURCE_LABELS: Record<StaticPartyDetailDto['sourceType'], string> = {
series: 'Série',
season: 'Saison',
playlist: 'Playlist'
};
/**
* Route `/static/:id` - one static (persistent) watch party: the group's
* progress through the whole series/playlist, its members, and - the
* point of the feature - what *you* missed, with ways to catch up (alone,
* or as a watch party others can join) or mark things as already seen.
* Launching the group's session hands off to the normal player
* (`/party/:id`) - a session is an ordinary party server-side. See
* server/src/services/StaticPartyService.ts and spec/static-watch-party.md.
*/
export async function renderStaticParty(root: HTMLElement, params: RouteParams): Promise<void> {
const staticId = params.id as string;
root.innerHTML = LOADING_HINT;
let detail: StaticPartyDetailDto;
try {
detail = await getStaticParty(staticId);
} catch (err) {
console.error('[staticParty] load failed', staticId, err);
root.innerHTML = `<p class="error">${escapeHtml((err as Error).message || 'Impossible de charger cette lounge party.')}</p>`;
return;
}
root.innerHTML = html;
root.querySelector('#back')!.addEventListener('click', () => history.back());
wireActions(root, staticId);
render(root, detail);
}
/** Re-fetches and re-renders in place - after any action that changed
* progress or membership. */
async function reload(root: HTMLElement, staticId: string): Promise<void> {
try {
const detail = await getStaticParty(staticId);
if (!root.querySelector('#staticTitle')) return; // navigated away mid-flight
render(root, detail);
} catch (err) {
console.error('[staticParty] reload failed', staticId, err);
showToast((err as Error).message, { variant: 'error' });
}
}
/** Fills every part of the page from `detail`. */
function render(root: HTMLElement, detail: StaticPartyDetailDto): void {
(root.querySelector('#staticCover') as HTMLImageElement).src = detail.imageUrl;
root.querySelector('#staticTitle')!.textContent = detail.name;
const sourceLabel = SOURCE_LABELS[detail.sourceType];
root.querySelector('#staticMeta')!.textContent =
`${sourceLabel} · ${detail.groupWatchedCount}/${detail.itemCount} vus par le groupe · ${detail.memberCount} membre${detail.memberCount > 1 ? 's' : ''}`;
const percent = detail.itemCount ? Math.round((detail.groupWatchedCount / detail.itemCount) * 100) : 0;
(root.querySelector('#staticProgressFill') as HTMLElement).style.width = `${percent}%`;
root.querySelector('#staticNext')!.innerHTML = detail.nextItemName
? `Prochain : <strong>${escapeHtml(detail.nextItemName)}</strong>`
: '<strong>Le groupe a tout vu.</strong>';
const sessionBtn = root.querySelector('#sessionBtn') as HTMLButtonElement;
sessionBtn.textContent = detail.livePartyId ? 'Rejoindre la séance en cours' : 'Lancer la séance';
sessionBtn.disabled = !detail.livePartyId && !detail.nextItemName;
root.querySelector('#joinBtn')!.classList.toggle('hide', detail.youAreMember);
root.querySelector('#leaveBtn')!.classList.toggle('hide', !detail.youAreMember || detail.youAreCreator);
root.querySelector('#deleteBtn')!.classList.toggle('hide', !detail.youAreCreator);
renderMissed(root, detail);
renderMembers(root, detail);
renderItems(root, detail);
}
/** "Tu as manqué" - hidden entirely when there's nothing to catch up on. */
function renderMissed(root: HTMLElement, detail: StaticPartyDetailDto): void {
const section = root.querySelector('#missedSection') as HTMLElement;
const missed = new Set(detail.missedItemIds);
const items = detail.items.filter((i) => missed.has(i.itemId));
section.hidden = !items.length;
root.querySelector('#missedCount')!.textContent = String(items.length);
root.querySelector('#missedList')!.innerHTML = items.map((item) => `
<div class="episodeRow">
<input type="checkbox" class="staticCheck" data-item-id="${escapeHtml(item.itemId)}" checked aria-label="Inclure dans le rattrapage" />
<img src="${escapeHtml(item.itemImageUrl)}" alt="" loading="lazy" onerror="this.classList.add('noImage')" />
<div class="episodeInfo"><strong>${escapeHtml(item.itemName)}</strong></div>
<div class="staticRowActions">
<button class="secondaryBtn soloOneBtn" data-item-id="${escapeHtml(item.itemId)}">Regarder en solo</button>
<button class="secondaryBtn markWatchedBtn" data-item-id="${escapeHtml(item.itemId)}">Marquer comme vu</button>
</div>
</div>`).join('');
}
/** One chip per member, with how many items they've watched. */
function renderMembers(root: HTMLElement, detail: StaticPartyDetailDto): void {
root.querySelector('#memberList')!.innerHTML = detail.members.length
? detail.members.map((m) => `
<span class="staticMember">${escapeHtml(m.userName)}${m.isCreator ? ' ★' : ''}<span class="hint">${m.watchedCount}/${detail.itemCount}</span></span>`).join('')
: '<p class="hint">Aucun membre pour le moment.</p>';
}
/** The whole item list, with the group's state and the viewer's own. */
function renderItems(root: HTMLElement, detail: StaticPartyDetailDto): void {
root.querySelector('#itemList')!.innerHTML = detail.items.map((item, i) => `
<div class="episodeRow${item.groupState === 'watched' ? ' isWatched' : ''}${item.groupState === 'next' ? ' isNext' : ''}">
<img src="${escapeHtml(item.itemImageUrl)}" alt="" loading="lazy" onerror="this.classList.add('noImage')" />
<div class="episodeInfo"><strong>${i + 1}. ${escapeHtml(item.itemName)}</strong></div>
<span class="staticState">${stateLabel(item)}${item.viewerWatched ? '<span class="mine">✓ vu par toi</span>' : ''}</span>
</div>`).join('');
}
/** The group-state label for one item. */
function stateLabel(item: StaticPartyItemDto): string {
if (item.groupState === 'watched') return 'Vu par le groupe';
if (item.groupState === 'next') return 'Prochain';
return 'À venir';
}
/**
* Wires every button once - lists are re-rendered on reload, so row
* buttons use one delegated listener on their container rather than
* per-row listeners re-added on every render.
*/
function wireActions(root: HTMLElement, staticId: string): void {
const sessionBtn = root.querySelector('#sessionBtn') as HTMLButtonElement;
sessionBtn.addEventListener('click', () => {
runBusy(sessionBtn, async () => {
const { partyId } = await startStaticSession(staticId);
navigate(`/party/${partyId}`);
});
});
const joinBtn = root.querySelector('#joinBtn') as HTMLButtonElement;
joinBtn.addEventListener('click', () => {
runBusy(joinBtn, async () => {
await joinStaticParty(staticId);
showToast('Tu fais maintenant partie de cette lounge party.');
await reload(root, staticId);
});
});
const leaveBtn = root.querySelector('#leaveBtn') as HTMLButtonElement;
leaveBtn.addEventListener('click', () => {
if (!confirm('Quitter cette lounge party ? Ta progression y sera oubliée.')) return;
runBusy(leaveBtn, async () => {
await leaveStaticParty(staticId);
await reload(root, staticId);
});
});
const deleteBtn = root.querySelector('#deleteBtn') as HTMLButtonElement;
deleteBtn.addEventListener('click', () => {
if (!confirm('Supprimer cette lounge party pour tout le monde ? La progression de chacun sera perdue.')) return;
runBusy(deleteBtn, async () => {
await deleteStaticParty(staticId);
showToast('Lounge party supprimée.');
navigate('/static');
});
});
const catchUp = (btn: HTMLButtonElement, mode: 'solo' | 'party') => {
const itemIds = Array.from(root.querySelectorAll<HTMLInputElement>('#missedList .staticCheck'))
.filter((c) => c.checked)
.map((c) => c.dataset.itemId as string);
if (!itemIds.length) {
showToast('Coche au moins un élément à rattraper.', { variant: 'error' });
return;
}
runBusy(btn, async () => {
const { partyId } = await startCatchUp(staticId, itemIds, mode);
navigate(`/party/${partyId}`);
});
};
const soloBtn = root.querySelector('#catchUpSoloBtn') as HTMLButtonElement;
const partyBtn = root.querySelector('#catchUpPartyBtn') as HTMLButtonElement;
soloBtn.addEventListener('click', () => catchUp(soloBtn, 'solo'));
partyBtn.addEventListener('click', () => catchUp(partyBtn, 'party'));
root.querySelector('#missedList')!.addEventListener('click', (e) => {
const btn = (e.target as HTMLElement).closest<HTMLButtonElement>('button[data-item-id]');
if (!btn) return;
const itemId = btn.dataset.itemId as string;
if (btn.classList.contains('soloOneBtn')) {
runBusy(btn, async () => {
const { partyId } = await startCatchUp(staticId, [itemId], 'solo');
navigate(`/party/${partyId}`);
});
} else if (btn.classList.contains('markWatchedBtn')) {
runBusy(btn, async () => {
await markStaticItemWatched(staticId, itemId);
await reload(root, staticId);
});
}
});
}
/** Disables `btn` while `action` runs; a failure is toasted, logged, and
* gives the button back. */
function runBusy(btn: HTMLButtonElement, action: () => Promise<void>): void {
btn.disabled = true;
action()
.catch((err: unknown) => {
console.error('[staticParty] action failed', err);
showToast((err as Error).message, { variant: 'error' });
})
.finally(() => {
btn.disabled = false;
});
}

View file

@ -1,31 +0,0 @@
import { createStaticParty } from '../../api/staticParty';
import { navigate } from '../../router';
import { showToast } from '../../toast';
/**
* "Créer une lounge party" - shared by views/series (whole series, or
* the selected season) and views/playlist: creates a lounge party over
* the source id (a series, a season or a video playlist - the server
* decides which, and refuses anything else with a message) and opens its
* page. `sourceId` is read at click time, so a caller whose source can
* change (the selected season tab) wires the button only once.
*/
export function wireCreateStaticButton(btn: HTMLButtonElement, getSourceId: () => string): void {
const idleLabel = btn.textContent || '';
btn.addEventListener('click', () => {
const sourceId = getSourceId();
btn.disabled = true;
btn.textContent = 'Création...';
createStaticParty(sourceId)
.then((sp) => {
console.info('[staticParty] created', sp.id, 'from', sourceId);
navigate(`/static/${sp.id}`);
})
.catch((err: unknown) => {
console.error('[staticParty] create failed', sourceId, err);
showToast((err as Error).message, { variant: 'error' });
btn.disabled = false;
btn.textContent = idleLabel;
});
});
}

View file

@ -8,9 +8,3 @@ services:
- "3000:3000"
environment:
EMBY_SERVER_URL: "https://theater.media.kyuno.fr"
# Static (persistent) watch parties are saved here - see
# server/src/services/StaticPartyStore.ts. Without the volume below
# they'd be lost every time the container is recreated.
DATA_DIR: "/data"
volumes:
- ./data:/data

File diff suppressed because it is too large Load diff

View file

@ -46,10 +46,6 @@ COPY --from=build /app/client/dist ./client/dist
WORKDIR /app/server
ENV PORT=3000
# Static watch parties' JSON file (StaticPartyStore) - mount a volume here
# (docker-compose.yml does), or they're lost with the container.
ENV DATA_DIR=/data
RUN mkdir -p /data
EXPOSE 3000
CMD ["node", "dist/index.js"]

View file

@ -9,7 +9,7 @@
"start": "node dist/index.js",
"dev": "tsx watch src/index.ts",
"typecheck": "tsc -p tsconfig.test.json",
"test": "tsx --test test/castTokenService.test.ts test/catalogService.test.ts test/embyClient.test.ts test/embySessionService.test.ts test/musicQueueService.test.ts test/partyService.test.ts test/partyStreamService.test.ts test/playstateService.test.ts test/socketGateway.test.ts test/staticPartyService.test.ts test/transcodeService.test.ts"
"test": "tsx --test test/castTokenService.test.ts test/catalogService.test.ts test/embyClient.test.ts test/musicQueueService.test.ts test/partyService.test.ts test/partyStreamService.test.ts test/playstateService.test.ts test/socketGateway.test.ts test/transcodeService.test.ts"
},
"engines": {
"node": ">=18"

View file

@ -66,28 +66,18 @@ export class Logger {
console.log(`[watchparty:transcode] ${new Date().toISOString()} ${message}`);
}
/** Every per-member background write this app makes to Emby under
* someone's own account - the party watch-state sync (index.ts's
* periodic sweep, and PartyService.advanceQueue/MusicQueueService
* .playNext's own item-transition calls - see PlaystateService), and
* the parallel Sessions-API lifecycle reported alongside it (see
* EmbySessionService). Logs every outgoing request (method + path,
* and the POST body for the UserData write) and its outcome, not
* just failures: this is a best-effort background sync with nothing
* else watching it, so `docker logs | grep watchparty:playstate`
* needs to be enough on its own to see exactly what was sent and
* whether Emby accepted it, without cross-referencing `emby` above.
* Safe to log in full here (unlike `stream` above) - the real
* embyToken travels as a header, never logged, and both services'
* own tests confirm it never leaks into these lines. */
/** The party watch-state sync (index.ts's periodic sweep, and
* PartyService.advanceQueue/MusicQueueService.playNext's own item-
* transition calls - see PlaystateService) - logs every outgoing
* request (method + path, and the POST body for the UserData write)
* and its outcome, not just failures: this is a best-effort
* background sync with nothing else watching it, so `docker logs |
* grep watchparty:playstate` needs to be enough on its own to see
* exactly what was sent and whether Emby accepted it, without cross-
* referencing `emby` above. Safe to log in full here (unlike `stream`
* above) - the real embyToken travels as a header, never logged, and
* PlaystateService's own tests confirm it never leaks into these lines. */
static playstate(message: string): void {
console.log(`[watchparty:playstate] ${new Date().toISOString()} ${message}`);
}
/** Static (persistent) watch parties - created/joined/left/deleted,
* group and per-member progress, sessions launched, and every load/save
* of their JSON file (see StaticPartyService/StaticPartyStore). */
static staticParty(message: string): void {
console.log(`[watchparty:static] ${new Date().toISOString()} ${message}`);
}
}

View file

@ -17,11 +17,3 @@ if (!EMBY_SERVER_URL) {
}
export const PORT = Number(process.env.PORT) || 3000;
/**
* Where persistent state lives on disk - today only the static watch
* parties (see StaticPartyStore). Relative paths resolve against the
* process's working directory. Mount a volume here in Docker, or every
* static watch party is lost with the container.
*/
export const DATA_DIR = process.env.DATA_DIR || './data';

View file

@ -5,24 +5,18 @@ import http from 'http';
import express, { type NextFunction, type Request, type Response } from 'express';
import { Server as SocketIoServer } from 'socket.io';
import { DATA_DIR, EMBY_SERVER_URL, PORT } from './config';
import { EMBY_SERVER_URL, PORT } from './config';
import { Logger } from './Logger';
import { AuthService, SESSION_COOKIE, SESSION_TTL_MS } from './services/AuthService';
import { CatalogService, CatalogFetchError, imageUrlFor, seasonQueue, seriesQueueFrom, splitPlaylistByMediaType } from './services/CatalogService';
import { CatalogService, CatalogFetchError, imageUrlFor, splitPlaylistByMediaType } from './services/CatalogService';
import { PartyService } from './services/PartyService';
import { MusicQueueService } from './services/MusicQueueService';
import { TranscodeService } from './services/TranscodeService';
import { PartyStreamService } from './services/PartyStreamService';
import { PlaystateService } from './services/PlaystateService';
import { EmbySessionService } from './services/EmbySessionService';
import { CastTokenService } from './services/CastTokenService';
import { StaticPartyService } from './services/StaticPartyService';
import { StaticPartyStore } from './services/StaticPartyStore';
import { SocketGateway } from './realtime/SocketGateway';
import type { ClientToServerEvents, EmbyItem, EmbySession, ItemDto, MediaType, MusicTrackInput, QueueItem, ServerToClientEvents, StaticParty } from './types';
StaticPartyStore.configure(DATA_DIR);
StaticPartyService.init();
import type { ClientToServerEvents, EmbySession, MediaType, QueueItem, ServerToClientEvents } from './types';
const app = express();
app.set('trust proxy', 1); // behind openresty/nginx - needed for req.secure to reflect the real scheme
@ -218,13 +212,6 @@ app.get('/api/catalog/item/:itemId/playback-options', requireSession, asyncRoute
res.json(await CatalogService.getPlaybackOptions(req.embySession!, param(req, 'itemId')));
}));
// Emby's own native intro/credits detection, when the library it's from has
// the feature enabled - powers the player's "Passer l'intro"/"Épisode
// suivant" buttons. See CatalogService.getPlaybackMarkers.
app.get('/api/catalog/item/:itemId/markers', requireSession, asyncRoute(async (req, res) => {
res.json(await CatalogService.getPlaybackMarkers(req.embySession!, param(req, 'itemId')));
}));
app.get('/api/catalog/libraries/:libraryId/items', requireSession, asyncRoute(async (req, res) => {
res.json({ items: await CatalogService.getLibraryItems(req.embySession!, param(req, 'libraryId')) });
}));
@ -258,7 +245,7 @@ app.get('/api/catalog/party-stream/:partyId/master.m3u8', allowCastCors, asyncRo
if (!session) { res.status(401).end(); return; }
const party = PartyService.getById(partyId);
if (!party || !party.members.has(session.userId)) {
res.status(403).json({ error: "Tu n'es pas membre de cette party." });
res.status(403).json({ error: "Tu n'es pas membre de cette watch party." });
return;
}
await PartyStreamService.serveMasterPlaylist(req, res, party, session);
@ -272,7 +259,7 @@ app.get('/api/catalog/party-stream/:partyId/raw', allowCastCors, asyncRoute(asyn
if (!session) { res.status(401).end(); return; }
const party = PartyService.getById(partyId);
if (!party || !party.members.has(session.userId)) {
res.status(403).json({ error: "Tu n'es pas membre de cette party." });
res.status(403).json({ error: "Tu n'es pas membre de cette watch party." });
return;
}
await PartyStreamService.serveRawAsset(req, res, party, session);
@ -302,7 +289,7 @@ app.get('/api/catalog/burn/:partyId/master.m3u8', allowCastCors, asyncRoute(asyn
if (!session) { res.status(401).end(); return; }
const party = PartyService.getById(partyId);
if (!party || !party.members.has(session.userId)) {
res.status(403).json({ error: "Tu n'es pas membre de cette party." });
res.status(403).json({ error: "Tu n'es pas membre de cette watch party." });
return;
}
if (!party.burnInSubtitleIndex) {
@ -318,7 +305,7 @@ app.get('/api/catalog/burn/:partyId/:segment', allowCastCors, asyncRoute(async (
if (!session) { res.status(401).end(); return; }
const party = PartyService.getById(partyId);
if (!party || !party.members.has(session.userId)) {
res.status(403).json({ error: "Tu n'es pas membre de cette party." });
res.status(403).json({ error: "Tu n'es pas membre de cette watch party." });
return;
}
await TranscodeService.serveSegment(req, res, party, param(req, 'segment'));
@ -350,32 +337,6 @@ app.post('/api/parties', requireSession, asyncRoute(async (req, res) => {
return;
}
// Neither is itself streamable (same reason a Playlist above isn't) -
// launching one queues every track inside it instead: every album for
// an artist, every track for an album, in Emby's own sort order (see
// resolveMusicTracksForItem). Asked for directly, alongside the same
// action on the party's own existing queue - see the "queue" route
// below - both share this resolution.
// A whole series - every season, in order, from S1E1 (see
// seriesQueueFrom). Launched from the series page's own button or its
// library tile; an episode's own button still starts from that episode
// (the Episode branch below).
if (item.Type === 'Series') {
await createPartyFromSeries(res, session, item);
return;
}
// Just one season - the series page's per-season button (see seasonQueue).
if (item.Type === 'Season') {
await createPartyFromSeason(res, session, item);
return;
}
if (item.Type === 'MusicAlbum' || item.Type === 'MusicArtist') {
const tracks = await resolveMusicTracksForItem(session, item);
createMusicPartyFromTracks(res, session, tracks);
return;
}
const mediaType: MediaType = item.Type === 'Audio' ? 'music' : 'video';
// Starting a party from an episode queues the rest of the series from
@ -386,8 +347,13 @@ app.post('/api/parties', requireSession, asyncRoute(async (req, res) => {
let queue: QueueItem[] | null = null;
if (mediaType === 'video' && item.Type === 'Episode' && item.SeriesId) {
const all = await CatalogService.getAllSeriesEpisodesRaw(session, item.SeriesId);
const remaining = seriesQueueFrom(all, itemId);
queue = (remaining.length ? remaining : [item]).map(toVideoQueueItem);
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 = PartyService.create(
@ -424,17 +390,6 @@ function formatEpisodeName(episode: { ParentIndexNumber?: number; IndexNumber?:
return episode.Name;
}
/** One video item (episode or movie) as a queue entry - shared by every
* path that builds a video queue: an episode's series, a playlist, a
* static watch party. */
function toVideoQueueItem(item: EmbyItem): QueueItem {
return {
itemId: item.Id,
itemName: item.Type === 'Episode' ? formatEpisodeName(item) : item.Name,
itemImageUrl: `/api/catalog/image/${item.Id}`
};
}
/**
* The "launch this playlist" half of POST /api/parties above - kept
* separate rather than merged into the single-item path, which is
@ -469,19 +424,41 @@ async function createPartyFromPlaylist(res: Response, session: EmbySession, play
const items = startIndex > 0 ? allItems.slice(startIndex) : allItems;
if (mediaType === 'music') {
const trackInputs: MusicTrackInput[] = items.map((track) => ({
itemId: track.Id,
itemName: track.Name,
artistName: track.AlbumArtist || track.Artists?.[0] || null,
albumName: track.Album || null,
itemImageUrl: imageUrlFor(track),
durationTicks: track.RunTimeTicks || null
}));
createMusicPartyFromTracks(res, session, trackInputs);
const first = items[0]!;
const party = PartyService.create(
session.userId, session.userName,
first.Id, first.Name, imageUrlFor(first),
null, mediaType
);
PartyService.setMemberEmbyToken(party, session.userId, session.embyToken);
// Same mechanism POST /api/parties/:id/tracks already uses (see
// below) - init seeds+auto-starts the first track, addTrack queues
// the rest, exactly as if they'd been added one at a time.
items.forEach((track, i) => {
const trackInput = {
itemId: track.Id,
itemName: track.Name,
artistName: track.AlbumArtist || track.Artists?.[0] || null,
albumName: track.Album || null,
itemImageUrl: imageUrlFor(track),
durationTicks: track.RunTimeTicks || null
};
if (i === 0) {
MusicQueueService.init(party, trackInput, session.userId, session.userName);
} else {
MusicQueueService.addTrack(party, trackInput, session.userId, session.userName);
}
});
SocketGateway.broadcastLobby(io);
res.json(PartyService.toPublicState(party, session.userId));
return;
}
const queue: QueueItem[] = items.map(toVideoQueueItem);
const queue: QueueItem[] = items.map((e) => ({
itemId: e.Id,
itemName: e.Type === 'Episode' ? formatEpisodeName(e) : e.Name,
itemImageUrl: `/api/catalog/image/${e.Id}`
}));
const party = PartyService.create(
session.userId, session.userName,
queue[0]!.itemId, queue[0]!.itemName, queue[0]!.itemImageUrl,
@ -492,112 +469,6 @@ async function createPartyFromPlaylist(res: Response, session: EmbySession, play
res.json(PartyService.toPublicState(party, session.userId));
}
/** The "launch the whole series" half of POST /api/parties - a watch
* party queued with every episode of every season (see seriesQueueFrom). */
async function createPartyFromSeries(res: Response, session: EmbySession, series: EmbyItem): Promise<void> {
const episodes = await CatalogService.getAllSeriesEpisodesRaw(session, series.Id);
const queue = seriesQueueFrom(episodes).map(toVideoQueueItem);
if (!queue.length) {
res.status(400).json({ error: "Cette série n'a aucun épisode." });
return;
}
createVideoPartyFromQueue(res, session, queue, `series="${series.Name}"`);
}
/** The "launch just this season" half of POST /api/parties - only that
* season's episodes, nothing after it (see seasonQueue). */
async function createPartyFromSeason(res: Response, session: EmbySession, season: EmbyItem): Promise<void> {
if (!season.SeriesId) {
res.status(400).json({ error: 'Saison introuvable.' });
return;
}
const episodes = await CatalogService.getAllSeriesEpisodesRaw(session, season.SeriesId);
const queue = seasonQueue(episodes, season).map(toVideoQueueItem);
if (!queue.length) {
res.status(400).json({ error: "Cette saison n'a aucun épisode." });
return;
}
createVideoPartyFromQueue(res, session, queue, `season="${season.Name}" series=${season.SeriesId}`);
}
/** Creates a video watch party playing `queue` in order, hosted by
* `session` - shared by the whole-series and single-season launches. */
function createVideoPartyFromQueue(res: Response, session: EmbySession, queue: QueueItem[], logContext: string): void {
const first = queue[0]!;
const party = PartyService.create(session.userId, session.userName, first.itemId, first.itemName, first.itemImageUrl, queue, 'video');
PartyService.setMemberEmbyToken(party, session.userId, session.embyToken);
Logger.party(`queue launch partyId=${party.id} ${logContext} episodes=${queue.length}`);
SocketGateway.broadcastLobby(io);
res.json(PartyService.toPublicState(party, session.userId));
}
/** A raw Emby item known/expected to be music-shaped (Audio, MusicAlbum,
* or MusicArtist - the three POST /api/parties and the "queue" route
* below ever call this for) resolved into a flat, ordered track list -
* a single track for Audio, every track in Emby's own IndexNumber order
* for an album, every album's tracks in Emby's own ProductionYear/
* SortName order for an artist's whole discography. Two Emby round
* trips for an artist's own albums+tracks would be one call each if
* this app ever needed to page through a huge discography - not
* attempted speculatively, since Emby has yet to show any sign of
* needing it for a personal library's worth of artists. */
async function resolveMusicTracksForItem(session: EmbySession, item: EmbyItem): Promise<MusicTrackInput[]> {
if (item.Type === 'MusicAlbum') {
const tracks = await CatalogService.getAlbumTracks(session, item.Id);
return tracks.map(itemDtoToTrackInput);
}
if (item.Type === 'MusicArtist') {
const albums = await CatalogService.getArtistAlbums(session, item.Id);
const perAlbum = await Promise.all(albums.map((album) => CatalogService.getAlbumTracks(session, album.id)));
return perAlbum.flat().map(itemDtoToTrackInput);
}
// A single Audio track - the only other type this is ever called for.
return [{
itemId: item.Id,
itemName: item.Name,
artistName: item.AlbumArtist || item.Artists?.[0] || null,
albumName: item.Album || null,
itemImageUrl: imageUrlFor(item),
durationTicks: item.RunTimeTicks || null
}];
}
function itemDtoToTrackInput(item: ItemDto): MusicTrackInput {
return {
itemId: item.id,
itemName: item.name,
artistName: item.artistName,
albumName: item.albumName,
itemImageUrl: item.imageUrl,
durationTicks: item.durationTicks
};
}
/** Shared by every "launch this as a listen party" path that can produce
* more than one track up front (an album, an artist's discography, a
* music playlist - see createPartyFromPlaylist above and the
* MusicAlbum/MusicArtist branch of POST /api/parties below) - seeds
* party.music with `tracks`, in order: the first auto-starts,
* everything else queues behind it via MusicQueueService.addTracks
* (one broadcast for however many tracks that is, not one per track). */
function createMusicPartyFromTracks(res: Response, session: EmbySession, tracks: MusicTrackInput[]): void {
if (!tracks.length) {
res.status(400).json({ error: 'Aucun morceau à lire.' });
return;
}
const [first, ...rest] = tracks;
const party = PartyService.create(
session.userId, session.userName,
first!.itemId, first!.itemName, first!.itemImageUrl,
null, 'music'
);
PartyService.setMemberEmbyToken(party, session.userId, session.embyToken);
MusicQueueService.init(party, first!, session.userId, session.userName);
MusicQueueService.addTracks(party, rest, session.userId, session.userName);
SocketGateway.broadcastLobby(io);
res.json(PartyService.toPublicState(party, session.userId));
}
// Adding a track to a music party's shared queue is REST, not a socket
// event (see SocketGateway's top comment): the album-browsing page that
// this is actually called from has no live connection to the party it's
@ -608,12 +479,12 @@ function createMusicPartyFromTracks(res: Response, session: EmbySession, tracks:
app.post('/api/parties/:partyId/tracks', requireSession, (req, res) => {
const party = PartyService.getById(param(req, 'partyId'));
if (!party || party.mediaType !== 'music') {
res.status(404).json({ error: 'Listen party introuvable.' });
res.status(404).json({ error: 'Watch party musique introuvable.' });
return;
}
const session = req.embySession!;
if (!party.members.has(session.userId)) {
res.status(403).json({ error: "Tu n'es pas membre de cette party." });
res.status(403).json({ error: "Tu n'es pas membre de cette watch party." });
return;
}
const track = req.body || {};
@ -626,39 +497,6 @@ app.post('/api/parties/:partyId/tracks', requireSession, (req, res) => {
res.json(PartyService.toPublicState(party, session.userId));
});
// Same "queue this item" resolution as the MusicAlbum/MusicArtist branch
// of POST /api/parties above (a track queues itself; an album/artist
// expands into every track inside it - see resolveMusicTracksForItem),
// but adding into an ALREADY-LIVE party's queue instead of creating a
// new one - views/album's and views/artist's own "add to queue" buttons.
// Kept as its own route rather than overloading POST .../tracks above:
// that one takes an already-built track object the caller already has
// in hand (views/album's per-track buttons) - making it also guess
// whether it received that or a bare id-to-resolve would be more
// surprising than a second, clearly-named route.
app.post('/api/parties/:partyId/queue', requireSession, asyncRoute(async (req, res) => {
const party = PartyService.getById(param(req, 'partyId'));
if (!party || party.mediaType !== 'music') {
res.status(404).json({ error: 'Listen party introuvable.' });
return;
}
const session = req.embySession!;
if (!party.members.has(session.userId)) {
res.status(403).json({ error: "Tu n'es pas membre de cette party." });
return;
}
const itemId = req.body?.itemId;
if (!itemId) {
res.status(400).json({ error: 'itemId is required.' });
return;
}
const item = await CatalogService.getRawItem(session, itemId);
const tracks = await resolveMusicTracksForItem(session, item);
MusicQueueService.addTracks(party, tracks, session.userId, session.userName);
SocketGateway.broadcastLobby(io); // the queue/now-playing preview may have just changed
res.json(PartyService.toPublicState(party, session.userId));
}));
app.post('/api/parties/:partyId/join', requireSession, (req, res) => {
const party = PartyService.getById(param(req, 'partyId'));
if (!party) {
@ -668,12 +506,6 @@ app.post('/api/parties/:partyId/join', requireSession, (req, res) => {
const session = req.embySession!;
PartyService.join(party, session.userId, session.userName);
PartyService.setMemberEmbyToken(party, session.userId, session.embyToken);
// Joining a static watch party's group session from the lobby makes you
// a member of that static party too - its progress is now yours.
if (party.staticLink?.mode === 'group') {
const sp = StaticPartyService.getById(party.staticLink.staticPartyId);
if (sp) StaticPartyService.join(sp, { userId: session.userId, userName: session.userName });
}
SocketGateway.broadcastLobby(io); // member count changed - reflect it on the home page immediately
res.json(PartyService.toPublicState(party, session.userId));
});
@ -681,16 +513,9 @@ app.post('/api/parties/:partyId/join', requireSession, (req, res) => {
app.post('/api/parties/:partyId/leave', requireSession, (req, res) => {
const party = PartyService.getById(param(req, 'partyId'));
if (party) {
const member = party.members.get(req.embySession!.userId);
const deviceId = typeof req.body?.deviceId === 'string' ? req.body.deviceId : null;
// See PartyService.isStaleDevice's own comment for why this check
// exists at all - a REST call has no live connection to guard this
// with, unlike the socket "leave"/"disconnect" handlers.
if (!PartyService.isStaleDevice(member, deviceId)) {
// Shared with the socket "leave"/"disconnect" handlers so the host-
// handoff notification and the lobby refresh only live in one place.
SocketGateway.applyLeave(io, party, req.embySession!.userId);
}
// Shared with the socket "leave"/"disconnect" handlers so the host-
// handoff notification and the lobby refresh only live in one place.
SocketGateway.applyLeave(io, party, req.embySession!.userId);
}
res.json({ ok: true });
});
@ -714,7 +539,7 @@ app.post('/api/parties/:partyId/cast-token', requireSession, (req, res) => {
const party = PartyService.getById(param(req, 'partyId'));
const session = req.embySession!;
if (!party || !party.members.has(session.userId)) {
res.status(403).json({ error: "Tu n'es pas membre de cette party." });
res.status(403).json({ error: "Tu n'es pas membre de cette watch party." });
return;
}
res.json({ token: CastTokenService.issue(party.id, session) });
@ -728,229 +553,6 @@ app.delete('/api/parties/:partyId/cast-token', requireSession, (req, res) => {
res.json({ ok: true });
});
// --- Static (persistent) watch parties ------------------------------------
// A whole series/video playlist watched by a group over several sessions,
// remembering the group's progress and each member's - see
// StaticPartyService and spec/static-watch-party.md. Each session is an
// ordinary live party (PartyService) linked back through party.staticLink.
/** Fetches a static party's source under `session`'s own account - so
* each item carries *their* Emby UserData (Played): the whole series
* from S1E1 (specials left out - see seriesQueueFrom), one season's
* episodes, or a playlist's video items. */
async function fetchStaticSourceItems(session: EmbySession, source: StaticParty['source']): Promise<EmbyItem[]> {
if (source.type === 'series') {
return seriesQueueFrom(await CatalogService.getAllSeriesEpisodesRaw(session, source.sourceId));
}
if (source.type === 'season') {
if (!source.seriesId) return [];
const episodes = await CatalogService.getAllSeriesEpisodesRaw(session, source.seriesId);
return seasonQueue(episodes, { Id: source.sourceId, IndexNumber: source.seasonNumber });
}
const { mediaType, items } = splitPlaylistByMediaType(await CatalogService.getPlaylistItemsRaw(session, source.sourceId));
return mediaType === 'video' ? items : [];
}
/**
* Re-fetches `sp`'s source for `session`: merges any new item into it
* (StaticPartyService.refreshItems) and returns the ids Emby already has
* as Played for this user. Best-effort - Emby being unreachable must not
* make the page unusable, so a failure is logged and treated as "no
* Played info, no new items" (the in-app progress still works on its own).
*/
async function syncStaticSource(session: EmbySession, sp: StaticParty): Promise<Set<string>> {
try {
const raw = await fetchStaticSourceItems(session, sp.source);
StaticPartyService.refreshItems(sp, raw.map(toVideoQueueItem));
return new Set(raw.filter((i) => i.UserData?.Played).map((i) => i.Id));
} catch (err) {
Logger.staticParty(`source sync failed id=${sp.id} user=${Logger.who(session.userId, session.userName)}: ${(err as Error).message}`);
return new Set();
}
}
/** `sp`'s live group session id, if that party is still running - clears a
* stale one (a party can end without its static party having heard). */
function liveGroupPartyId(sp: StaticParty): string | null {
if (sp.livePartyId && !PartyService.getById(sp.livePartyId)) StaticPartyService.setLiveParty(sp, null);
return sp.livePartyId;
}
/** Finds `:staticId` or answers 404 - null means the response is sent. */
function staticPartyOr404(req: Request, res: Response): StaticParty | null {
const sp = StaticPartyService.getById(param(req, 'staticId'));
if (!sp) res.status(404).json({ error: 'Lounge party introuvable.' });
return sp;
}
app.get('/api/static-parties', requireSession, asyncRoute(async (req, res) => {
const session = req.embySession!;
const parties = StaticPartyService.list();
const played = await Promise.all(parties.map((sp) => syncStaticSource(session, sp)));
res.json({
staticParties: parties.map((sp, i) => StaticPartyService.toSummary(sp, session.userId, played[i]!, liveGroupPartyId(sp)))
});
}));
app.post('/api/static-parties', requireSession, asyncRoute(async (req, res) => {
const sourceId = req.body?.sourceId;
if (!sourceId || typeof sourceId !== 'string') {
res.status(400).json({ error: 'sourceId is required.' });
return;
}
const session = req.embySession!;
const item = await CatalogService.getRawItem(session, sourceId);
// A whole series, one season of it, or a video playlist. An episode
// stands for its whole series - the natural entry point from
// views/series is the series itself, but nothing's lost accepting one.
// `sourceItem` is what gives the default name and the artwork: the
// series itself for a season (a season's own poster is often missing).
let source: StaticParty['source'];
let sourceItem = item;
let defaultName = item.Name;
if (item.Type === 'Series') {
source = { type: 'series', sourceId: item.Id };
} else if (item.Type === 'Season' && item.SeriesId) {
source = { type: 'season', sourceId: item.Id, seriesId: item.SeriesId, seasonNumber: item.IndexNumber };
sourceItem = await CatalogService.getRawItem(session, item.SeriesId);
defaultName = `${sourceItem.Name} - ${item.Name}`;
} else if (item.Type === 'Episode' && item.SeriesId) {
source = { type: 'series', sourceId: item.SeriesId };
sourceItem = await CatalogService.getRawItem(session, item.SeriesId);
defaultName = sourceItem.Name;
} else if (item.Type === 'Playlist') {
source = { type: 'playlist', sourceId: item.Id };
} else {
res.status(400).json({ error: 'Une lounge party se crée depuis une série, une saison ou une playlist vidéo.' });
return;
}
const raw = await fetchStaticSourceItems(session, source);
if (!raw.length) {
const emptyMessages = { series: "Cette série n'a aucun épisode.", season: "Cette saison n'a aucun épisode.", playlist: 'Cette playlist ne contient aucune vidéo.' };
res.status(400).json({ error: emptyMessages[source.type] });
return;
}
const name = typeof req.body?.name === 'string' && req.body.name.trim() ? req.body.name.trim() : defaultName;
const sp = StaticPartyService.create(
{ userId: session.userId, userName: session.userName },
source, name, imageUrlFor(sourceItem), raw.map(toVideoQueueItem)
);
const played = new Set(raw.filter((i) => i.UserData?.Played).map((i) => i.Id));
res.json(StaticPartyService.toDetail(sp, session.userId, played, null));
}));
app.get('/api/static-parties/:staticId', requireSession, asyncRoute(async (req, res) => {
const sp = staticPartyOr404(req, res);
if (!sp) return;
const session = req.embySession!;
const played = await syncStaticSource(session, sp);
res.json(StaticPartyService.toDetail(sp, session.userId, played, liveGroupPartyId(sp)));
}));
app.post('/api/static-parties/:staticId/join', requireSession, (req, res) => {
const sp = staticPartyOr404(req, res);
if (!sp) return;
const session = req.embySession!;
StaticPartyService.join(sp, { userId: session.userId, userName: session.userName });
res.json({ ok: true });
});
app.post('/api/static-parties/:staticId/leave', requireSession, (req, res) => {
const sp = staticPartyOr404(req, res);
if (!sp) return;
StaticPartyService.leave(sp, req.embySession!.userId);
res.json({ ok: true });
});
app.delete('/api/static-parties/:staticId', requireSession, (req, res) => {
const sp = staticPartyOr404(req, res);
if (!sp) return;
if (!StaticPartyService.delete(sp, req.embySession!.userId)) {
res.status(403).json({ error: 'Seule la personne qui a créé cette lounge party peut la supprimer.' });
return;
}
res.json({ ok: true });
});
app.post('/api/static-parties/:staticId/items/:itemId/watched', requireSession, (req, res) => {
const sp = staticPartyOr404(req, res);
if (!sp) return;
const session = req.embySession!;
if (!StaticPartyService.markWatched(sp, { userId: session.userId, userName: session.userName }, param(req, 'itemId'))) {
res.status(404).json({ error: 'Cet élément ne fait pas partie de cette lounge party.' });
return;
}
res.json({ ok: true });
});
// Launches the group's next session - or joins it, if one's already
// running. Whoever launches it hosts it (see PartyService.create), and it
// resumes exactly where the group stopped last time: the host's starting
// position is seeded from the saved one, which is what Player.ts already
// reads to place every member's first frame (see its init()).
app.post('/api/static-parties/:staticId/session', requireSession, asyncRoute(async (req, res) => {
const sp = staticPartyOr404(req, res);
if (!sp) return;
const session = req.embySession!;
const who = { userId: session.userId, userName: session.userName };
const existingId = liveGroupPartyId(sp);
if (!existingId) await syncStaticSource(session, sp); // pick up new episodes before building the queue
// Checked again after the await: someone else may have launched it meanwhile.
const liveId = liveGroupPartyId(sp);
const live = liveId ? PartyService.getById(liveId) : null;
if (live) {
PartyService.join(live, session.userId, session.userName);
PartyService.setMemberEmbyToken(live, session.userId, session.embyToken);
StaticPartyService.join(sp, who);
SocketGateway.broadcastLobby(io);
res.json({ partyId: live.id });
return;
}
const queue = StaticPartyService.groupQueue(sp);
if (!queue.length) {
res.status(409).json({ error: 'Le groupe a déjà tout vu dans cette lounge party.' });
return;
}
const first = queue[0]!;
const party = PartyService.create(session.userId, session.userName, first.itemId, first.itemName, first.itemImageUrl, queue, 'video');
party.staticLink = { staticPartyId: sp.id, mode: 'group' };
party.members.get(session.userId)!.positionTicks = sp.group.resumePositionTicks;
PartyService.setMemberEmbyToken(party, session.userId, session.embyToken);
StaticPartyService.join(sp, who);
StaticPartyService.setLiveParty(sp, party.id);
SocketGateway.broadcastLobby(io);
res.json({ partyId: party.id });
}));
// Catching up on missed items - alone (a party hidden from the lobby) or as
// an ordinary watch party others can join. Only the participants' own
// progress changes; the group's doesn't (see StaticPartyService.onItemCompleted).
app.post('/api/static-parties/:staticId/catch-up', requireSession, (req, res) => {
const sp = staticPartyOr404(req, res);
if (!sp) return;
const session = req.embySession!;
const mode = req.body?.mode === 'party' ? 'party' : 'solo';
const requested = new Set(Array.isArray(req.body?.itemIds) ? req.body.itemIds.filter((id: unknown) => typeof id === 'string') : []);
const queue = sp.items.filter((i) => requested.has(i.itemId)); // the static party's own order, never the caller's
if (!queue.length) {
res.status(400).json({ error: 'Aucun élément à rattraper.' });
return;
}
const first = queue[0]!;
const party = PartyService.create(session.userId, session.userName, first.itemId, first.itemName, first.itemImageUrl, queue, 'video');
party.staticLink = { staticPartyId: sp.id, mode: 'catchUp' };
party.hiddenFromLobby = mode === 'solo';
PartyService.setMemberEmbyToken(party, session.userId, session.embyToken);
StaticPartyService.join(sp, { userId: session.userId, userName: session.userName });
Logger.staticParty(`catch-up started id=${sp.id} mode=${mode} partyId=${party.id} items=${queue.length} by=${Logger.who(session.userId, session.userName)}`);
if (!party.hiddenFromLobby) SocketGateway.broadcastLobby(io);
res.json({ partyId: party.id });
});
// Catches whatever asyncRoute() forwarded (or a synchronous throw) -
// CatalogFetchError carries its own status/message; anything else is an
// unexpected bug, logged in full server-side but never leaked to the
@ -1004,70 +606,17 @@ setInterval(() => PartyStreamService.reapIdleSessions(30 * 60 * 1000), 5 * 60 *
// Marking an item Played (once the party moves past it) is handled
// separately, at the source of that transition - see
// PartyService.advanceQueue and MusicQueueService.playNext.
//
// Also where EmbySessionService's own start/progress/stop lifecycle is
// driven from, rather than scattered across every queue-advance/track-
// skip call site the way markPlayed needed to be - this loop already
// visits every connected member on every tick, so it's already in
// exactly the right place to notice "this member's current item just
// changed" (member.embySession.itemId no longer matches) and react:
// stop the old session if there was one, negotiate a fresh
// MediaSourceId/PlaySessionId under their own account, and start the
// new one. Same item as last tick just reports Progress with the
// already-cached ids - no renegotiation needed for that, the common case.
setInterval(() => {
for (const party of PartyService.listActive()) {
const state = PartyService.getCurrentPlaybackState(party);
if (!state) continue; // nothing currently playing (e.g. an empty music queue)
// A static watch party's group session: remember where the group is,
// so the next session resumes there (no-op for any other party).
if (party.staticLink) StaticPartyService.recordPosition(party.staticLink, state.itemId, state.positionTicks);
for (const member of party.members.values()) {
if (!member.connection || !member.embyToken) continue;
PlaystateService.updatePosition(member.embyToken, member.userId, state.itemId, state.positionTicks);
if (member.embySession && member.embySession.itemId === state.itemId) {
const s = member.embySession;
EmbySessionService.progress(member.embyToken, s.itemId, s.mediaSourceId, s.playSessionId, state.positionTicks, state.isPaused);
continue;
}
// The item just changed for them (or this is the first tick since
// they started watching/listening) - stop whatever they had open
// before, if anything, then negotiate and start a fresh one.
const previous = member.embySession;
const embyToken = member.embyToken;
member.embySession = null;
if (previous) {
EmbySessionService.stop(embyToken, previous.itemId, previous.mediaSourceId, previous.playSessionId, member.positionTicks);
}
// CatalogService's own methods all take a full EmbySession, for
// consistency with every live user-facing route that already has
// one from requireSession - this sweep only ever has a member's
// bare stored token, same situation PlaystateService/
// EmbySessionService are already in. serverId/createdUtc are safe
// to fake: negotiatePlaybackSession (like every per-member Emby
// write in this app) never reads either, only embyToken/userId.
const fakeSession: EmbySession = { embyToken, userId: member.userId, userName: member.userName, serverId: '', createdUtc: 0 };
CatalogService.negotiatePlaybackSession(fakeSession, state.itemId).then((negotiated) => {
if (!negotiated) return;
member.embySession = { itemId: state.itemId, mediaSourceId: negotiated.mediaSourceId, playSessionId: negotiated.playSessionId };
EmbySessionService.start(embyToken, state.itemId, negotiated.mediaSourceId, negotiated.playSessionId, state.positionTicks, state.isPaused);
});
}
}
}, 20 * 1000);
// A pending (debounced) static-party save must reach disk before the
// process exits - `docker stop` sends SIGTERM, Ctrl+C sends SIGINT.
for (const signal of ['SIGTERM', 'SIGINT'] as const) {
process.once(signal, () => {
Logger.staticParty(`${signal} received - flushing before exit`);
StaticPartyStore.flushSync();
process.exit(0);
});
}
server.listen(PORT, () => {
console.log(`[watchparty] listening on :${PORT} (Emby server: ${EMBY_SERVER_URL || 'NOT SET'})`);
});

View file

@ -3,7 +3,6 @@
import { Logger } from '../Logger';
import { MusicQueueService } from '../services/MusicQueueService';
import { PartyService } from '../services/PartyService';
import { StaticPartyService } from '../services/StaticPartyService';
import type { AppServer, LeaveResult, LobbyPartyDto, Party, SocketMemberDto } from '../types';
/**
@ -36,9 +35,6 @@ import type { AppServer, LeaveResult, LobbyPartyDto, Party, SocketMemberDto } fr
* "playstate" {isPaused, positionTicks} (host only - ignored otherwise)
* "playstate" (server -> client, to everyone in the party but the sender)
* "next" (host only - advance the queue)
* "itemEnded" (host only - the last queued item
* played to its end; counts it as
* completed without advancing)
* "itemChanged" (server -> client)
* "setBurnInSubtitle" {subtitleIndex, audioIndex?} (host only - picks
* or clears the party's *shared* self-hosted-ffmpeg burn-in stream,
@ -211,17 +207,6 @@ class SocketGatewayClass {
}
}));
// The last queued item played to its end - nothing to advance to,
// but it still counts as completed (Emby Played flag, static watch
// party progress). Host-only, same as "next".
socket.on('itemEnded', this.safe(() => {
if (!party || userId !== party.hostUserId) {
return;
}
Logger.party(`item ended partyId=${party.id} item="${party.itemName}" (${party.queueIndex + 1}/${party.queue.length})`);
PartyService.completeCurrentItem(party);
}));
// Host-only, video-only: picks (or clears) the party's *shared*
// burn-in stream - see TranscodeService and Party.burnInSubtitleIndex's
// own comment. Deliberately doesn't touch TranscodeService directly
@ -298,12 +283,6 @@ class SocketGatewayClass {
MusicQueueService.setPaused(party, !!msg?.isPaused);
}));
// No host check either, same reasoning - see MusicQueueService.seek.
socket.on('musicSeek', this.safe((msg: { positionSeconds: number }) => {
if (!party || party.mediaType !== 'music' || !party.members.get(userId)) return;
MusicQueueService.seek(party, Number(msg?.positionSeconds) || 0);
}));
// Anyone (not just followers - harmless either way) can ask the host
// to pause; the presence overlay (the video player) is what triggers
// this. Broadcast to the whole party, not just the host, so everyone
@ -425,11 +404,8 @@ class SocketGatewayClass {
}
private lobbySnapshot(): LobbyPartyDto[] {
// A solo catch-up session of a static watch party isn't something
// anyone else should see or join - see Party.hiddenFromLobby.
return PartyService.listActive().filter((party) => !party.hiddenFromLobby).map((party) => {
return PartyService.listActive().map((party) => {
const host = party.members.get(party.hostUserId);
const staticParty = party.staticLink ? StaticPartyService.getById(party.staticLink.staticPartyId) : null;
// Video only - see LobbyPartyDto's own comment for why music parties
// get null here and fall back to createdUtc client-side instead.
// The host is authoritative for position/pause the same way they are
@ -447,9 +423,7 @@ class SocketGatewayClass {
createdUtc: party.createdUtc, // client computes/ticks elapsed time itself from this
positionTicks: isVideo && host ? host.positionTicks : null,
isPaused: isVideo && host ? host.isPaused : null,
positionUpdatedUtc: isVideo && host ? host.lastUpdatedUtc : null,
staticPartyId: staticParty ? staticParty.id : null,
staticPartyName: staticParty ? staticParty.name : null
positionUpdatedUtc: isVideo && host ? host.lastUpdatedUtc : null
};
});
}

View file

@ -4,15 +4,14 @@ import type { Request, Response } from 'express';
import { EMBY_SERVER_URL } from '../config';
import { EmbyClient } from '../emby/EmbyClient';
import { Logger } from '../Logger';
import type { EmbyItem, EmbySession, ItemDto, LibraryDto, MediaType, PlaybackMarkersDto, PlaybackOptionsDto, SeasonDto } from '../types';
import type { EmbyItem, EmbySession, ItemDto, LibraryDto, MediaType, PlaybackOptionsDto, SeasonDto } from '../types';
// 'MusicVideo' is Emby's own item type for a musicvideos library's
// contents (distinct from the generic 'Video', which is home videos).
// 'Audio' is a music track. 'Series' isn't itself streamable, but
// launching one queues the whole series (see seriesQueueFrom and POST
// /api/parties). Season/MusicArtist/MusicAlbum get no tile button - their
// own pages have their own launch actions. "Voir sur Emby" always works.
const WATCH_PARTY_TYPES = new Set(['Movie', 'Episode', 'Video', 'MusicVideo', 'Audio', 'Series']);
// 'Audio' is a music track. Series/Season/MusicArtist/MusicAlbum aren't
// directly playable - only show "start a party" for things Emby can
// actually stream. "Voir sur Emby" always works though.
const WATCH_PARTY_TYPES = new Set(['Movie', 'Episode', 'Video', 'MusicVideo', 'Audio']);
// Emby's /Views can return all sorts of things beyond actual media
// libraries - boxset collections, live TV, photos, books, a mixed-content
@ -69,36 +68,6 @@ export function imageUrlFor(item: EmbyItem): string {
return `/api/catalog/image/${imageItemId}`;
}
/**
* What a watch party queues from a series' full episode list (Emby's own
* order, every season - see getAllSeriesEpisodesRaw): everything from
* `fromEpisodeId` on, or - when launching the whole series - everything
* from its first regular episode on. Specials (season 0) sort first in
* Emby's list, so "the whole series" deliberately starts at S1E1, the
* same queue clicking S1E1's own button builds. An unknown
* `fromEpisodeId` yields an empty list (the caller falls back to the one
* item). Pure.
*/
export function seriesQueueFrom(episodes: EmbyItem[], fromEpisodeId?: string): EmbyItem[] {
if (fromEpisodeId) {
const index = episodes.findIndex((e) => e.Id === fromEpisodeId);
return index >= 0 ? episodes.slice(index) : [];
}
const firstRegular = episodes.findIndex((e) => e.ParentIndexNumber !== 0);
return firstRegular >= 0 ? episodes.slice(firstRegular) : episodes;
}
/**
* Just one season's episodes, in Emby's order, out of a series' full
* episode list - "launch this season". Matched by SeasonId, falling back
* to the season number for an episode Emby returned without one. Pure.
*/
export function seasonQueue(episodes: EmbyItem[], season: { Id: string; IndexNumber?: number }): EmbyItem[] {
return episodes.filter((e) => e.SeasonId
? e.SeasonId === season.Id
: season.IndexNumber != null && e.ParentIndexNumber === season.IndexNumber);
}
export function splitPlaylistByMediaType(items: EmbyItem[]): { mediaType: MediaType; items: EmbyItem[] } {
if (!items.length) return { mediaType: 'video', items: [] };
const isMusic = items[0]!.Type === 'Audio';
@ -156,23 +125,9 @@ class CatalogServiceClass {
* mediaType from the clicked item's real Emby Type (see index.ts).
* Unlike every other method here, this preserves Emby's own status
* (typically a real 404) rather than a blanket 502 - "titre introuvable"
* is a not-found, not an upstream failure.
*
* `Fields=AlbumId` - without it, Emby's default field set for a single
* item omits it, so a track started directly (not via its album/
* artist - the one path that reaches imageUrlFor through *this*
* method, in index.ts's POST /api/parties) always missed imageUrlFor's
* own Audio -> AlbumId fallback and fell through to the track's own
* id instead - which, confirmed directly against a real server (see
* imageUrlFor's own comment), essentially never has a Primary image
* of its own. Reported directly as "the app really struggles to find
* artist/album/track images" - this is the one concrete, fixable
* piece of that: a party started this way now gets the same album
* art every other path already resolves correctly. Harmless for
* every non-Audio item this also fetches (Movie/Series/Episode/...) -
* they have no AlbumId to begin with, so Emby just omits the field. */
* is a not-found, not an upstream failure. */
async getRawItem(session: EmbySession, itemId: string): Promise<EmbyItem> {
return this.fetchJson<EmbyItem>(`/Users/${session.userId}/Items/${itemId}?Fields=AlbumId`, session, 'Titre introuvable.', { preserveEmbyStatus: true });
return this.fetchJson<EmbyItem>(`/Users/${session.userId}/Items/${itemId}`, session, 'Titre introuvable.', { preserveEmbyStatus: true });
}
async getItem(session: EmbySession, itemId: string): Promise<ItemDto> {
@ -250,7 +205,7 @@ class CatalogServiceClass {
// so a 300 cap would have silently dropped 75% of them, not just
// trimmed an edge case. Rendering that many is tiles.ts's
// renderTilesInChunks's job, not this query's.
const params = new URLSearchParams({ ParentId: libraryId, UserId: session.userId, SortBy: 'SortName', Fields: 'SortName' });
const params = new URLSearchParams({ ParentId: libraryId, UserId: session.userId, SortBy: 'SortName' });
const data = await this.fetchJson<EmbyItemsResponse>(`/Artists?${params}`, session, 'Impossible de récupérer les artistes.');
return (data.Items || []).map((a) => this.toItemDto(a, session.serverId));
}
@ -306,7 +261,7 @@ class CatalogServiceClass {
ParentId: libraryId,
Recursive: 'false', // top-level: Movies as movies, TV as Series tiles (not every episode)
SortBy: 'SortName',
Fields: 'ProductionYear,AlbumId,SortName',
Fields: 'ProductionYear,AlbumId',
ImageTypeLimit: '1'
});
const data = await this.fetchJson<EmbyItemsResponse>(`/Users/${session.userId}/Items?${params}`, session, 'Impossible de récupérer le contenu de cette bibliothèque.');
@ -331,30 +286,6 @@ class CatalogServiceClass {
};
}
/**
* Emby's own native intro/credits detection - see PlaybackMarkersDto's
* own comment for what MarkerType actually looks like and where that
* was confirmed. Read from the same Chapters field getRawItem/toItemDto
* never request (they have no use for chapters at all) - a dedicated
* fetch here rather than folding it into getItem, since a chapter list
* is only ever needed by the player deciding whether to show a skip
* button, nowhere a plain ItemDto is used.
*/
async getPlaybackMarkers(session: EmbySession, itemId: string): Promise<PlaybackMarkersDto> {
const data = await this.fetchJson<{ Chapters?: Array<{ StartPositionTicks: number; MarkerType?: string }> }>(
`/Users/${session.userId}/Items/${itemId}?Fields=Chapters`,
session,
'Impossible de récupérer les marqueurs de lecture.'
);
const chapters = data.Chapters || [];
const startOf = (markerType: string) => chapters.find((c) => c.MarkerType === markerType)?.StartPositionTicks ?? null;
return {
introStartTicks: startOf('IntroStart'),
introEndTicks: startOf('IntroEnd'),
creditsStartTicks: startOf('CreditsStart')
};
}
/**
* The negotiated MediaSource's id/container/streams - used by
* TranscodeService to build its own ffmpeg source URL and translate
@ -376,33 +307,6 @@ class CatalogServiceClass {
return { id: source.Id, container: source.Container || 'mkv', streams: source.MediaStreams || [] };
}
/**
* A fresh PlaybackInfo negotiation under `session`'s own account,
* purely to get the MediaSourceId/PlaySessionId EmbySessionService
* needs to open a real Emby Sessions-API entry for this member - see
* its own class comment for why every party member needs their own,
* rather than reusing the one PartyStreamService already negotiated
* for the party's actual shared relay (that one was negotiated once,
* under whichever member happened to trigger it first - never
* guaranteed valid for every OTHER member's own account, since Emby
* scopes PlaybackInfo per user). Null on any failure (an expired
* token, Emby unreachable, no source at all) rather than throwing -
* this is reported to an admin dashboard as a nice-to-have, never
* something that should be allowed to disrupt real playback the way
* getMediaSource's own hard failure is allowed to (that one feeds an
* actual ffmpeg source).
*/
async negotiatePlaybackSession(session: EmbySession, itemId: string): Promise<{ mediaSourceId: string; playSessionId: string } | null> {
try {
const info = await this.fetchJson<PlaybackInfoResponse>(`/Items/${itemId}/PlaybackInfo?UserId=${session.userId}`, session, 'Impossible de négocier une session de lecture.');
const source = (info.MediaSources || [])[0];
if (!source?.Id || !info.PlaySessionId) return null;
return { mediaSourceId: source.Id, playSessionId: info.PlaySessionId };
} catch {
return null;
}
}
// --- Direct proxying (images/video/audio) - these need the raw
// Express req/res, unlike everything above which just returns JSON. ---
@ -548,7 +452,6 @@ class CatalogServiceClass {
return {
id: item.Id,
name: item.Name,
sortName: item.SortName || item.Name,
overview: item.Overview,
year: item.ProductionYear,
type: item.Type,

View file

@ -1,104 +0,0 @@
'use strict';
import { EmbyClient } from '../emby/EmbyClient';
import { Logger } from '../Logger';
/**
* Reports a party member's own playback to Emby's real Sessions API
* (`POST /Sessions/Playing`, `/Sessions/Playing/Progress`,
* `/Sessions/Playing/Stopped`) - the piece of session lifecycle
* docs/architecture.md's "Sync watch-party progress back to Emby"
* section deliberately deferred at the time ("wiring clean start/stop
* into every transition... is real complexity for a secondary payoff").
* Asked for directly since: without this, this app's own streams never
* showed up in Emby's admin "now playing" dashboard the way a real web/
* mobile/TV client's would - only the resume-position/watched-history
* UserData (PlaystateService) was ever visible anywhere, and only by
* opening the item itself, not from the dashboard's live session list.
*
* One real Emby session per PARTY MEMBER, not one per party - a
* faithful copy of how a real client behaves (every device/account gets
* its own), and the actual point of the request ("let admin see who's
* watching what") would be lost by collapsing every member onto one
* shared entry. Every call needs the member's own `embyToken` (the same
* source every other per-member Emby write in this app already uses -
* see `PartyMember.embyToken`/`PlaystateService`) - Emby's Sessions API
* is inherently per-account, there's no equivalent of "the party's" own
* session to report against instead.
*
* `MediaSourceId`/`PlaySessionId` (see `CatalogService
* .negotiatePlaybackSession`) are required in practice, not just
* present in the schema - confirmed by reading the real server's own
* `/openapi.json` rather than assumed, after this exact neighboring
* area (`PlaystateService`'s own history) already got bitten twice by
* an Emby endpoint accepting a request shape that looked complete but
* wasn't. `index.ts`'s periodic sweep is what actually drives the
* start/progress/stop transitions - see its own comment for why that
* one place could cover this instead of scattering hooks across every
* queue-advance/track-skip call site the way `PlaystateService
* .markPlayed` needed to.
*
* Best-effort throughout, exactly like `PlaystateService`: any failure
* is caught and logged here, never thrown - none of this may ever
* disrupt real playback over an admin-dashboard nicety.
*/
class EmbySessionServiceClass {
/** Called once per member when they start watching/listening to
* `itemId` - a fresh join, or the party moving to a new item while
* they're already a member (see index.ts's sweep, which detects that
* by comparing against `PartyMember.embySession.itemId`). */
async start(embyToken: string, itemId: string, mediaSourceId: string, playSessionId: string, positionTicks: number, isPaused: boolean): Promise<void> {
await this.report('/Sessions/Playing', embyToken, {
ItemId: itemId,
MediaSourceId: mediaSourceId,
PlaySessionId: playSessionId,
PositionTicks: Math.round(positionTicks),
IsPaused: isPaused,
PlayMethod: 'Transcode',
CanSeek: true
});
}
/** Called periodically (~20s, alongside `PlaystateService
* .updatePosition` - see index.ts's sweep) for every member with a
* live session already open for their current item. */
async progress(embyToken: string, itemId: string, mediaSourceId: string, playSessionId: string, positionTicks: number, isPaused: boolean): Promise<void> {
await this.report('/Sessions/Playing/Progress', embyToken, {
ItemId: itemId,
MediaSourceId: mediaSourceId,
PlaySessionId: playSessionId,
PositionTicks: Math.round(positionTicks),
IsPaused: isPaused,
PlayMethod: 'Transcode'
});
}
/** Called once per member when they stop watching/listening to
* `itemId` - leaving the party (`PartyService.leave`), the item
* changing (index.ts's sweep), or the party being destroyed for
* inactivity (`PartyService.removeIdleParties`). */
async stop(embyToken: string, itemId: string, mediaSourceId: string, playSessionId: string, positionTicks: number): Promise<void> {
await this.report('/Sessions/Playing/Stopped', embyToken, {
ItemId: itemId,
MediaSourceId: mediaSourceId,
PlaySessionId: playSessionId,
PositionTicks: Math.round(positionTicks)
});
}
/** Every call logs its own outgoing request + outcome - see
* Logger.playstate's own comment for why this needs to be enough on
* its own, greppable, to answer "is this actually reaching Emby, and
* with what" for this service too, not just PlaystateService. */
private async report(path: string, embyToken: string, body: Record<string, unknown>): Promise<void> {
Logger.playstate(`-> POST ${path} itemId=${body.ItemId}`);
try {
const res = await EmbyClient.fetch(path, embyToken, { method: 'POST', body });
Logger.playstate(`<- ${res.status} ${res.ok ? 'ok' : 'FAILED'} ${path} itemId=${body.ItemId}`);
} catch (err) {
Logger.playstate(`<- threw ${path} itemId=${body.ItemId}: ${(err as Error).message}`);
}
}
}
export const EmbySessionService = new EmbySessionServiceClass();

View file

@ -52,42 +52,7 @@ class MusicQueueServiceClass {
addTrack(party: Party, track: MusicTrackInput, addedByUserId: string, addedByUserName: string): void {
const music = party.music!;
const entry = this.buildTrackEntry(track, addedByUserId, addedByUserName);
music.queue.push(entry);
party.lastActivityUtc = Date.now();
Logger.party(`musicQueueAdd partyId=${party.id} user=${Logger.who(addedByUserId, addedByUserName)} track="${entry.itemName}" queueLength=${music.queue.length}`);
if (!music.current) {
this.playNext(party); // queue was empty - this track starts immediately
}
this.broadcast(party);
}
/**
* Same per-track behavior as addTrack (auto-starts if the queue was
* empty when the first of these lands), but ONE broadcast at the end
* instead of one per track - asked for directly, to queue a whole
* album or an artist's entire discography in one action rather than
* one track at a time. Without this, a hundred-track discography
* would spam the party with a hundred intermediate "queueUpdated"
* events for what's really a single user action - see index.ts's
* createMusicPartyFromTracks/the "queue" route, the two callers.
*/
addTracks(party: Party, tracks: MusicTrackInput[], addedByUserId: string, addedByUserName: string): void {
if (!tracks.length) return;
const music = party.music!;
for (const track of tracks) {
music.queue.push(this.buildTrackEntry(track, addedByUserId, addedByUserName));
if (!music.current) {
this.playNext(party);
}
}
party.lastActivityUtc = Date.now();
Logger.party(`musicQueueAddBatch partyId=${party.id} user=${Logger.who(addedByUserId, addedByUserName)} count=${tracks.length} queueLength=${music.queue.length}`);
this.broadcast(party);
}
private buildTrackEntry(track: MusicTrackInput, addedByUserId: string, addedByUserName: string): MusicTrack {
return {
const entry: MusicTrack = {
itemId: track.itemId,
itemName: track.itemName,
artistName: track.artistName || null,
@ -99,6 +64,13 @@ class MusicQueueServiceClass {
addedByUserId,
addedByUserName
};
music.queue.push(entry);
party.lastActivityUtc = Date.now();
Logger.party(`musicQueueAdd partyId=${party.id} user=${Logger.who(addedByUserId, addedByUserName)} track="${entry.itemName}" queueLength=${music.queue.length}`);
if (!music.current) {
this.playNext(party); // queue was empty - this track starts immediately
}
this.broadcast(party);
}
/** No host check - anyone can toggle this (see class comment). */
@ -120,41 +92,6 @@ class MusicQueueServiceClass {
this.broadcast(party);
}
/**
* No host check - anyone can do this (see class comment): a seek is
* just a continuous version of pause/resume, reversible and not a
* change to what's playing - not the majority-vote model reserved for
* actually skipping to different content. Clamped into
* [0, durationSeconds] when the duration is known (an unknown one -
* never actually seen in practice, see MusicTrack - just trusts the
* caller's own non-negative value, same as the video player's seek
* has no server-side duration to clamp against either).
*/
seek(party: Party, positionSeconds: number): void {
const current = party.music?.current;
if (!current) return;
const duration = current.track.durationSeconds;
const clampedSeconds = Math.max(0, duration != null ? Math.min(positionSeconds, duration) : positionSeconds);
const elapsedMs = clampedSeconds * 1000;
if (current.isPaused) {
current.pausedElapsedMs = elapsedMs;
} else {
// Must clear the existing timer first, not just overwrite it -
// scheduleAdvance only ever sets advanceTimer, it never clears a
// prior one itself (setPaused's own resume branch gets away with
// skipping this only because pausing already cleared it - see its
// own "if (isPaused)" branch). Skipping this here would leave the
// OLD, now-wrong-duration timer running alongside the new one,
// eventually firing playNext at the wrong time.
this.clearTimer(party);
current.startedUtc = Date.now() - elapsedMs;
this.scheduleAdvance(party);
}
party.lastActivityUtc = Date.now();
Logger.party(`musicSeek partyId=${party.id} -> ${clampedSeconds}s`);
this.broadcast(party);
}
/**
* Registers `userId`'s vote to skip the current track - a strict
* majority of `connectedMemberCount` (computed by the caller, which has

View file

@ -7,8 +7,6 @@ import { TranscodeService } from './TranscodeService';
import { PartyStreamService } from './PartyStreamService';
import { PlaystateService } from './PlaystateService';
import { CastTokenService } from './CastTokenService';
import { EmbySessionService } from './EmbySessionService';
import { StaticPartyService } from './StaticPartyService';
import type { LeaveResult, MediaType, Party, PartyMember, PartyPublicState, QueueItem } from '../types';
/**
@ -145,30 +143,6 @@ class PartyServiceClass {
return { itemId: party.itemId, positionTicks: host.positionTicks, isPaused: host.isPaused };
}
/**
* Whether a request claiming to be `deviceId` is stale for `member` -
* already replaced by a takeover on a *different* device (see
* SocketGateway's "join" handler). Used by the REST /leave route
* (index.ts), which - unlike the socket "leave"/"disconnect" handlers,
* guarded by comparing `member.connection` against the live socket
* itself - has no connection to compare against instead. Without this,
* a device already superseded by a takeover elsewhere could still call
* /leave and end the CURRENT device's membership by mistake - confirmed
* as the real cause of a "both devices end up on the session, controls
* never transfer" report: the stale device's own client didn't react to
* being disconnected at all (see Player.ts/MusicBar.ts's onClose), so
* its still-rendered "Quitter" button stayed clickable and reached here.
*
* Only ever returns true on positive evidence of a mismatch (both sides
* known and different) - a member with no recorded deviceId yet, or a
* caller that didn't send one, is never treated as stale, mirroring the
* socket handlers' own "nothing to conflict with yet" reasoning for a
* null deviceId.
*/
isStaleDevice(member: PartyMember | undefined, deviceId: string | null): boolean {
return !!(member?.deviceId && deviceId && member.deviceId !== deviceId);
}
/**
* Removes a member. If they were the host, promotes whoever's left
* (arbitrary but deterministic-ish - good enough for v1); if nobody's
@ -183,15 +157,6 @@ class PartyServiceClass {
party.lastActivityUtc = Date.now();
MusicQueueService.removeVote(party, userId); // no-op for a video party (party.music is unset)
CastTokenService.revokeForMember(party.id, userId); // no-op if they never cast - see its own comment for why this runs unconditionally, not just on an explicit "stop casting"
// No-op if they never had a live Emby Sessions-API entry (never
// connected, or the periodic sweep hadn't reached them yet) - see
// EmbySessionService's own comment. Fire-and-forget, same as every
// other per-member Emby report in this app - it already swallows its
// own failures, and leaving the party must never wait on Emby.
if (leavingMember?.embyToken && leavingMember.embySession) {
const s = leavingMember.embySession;
EmbySessionService.stop(leavingMember.embyToken, s.itemId, s.mediaSourceId, s.playSessionId, leavingMember.positionTicks);
}
let newHostUserId: string | null = party.hostUserId;
if (wasHost) {
@ -210,7 +175,6 @@ class PartyServiceClass {
MusicQueueService.destroy(party); // clears the auto-advance timer, if any - see its own comment
TranscodeService.endSession(party.id); // no-op if this party never had a burn-in stream running
PartyStreamService.endSession(party.id); // same - no-op if this party never negotiated a shared stream
if (party.staticLink) StaticPartyService.onPartyDestroyed(party.staticLink, party.id);
Logger.party(`destroyed partyId=${party.id} (last member ${whoLeft} left)`);
} else if (wasHost && newHostUserId != null) {
const newHost = party.members.get(newHostUserId)!;
@ -232,9 +196,7 @@ class PartyServiceClass {
if (party.queueIndex >= party.queue.length - 1) {
return false;
}
// Whatever was just playing is "done" - before the party's own state
// moves on. See completeCurrentItem's own comment.
this.completeCurrentItem(party);
const outgoingItemId = party.itemId; // whatever was just playing - see the markPlayed loop below
party.queueIndex += 1;
const next = party.queue[party.queueIndex]!;
party.itemId = next.itemId;
@ -245,6 +207,18 @@ class PartyServiceClass {
member.isPaused = false;
member.lastUpdatedUtc = Date.now();
}
// The party moving on from `outgoingItemId` is this app's own signal
// that it's "done" - see PlaystateService.markPlayed's own comment
// for why that's a heuristic, not a real "watched it all" check, and
// why every currently-connected member gets their own account
// updated, not just the host. Fire-and-forget, same as the periodic
// position sync in index.ts - a member's bad token or a transient
// Emby hiccup here must never block the party from actually moving
// on to the next item.
for (const member of party.members.values()) {
if (!member.connection || !member.embyToken) continue;
PlaystateService.markPlayed(member.embyToken, member.userId, outgoingItemId);
}
// A new item's own MediaStreams indices have nothing to do with the
// old one's - carrying a burn-in or text subtitle pick across would
// show whatever track happens to share that index on the new item,
@ -269,40 +243,6 @@ class PartyServiceClass {
return true;
}
/**
* Marks the party's current item as "done": the party moving on from it
* (advanceQueue) or the last queued item playing to its end (the host's
* "itemEnded" - see SocketGateway) is this app's own signal for that -
* see PlaystateService.markPlayed's own comment for why it's a
* heuristic, not a real "watched it all" check, and why every
* currently-connected member gets their own account updated, not just
* the host. Fire-and-forget, same as the periodic position sync in
* index.ts - a member's bad token or a transient Emby hiccup here must
* never block the party from actually moving on.
*
* For a session of a static watch party, also records the item as
* completed there (StaticPartyService.onItemCompleted) for every
* connected member. Idempotent per item (`lastCompletedItemId`), so an
* "itemEnded" and a later "next" on the same item never count twice.
*/
completeCurrentItem(party: Party): void {
const itemId = party.itemId;
if (party.lastCompletedItemId === itemId) return;
party.lastCompletedItemId = itemId;
const connected = Array.from(party.members.values()).filter((m) => !!m.connection);
for (const member of connected) {
if (!member.embyToken) continue;
PlaystateService.markPlayed(member.embyToken, member.userId, itemId);
}
if (party.staticLink) {
StaticPartyService.onItemCompleted(
party.staticLink,
itemId,
connected.map((m) => ({ userId: m.userId, userName: m.userName }))
);
}
}
/** All active parties - powers the "watch parties en cours" home section. */
listActive(): Party[] {
return Array.from(this.partiesById.values());
@ -316,18 +256,6 @@ class PartyServiceClass {
MusicQueueService.destroy(party); // same leak this class's own destroy path guards against
TranscodeService.endSession(party.id); // same leak - see the "leave" destroy path above
PartyStreamService.endSession(party.id);
if (party.staticLink) StaticPartyService.onPartyDestroyed(party.staticLink, party.id);
// Same reasoning, for any member whose own "leave" (a clean
// disconnect) never fired for whatever reason - a real gap would
// otherwise leave a stale entry sitting in Emby's own dashboard
// indefinitely, not just until the next sweep tick the way a
// still-connected member's stale Progress report would.
for (const member of party.members.values()) {
if (member.embyToken && member.embySession) {
const s = member.embySession;
EmbySessionService.stop(member.embyToken, s.itemId, s.mediaSourceId, s.playSessionId, member.positionTicks);
}
}
const idleMinutes = Math.round((Date.now() - party.lastActivityUtc) / 60000);
Logger.party(`destroyed partyId=${party.id} (idle ${idleMinutes}min, ${party.members.size} member(s) still connected)`);
}
@ -383,8 +311,7 @@ class PartyServiceClass {
embyToken: null,
isPaused: true,
positionTicks: 0,
lastUpdatedUtc: Date.now(),
embySession: null
lastUpdatedUtc: Date.now()
};
}
}

View file

@ -1,319 +0,0 @@
'use strict';
import crypto from 'crypto';
import { Logger } from '../Logger';
import { StaticPartyStore } from './StaticPartyStore';
import type {
PersistedStaticParty,
QueueItem,
StaticParty,
StaticPartyDetailDto,
StaticPartyLink,
StaticPartyMember,
StaticPartySummaryDto
} from '../types';
/** A connected participant, as reported by a live session. */
export interface Participant {
userId: string;
userName: string;
}
/**
* Which of `items` the viewer has missed, in the items' own order: every
* item the group already completed, minus what the viewer watched in-app
* (`watchedItemIds`) and minus what Emby itself already has as Played for
* them (`embyPlayedIds` - watched outside this app, or before this party
* existed). Pure - the one rule this whole feature is about, tested on
* its own (see staticPartyService.test.ts).
*/
export function computeMissedIds(
items: QueueItem[],
completedItemIds: string[],
watchedItemIds: string[],
embyPlayedIds: ReadonlySet<string>
): string[] {
const completed = new Set(completedItemIds);
const watched = new Set(watchedItemIds);
return items
.map((i) => i.itemId)
.filter((id) => completed.has(id) && !watched.has(id) && !embyPlayedIds.has(id));
}
/**
* Merges a freshly fetched item list into a static party's existing one:
* keeps the existing order and every existing item (even one since
* removed from the source - the group's history shouldn't lose it),
* refreshes names/images, and appends anything new (a new episode, a
* playlist addition) at the end in the source's own order. Returns the
* merged list and how many items were added. Pure.
*/
export function mergeItemLists(existing: QueueItem[], fresh: QueueItem[]): { items: QueueItem[]; added: number } {
const freshById = new Map(fresh.map((i) => [i.itemId, i]));
const known = new Set(existing.map((i) => i.itemId));
const items = existing.map((i) => freshById.get(i.itemId) ?? i);
const additions = fresh.filter((i) => !known.has(i.itemId));
return { items: items.concat(additions), added: additions.length };
}
/**
* Static (persistent) watch parties - a whole series or video playlist a
* group watches together over several sessions. Remembers the group's own
* progress (which items it completed, where it stopped) and each member's
* own (which items they watched), so someone coming back or joining late
* sees exactly what they missed. See spec/static-watch-party.md.
*
* A static party never plays anything itself: each session is an
* ordinary live Party (PartyService), created by index.ts and linked back
* here through `party.staticLink` - so the player, sync, shared stream,
* lobby and host handoff are all reused as-is. This service never imports
* PartyService (PartyService calls into this one), keeping the dependency
* one-way.
*
* Persisted through StaticPartyStore after every change - one file per
* party, so only the party that changed is ever rewritten.
*/
class StaticPartyServiceClass {
/** Every static party, by id. */
private partiesById = new Map<string, StaticParty>();
/** Loads every persisted party - once, at startup. */
public init(): void {
this.partiesById.clear();
for (const persisted of StaticPartyStore.load()) {
this.partiesById.set(persisted.id, { ...persisted, livePartyId: null });
}
}
/** Every static party, most recently active first. */
public list(): StaticParty[] {
return Array.from(this.partiesById.values()).sort((a, b) => this.lastActivity(b) - this.lastActivity(a));
}
/** One static party, or null. */
public getById(id: string): StaticParty | null {
return this.partiesById.get(id) || null;
}
/** Creates a static party over `items` - the creator is its first member. */
public create(
creator: Participant,
source: StaticParty['source'],
name: string,
imageUrl: string,
items: QueueItem[]
): StaticParty {
const now = Date.now();
const sp: StaticParty = {
id: crypto.randomUUID(),
name,
source,
imageUrl,
createdByUserId: creator.userId,
createdUtc: now,
items,
group: { nextIndex: 0, resumePositionTicks: 0, completedItemIds: [], lastSessionUtc: null },
members: {},
livePartyId: null
};
sp.members[creator.userId] = this.newMember(creator);
this.partiesById.set(sp.id, sp);
Logger.staticParty(`created id=${sp.id} name="${name}" source=${source.type}:${source.sourceId} items=${items.length} by=${Logger.who(creator.userId, creator.userName)}`);
this.persist(sp);
return sp;
}
/** Adds `who` as a member - idempotent (refreshes their name only). */
public join(sp: StaticParty, who: Participant): StaticPartyMember {
const existing = sp.members[who.userId];
if (existing) {
existing.userName = who.userName;
existing.lastSeenUtc = Date.now();
this.persist(sp);
return existing;
}
const member = this.newMember(who);
sp.members[who.userId] = member;
Logger.staticParty(`joined id=${sp.id} user=${Logger.who(who.userId, who.userName)} members=${Object.keys(sp.members).length}`);
this.persist(sp);
return member;
}
/** Removes a member - and their progress. The party itself stays, even
* empty: only its creator deletes it (see delete()). */
public leave(sp: StaticParty, userId: string): void {
const member = sp.members[userId];
if (!member) return;
delete sp.members[userId];
Logger.staticParty(`left id=${sp.id} user=${Logger.who(userId, member.userName)} members=${Object.keys(sp.members).length}`);
this.persist(sp);
}
/** Deletes the party for good - creator only. Returns false (and does
* nothing) for anyone else. */
public delete(sp: StaticParty, userId: string): boolean {
if (sp.createdByUserId !== userId) {
Logger.staticParty(`delete refused id=${sp.id} user=${userId} (not the creator)`);
return false;
}
this.partiesById.delete(sp.id);
Logger.staticParty(`deleted id=${sp.id} name="${sp.name}"`);
StaticPartyStore.remove(sp.id);
return true;
}
/** Merges a fresh fetch of the source into `sp.items` (see
* mergeItemLists) - new episodes/playlist entries appear at the end. */
public refreshItems(sp: StaticParty, fresh: QueueItem[]): void {
if (!fresh.length) return;
const { items, added } = mergeItemLists(sp.items, fresh);
const changed = added > 0 || items.some((item, i) => item !== sp.items[i]);
sp.items = items;
if (added) Logger.staticParty(`refreshed id=${sp.id} +${added} new item(s), total=${items.length}`);
if (changed) this.persist(sp);
}
/** What the group's next session plays: everything from the group's
* next item on. Empty once the group has seen everything. */
public groupQueue(sp: StaticParty): QueueItem[] {
return sp.items.slice(sp.group.nextIndex);
}
/** Records which live party is the group's current session (or none). */
public setLiveParty(sp: StaticParty, partyId: string | null): void {
sp.livePartyId = partyId;
if (partyId) {
sp.group.lastSessionUtc = Date.now();
Logger.staticParty(`session started id=${sp.id} partyId=${partyId} at item ${sp.group.nextIndex + 1}/${sp.items.length}`);
this.persist(sp);
}
}
/**
* A linked live session finished `itemId` (played to its end, or the
* host moved past it - the same heuristic as PlaystateService.markPlayed).
* Every connected participant gets it as watched (and becomes a member
* if they joined the live session without joining the static party
* first). In `group` mode the group itself also moves past it; in
* `catchUp` mode only those participants' own progress changes.
*/
public onItemCompleted(link: StaticPartyLink, itemId: string, connected: Participant[]): void {
const sp = this.partiesById.get(link.staticPartyId);
if (!sp) return; // deleted while a session was still running
for (const who of connected) {
const member = sp.members[who.userId] ?? (sp.members[who.userId] = this.newMember(who));
member.lastSeenUtc = Date.now();
if (!member.watchedItemIds.includes(itemId)) member.watchedItemIds.push(itemId);
}
if (link.mode === 'group') {
if (!sp.group.completedItemIds.includes(itemId)) sp.group.completedItemIds.push(itemId);
const index = sp.items.findIndex((i) => i.itemId === itemId);
if (index >= 0 && index + 1 > sp.group.nextIndex) sp.group.nextIndex = index + 1;
sp.group.resumePositionTicks = 0;
}
Logger.staticParty(`item completed id=${sp.id} mode=${link.mode} item=${itemId} by=[${connected.map((c) => c.userName).join(', ')}] group=${sp.group.completedItemIds.length}/${sp.items.length}`);
this.persist(sp);
}
/** The group session's current position - saved so the next session
* resumes there. Ignored for anything but the group's next item. */
public recordPosition(link: StaticPartyLink, itemId: string, positionTicks: number): void {
if (link.mode !== 'group') return;
const sp = this.partiesById.get(link.staticPartyId);
if (!sp || sp.items[sp.group.nextIndex]?.itemId !== itemId) return;
if (sp.group.resumePositionTicks === positionTicks) return;
sp.group.resumePositionTicks = positionTicks;
this.persist(sp);
}
/** A linked live party ended - forget it as the group's live session. */
public onPartyDestroyed(link: StaticPartyLink, partyId: string): void {
const sp = this.partiesById.get(link.staticPartyId);
if (!sp || sp.livePartyId !== partyId) return;
sp.livePartyId = null;
Logger.staticParty(`session ended id=${sp.id} partyId=${partyId} resumeAt=item ${sp.group.nextIndex + 1} ${Math.round(sp.group.resumePositionTicks / 10000000)}s`);
this.persist(sp);
}
/** "Marquer comme vu" - `who` becomes a member if they weren't yet. */
public markWatched(sp: StaticParty, who: Participant, itemId: string): boolean {
if (!sp.items.some((i) => i.itemId === itemId)) return false;
const member = sp.members[who.userId] ?? (sp.members[who.userId] = this.newMember(who));
if (!member.watchedItemIds.includes(itemId)) member.watchedItemIds.push(itemId);
member.lastSeenUtc = Date.now();
Logger.staticParty(`marked watched id=${sp.id} user=${Logger.who(who.userId, who.userName)} item=${itemId}`);
this.persist(sp);
return true;
}
/** `viewerUserId`'s missed items - see computeMissedIds. */
public missedIds(sp: StaticParty, viewerUserId: string, embyPlayedIds: ReadonlySet<string>): string[] {
const watched = sp.members[viewerUserId]?.watchedItemIds ?? [];
return computeMissedIds(sp.items, sp.group.completedItemIds, watched, embyPlayedIds);
}
/** The list-view shape of `sp` for `viewerUserId`. `livePartyId` is
* passed in already checked against PartyService (still alive). */
public toSummary(sp: StaticParty, viewerUserId: string, embyPlayedIds: ReadonlySet<string>, livePartyId: string | null): StaticPartySummaryDto {
return {
id: sp.id,
name: sp.name,
sourceType: sp.source.type,
imageUrl: sp.imageUrl,
itemCount: sp.items.length,
groupWatchedCount: sp.group.completedItemIds.length,
nextItemName: sp.items[sp.group.nextIndex]?.itemName ?? null,
memberCount: Object.keys(sp.members).length,
youAreMember: !!sp.members[viewerUserId],
youAreCreator: sp.createdByUserId === viewerUserId,
missedCount: this.missedIds(sp, viewerUserId, embyPlayedIds).length,
livePartyId
};
}
/** The detail-view shape of `sp` for `viewerUserId`. */
public toDetail(sp: StaticParty, viewerUserId: string, embyPlayedIds: ReadonlySet<string>, livePartyId: string | null): StaticPartyDetailDto {
const completed = new Set(sp.group.completedItemIds);
const viewerWatched = new Set(sp.members[viewerUserId]?.watchedItemIds ?? []);
return {
...this.toSummary(sp, viewerUserId, embyPlayedIds, livePartyId),
items: sp.items.map((item, index) => ({
...item,
groupState: completed.has(item.itemId) ? 'watched' : index === sp.group.nextIndex ? 'next' : 'upcoming',
viewerWatched: viewerWatched.has(item.itemId) || embyPlayedIds.has(item.itemId)
})),
missedItemIds: this.missedIds(sp, viewerUserId, embyPlayedIds),
members: Object.values(sp.members).map((m) => ({
userId: m.userId,
userName: m.userName,
watchedCount: m.watchedItemIds.length,
isCreator: m.userId === sp.createdByUserId
}))
};
}
/** Writes `sp`'s own file (debounced - see StaticPartyStore). Only the
* party that changed is ever rewritten. */
private persist(sp: StaticParty): void {
StaticPartyStore.save(sp.id, () => this.snapshotOf(sp));
}
/** `sp` minus runtime-only state. */
private snapshotOf(sp: StaticParty): PersistedStaticParty {
const { livePartyId: _live, ...persisted } = sp;
return persisted;
}
/** When `sp` last saw anything happen - for list ordering. */
private lastActivity(sp: StaticParty): number {
return Math.max(sp.createdUtc, sp.group.lastSessionUtc ?? 0);
}
/** A fresh member record for `who`. */
private newMember(who: Participant): StaticPartyMember {
const now = Date.now();
return { userId: who.userId, userName: who.userName, joinedUtc: now, watchedItemIds: [], lastSeenUtc: now };
}
}
export const StaticPartyService = new StaticPartyServiceClass();

View file

@ -1,272 +0,0 @@
'use strict';
import fs from 'fs';
import path from 'path';
import { Logger } from '../Logger';
import type { PersistedStaticParty } from '../types';
/** One party's own file - versioned so a future format change can migrate
* instead of silently misreading an older file. */
interface StaticPartyFile {
version: 1;
party: PersistedStaticParty;
}
/** The first release's single-file format - only read once, to migrate. */
interface LegacyStaticPartiesFile {
version: 1;
parties: PersistedStaticParty[];
}
/** How long a save waits for more changes to the same party before
* writing - progress updates arrive in bursts (a whole party's members
* at once, the 20s position sweep). */
const SAVE_DEBOUNCE_MS = 2000;
/** Party ids become file names - anything else is refused outright, so a
* crafted id can never reach outside the directory. */
const SAFE_ID = /^[A-Za-z0-9-]+$/;
/**
* The static watch parties' persistence: **one JSON file per party**,
* `${DATA_DIR}/static-parties/<id>.json` - the first and only thing this
* app keeps on disk (everything else is deliberately in-memory - see
* docs/architecture.md's "Party lifecycle").
*
* One file per party (rather than a single file for all of them) limits
* what any one problem can damage: a write only ever touches the party
* that changed, and a corrupt file costs that one party, never the rest.
* Each write is also atomic (a `.tmp` sibling, then a rename over the
* real file), so a crash mid-write leaves the previous version intact.
*
* Writes are debounced per party (SAVE_DEBOUNCE_MS). A corrupt file is
* logged and moved aside (`.corrupt`) rather than overwritten or
* silently dropped - the server must never refuse to start over one, and
* nothing is lost for good. The first release's single
* `static-parties.json` is migrated once, on the first load that finds it.
*
* JSON files rather than a database: the data is tiny, the runtime image
* is Node 20 (no built-in `node:sqlite`), and a native SQLite binding would
* be the image's first compiled dependency - not worth it at this size.
*/
class StaticPartyStoreClass {
/** `${DATA_DIR}/static-parties` - see configure(). */
private dir: string | null = null;
/** The first release's single file, migrated on load if still present. */
private legacyFile: string | null = null;
/** Pending debounced saves, by party id. */
private timers = new Map<string, NodeJS.Timeout>();
/** What each pending save will write - always the latest snapshot. */
private pending = new Map<string, () => PersistedStaticParty>();
/** Points the store at `dataDir`. Must be called before anything else -
* index.ts does it at startup, tests with a temp directory. */
public configure(dataDir: string): void {
this.dir = path.resolve(dataDir, 'static-parties');
this.legacyFile = path.resolve(dataDir, 'static-parties.json');
}
/** Reads every persisted party - synchronous, startup-only. */
public load(): PersistedStaticParty[] {
const dir = this.requireDir();
try {
fs.mkdirSync(dir, { recursive: true });
} catch (err) {
Logger.staticParty(`could not create ${dir} - starting empty: ${(err as Error).message}`);
return [];
}
this.migrateLegacyFile();
let names: string[];
try {
names = fs.readdirSync(dir);
} catch (err) {
Logger.staticParty(`could not list ${dir} - starting empty: ${(err as Error).message}`);
return [];
}
const parties: PersistedStaticParty[] = [];
for (const name of names) {
const file = path.join(dir, name);
if (name.endsWith('.tmp')) {
// A write interrupted before its rename - the real file next to it
// (if any) is still the last complete version.
this.tryUnlink(file);
continue;
}
if (!name.endsWith('.json')) continue; // .corrupt files and anything else
const party = this.readPartyFile(file);
if (party) parties.push(party);
}
Logger.staticParty(`loaded ${parties.length} static party(ies) from ${dir}`);
return parties;
}
/** Schedules a debounced write of `id`'s own file, from whatever
* `snapshot` returns at write time (so a burst of changes only ever
* writes the final state). */
public save(id: string, snapshot: () => PersistedStaticParty): void {
if (!SAFE_ID.test(id)) {
Logger.staticParty(`save refused: unsafe id "${id}"`);
return;
}
this.pending.set(id, snapshot);
if (this.timers.has(id)) return;
const timer = setTimeout(() => {
this.timers.delete(id);
this.writeOne(id).catch((err: unknown) => {
Logger.staticParty(`save failed id=${id}: ${(err as Error).message}`);
});
}, SAVE_DEBOUNCE_MS);
timer.unref(); // a pending save must never keep a test process (or a shutdown) alive
this.timers.set(id, timer);
}
/** Deletes `id`'s file - and cancels any save still pending for it, so
* a debounced write can't recreate a party that was just deleted. */
public remove(id: string): void {
const timer = this.timers.get(id);
if (timer) clearTimeout(timer);
this.timers.delete(id);
this.pending.delete(id);
if (!SAFE_ID.test(id)) return;
const file = this.fileFor(id);
try {
fs.unlinkSync(file);
Logger.staticParty(`removed ${file}`);
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
Logger.staticParty(`could not remove ${file}: ${(err as Error).message}`);
}
}
}
/** Writes every pending save immediately and synchronously - for a
* graceful shutdown (SIGTERM/SIGINT), where an async write could be cut
* short by the process exiting. */
public flushSync(): void {
for (const timer of this.timers.values()) clearTimeout(timer);
this.timers.clear();
const pending = Array.from(this.pending.entries());
this.pending.clear();
for (const [id, snapshot] of pending) {
const file = this.fileFor(id);
try {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(`${file}.tmp`, this.serialize(snapshot()), 'utf8');
fs.renameSync(`${file}.tmp`, file);
} catch (err) {
Logger.staticParty(`flush failed id=${id}: ${(err as Error).message}`);
}
}
if (pending.length) Logger.staticParty(`flushed ${pending.length} static party(ies)`);
}
/** Writes every pending save now (async) - lets tests wait for disk. */
public async writeNow(): Promise<void> {
for (const timer of this.timers.values()) clearTimeout(timer);
this.timers.clear();
const ids = Array.from(this.pending.keys());
try {
await Promise.all(ids.map((id) => this.writeOne(id)));
} catch (err) {
Logger.staticParty(`writeNow failed: ${(err as Error).message}`);
throw err;
}
}
/** Writes `id`'s pending snapshot - atomically, via a `.tmp` rename. */
private async writeOne(id: string): Promise<void> {
const snapshot = this.pending.get(id);
if (!snapshot) return;
this.pending.delete(id);
const file = this.fileFor(id);
try {
await fs.promises.mkdir(path.dirname(file), { recursive: true });
await fs.promises.writeFile(`${file}.tmp`, this.serialize(snapshot()), 'utf8');
await fs.promises.rename(`${file}.tmp`, file);
} catch (err) {
Logger.staticParty(`write to ${file} failed: ${(err as Error).message}`);
throw err;
}
}
/** One party file's content, or null (moved aside) if it's unreadable. */
private readPartyFile(file: string): PersistedStaticParty | null {
try {
const parsed = JSON.parse(fs.readFileSync(file, 'utf8')) as Partial<StaticPartyFile>;
const party = parsed?.party;
if (!party || typeof party.id !== 'string' || !Array.isArray(party.items)) {
throw new Error('missing or malformed "party"');
}
if (path.basename(file) !== `${party.id}.json`) throw new Error(`file name does not match id ${party.id}`);
return party;
} catch (err) {
Logger.staticParty(`corrupt file ${file} - moved aside to .corrupt, skipped: ${(err as Error).message}`);
try {
fs.renameSync(file, `${file}.corrupt`);
} catch (renameErr) {
Logger.staticParty(`could not move corrupt file aside: ${(renameErr as Error).message}`);
}
return null;
}
}
/**
* The first release kept every party in one `static-parties.json` -
* split it into one file per party, then rename it to `.migrated` (kept,
* not deleted, in case anything went wrong). A party that already has
* its own file is left alone: that file is newer.
*/
private migrateLegacyFile(): void {
const legacy = this.legacyFile!;
if (!fs.existsSync(legacy)) return;
try {
const parsed = JSON.parse(fs.readFileSync(legacy, 'utf8')) as Partial<LegacyStaticPartiesFile>;
const parties = Array.isArray(parsed?.parties) ? parsed.parties : [];
let migrated = 0;
for (const party of parties) {
if (!party || typeof party.id !== 'string' || !SAFE_ID.test(party.id)) continue;
const file = this.fileFor(party.id);
if (fs.existsSync(file)) continue;
fs.writeFileSync(`${file}.tmp`, this.serialize(party), 'utf8');
fs.renameSync(`${file}.tmp`, file);
migrated++;
}
fs.renameSync(legacy, `${legacy}.migrated`);
Logger.staticParty(`migrated ${migrated} static party(ies) from ${legacy} to one file each (original kept as .migrated)`);
} catch (err) {
Logger.staticParty(`could not migrate ${legacy} - left in place: ${(err as Error).message}`);
}
}
/** `id`'s own file path. */
private fileFor(id: string): string {
return path.join(this.requireDir(), `${id}.json`);
}
/** A party file's JSON text. */
private serialize(party: PersistedStaticParty): string {
const data: StaticPartyFile = { version: 1, party };
return JSON.stringify(data, null, 2);
}
/** Best-effort delete - logs anything but "already gone". */
private tryUnlink(file: string): void {
try {
fs.unlinkSync(file);
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
Logger.staticParty(`could not remove ${file}: ${(err as Error).message}`);
}
}
}
/** The configured directory - throws if configure() was never called. */
private requireDir(): string {
if (!this.dir) throw new Error('StaticPartyStore.configure() was never called');
return this.dir;
}
}
export const StaticPartyStore = new StaticPartyStoreClass();

View file

@ -29,18 +29,8 @@ export interface ClientToServerEvents {
join: (msg: { partyId: string; deviceId: string; force?: boolean }) => void;
playstate: (msg: { isPaused: boolean; positionTicks: number }) => void;
next: () => void;
/** Host-only (checked server-side exactly like "next") - the last
* queued item played to its end, so there's no "next" to send. Lets
* the server count that final item as completed (PartyService
* .completeCurrentItem) without advancing anything. */
itemEnded: () => void;
voteSkip: () => void;
musicPlaystate: (msg: { isPaused: boolean }) => void;
/** No host check, same as "musicPlaystate" - see MusicQueueService's own
* class comment on why a seek gets the same permissive treatment as
* pause/resume (reversible, doesn't change what's playing) rather than
* "voteSkip"'s majority-vote gate (which does). */
musicSeek: (msg: { positionSeconds: number }) => void;
requestPause: () => void;
requestResume: () => void;
/** Host-only (checked server-side exactly like "next") - picks which
@ -162,19 +152,11 @@ declare global {
export interface EmbyItem {
Id: string;
Name: string;
/** Emby's own sort key - articles ("The", "Le", "L'", ...) stripped,
* diacritics/case normalized its own way. Only present when a caller's
* `Fields` actually asks for it (getLibraryItems/getArtists do, for
* the library alphabet index - see ItemDto.sortName and
* CatalogService.toItemDto). */
SortName?: string;
Overview?: string;
ProductionYear?: number;
Type?: string;
SeriesId?: string;
SeriesName?: string;
/** An episode's own season - see CatalogService.seasonQueue. */
SeasonId?: string;
ParentIndexNumber?: number;
IndexNumber?: number;
RunTimeTicks?: number;
@ -187,11 +169,6 @@ export interface EmbyItem {
* one sampled 500'd), only its album does - this is how an image URL
* falls back to the album's art instead. */
AlbumId?: string;
/** The requesting user's own per-item state - Emby embeds it on every
* item fetched with a userId, unasked (see PlaystateService's own
* comment). Only `Played` is read, by the static watch party's
* "missed items" computation (StaticPartyService). */
UserData?: { Played?: boolean };
}
/** This app's own DTO shape - what toItemDto() in CatalogService produces,
@ -201,16 +178,6 @@ export interface EmbyItem {
export interface ItemDto {
id: string;
name: string;
/** Emby's own SortName when the caller requested it, the display name
* otherwise (CatalogService.toItemDto always falls back) - same
* "populated where it matters, harmless default elsewhere" shape as
* durationTicks/indexNumber below. Only client/src/alphaIndex.ts reads
* this today, to bucket a library by letter the same way Emby itself
* already ordered it: Emby's SortName strips leading articles (e.g.
* "Le Voyage" sorts as "Voyage"), so bucketing by the raw display name
* instead put a "L" header in the middle of a "V" run - reported
* directly as headers landing in no sensible order at all. */
sortName: string;
overview?: string;
year?: number;
type?: string;
@ -252,25 +219,6 @@ export interface PlaybackOptionsDto {
subtitleTracks: PlaybackTrackOption[];
}
/**
* Emby's own native intro/credits detection (a library's own
* EnableMarkerDetection option, run by its "Detect Episode Intros"
* scheduled task - confirmed directly against a real server's
* /openapi.json: MarkerType is one of Chapter/IntroStart/IntroEnd/
* CreditsStart, mixed into the same Chapters array as the auto-generated
* per-5-minutes ones). Every field is nullable, independently - the task
* may not have reached this item yet, the library may not have the
* feature enabled at all (it's per-library, not global), or a real
* cold-open episode may genuinely have no IntroStart/IntroEnd. None of
* that is an error - see CatalogService.getPlaybackMarkers - a caller
* just checks each field for null before offering the matching skip
* button. */
export interface PlaybackMarkersDto {
introStartTicks: number | null;
introEndTicks: number | null;
creditsStartTicks: number | null;
}
// --- Party (video) -----------------------------------------------------------
/** One entry in a video party's fixed, pre-built queue (a movie/standalone
@ -307,22 +255,6 @@ export interface PartyMember {
lastUpdatedUtc: number;
lastPauseRequestUtc?: number;
lastResumeRequestUtc?: number;
/** This member's own live Emby Sessions-API entry, if one's currently
* open - see EmbySessionService. Null whenever they don't have one
* (not connected/no token yet, or the periodic sweep hasn't started
* one for their current item yet). `itemId` is what the sweep compares
* against on each tick to notice an item change (stop the old
* session, start a fresh one) - `mediaSourceId`/`playSessionId` are
* cached here specifically so a Progress report can reuse them
* without renegotiating PlaybackInfo on every single tick. */
embySession: EmbyPlaybackSession | null;
}
/** See PartyMember.embySession's own comment. */
export interface EmbyPlaybackSession {
itemId: string;
mediaSourceId: string;
playSessionId: string;
}
/** The full internal party record - never sent to the client as-is, see
@ -371,25 +303,6 @@ export interface Party {
* MusicBar.ts never had this picker in the first place. */
streamAudioIndex: string | null;
streamQuality: string | null;
/** Set only for a live session launched from a static watch party (see
* StaticPartyService) - absent for every ordinary party. */
staticLink?: StaticPartyLink;
/** Kept out of the home page's live lobby list (SocketGateway
* .lobbySnapshot) - a solo catch-up session of a static watch party. */
hiddenFromLobby?: boolean;
/** The last item this party already reported as completed (see
* PartyService.completeCurrentItem) - guards against an "itemEnded"
* followed by a "next" on the same item counting it twice. */
lastCompletedItemId?: string | null;
}
/** How a live party relates to the static watch party it was launched
* from - `group` is the group's own session (advances the group's
* progress), `catchUp` is someone catching up on items they missed
* (only marks those items watched for whoever was connected). */
export interface StaticPartyLink {
staticPartyId: string;
mode: 'group' | 'catchUp';
}
export interface PublicMember {
@ -446,92 +359,6 @@ export interface LobbyPartyDto {
positionTicks: number | null;
isPaused: boolean | null;
positionUpdatedUtc: number | null;
/** The static watch party this live session belongs to, if any - shown
* as a badge on the home page's lobby card. */
staticPartyId: string | null;
staticPartyName: string | null;
}
// --- Static (persistent) watch party ------------------------------------------
/** A lounge party's source: a whole series, one season of it, or a
* video playlist. */
export type StaticPartySourceType = 'series' | 'season' | 'playlist';
/** One member of a static watch party - persisted, independent of any
* live connection (unlike PartyMember). */
export interface StaticPartyMember {
userId: string;
userName: string;
joinedUtc: number;
/** Items this member watched in-app: with the group, in a catch-up
* session, or marked watched by hand. Emby's own Played flag is
* combined with this at read time, never copied into it. */
watchedItemIds: string[];
lastSeenUtc: number;
}
/** The group's own progress through a static watch party's items. */
export interface StaticPartyGroupProgress {
/** Index into `items` of the next item the group will watch. */
nextIndex: number;
/** Where the group stopped inside items[nextIndex] - applied as the
* host's starting position on the next session. */
resumePositionTicks: number;
completedItemIds: string[];
lastSessionUtc: number | null;
}
/** A persistent watch party over a whole series or video playlist - see
* StaticPartyService and spec/static-watch-party.md. */
export interface StaticParty {
id: string;
name: string;
/** What the items come from. A `season` also records its series
* (`seriesId`) and number (`seasonNumber`) - episodes are fetched per
* series, then filtered (see CatalogService.seasonQueue). */
source: { type: StaticPartySourceType; sourceId: string; seriesId?: string; seasonNumber?: number };
imageUrl: string;
createdByUserId: string;
createdUtc: number;
items: QueueItem[];
group: StaticPartyGroupProgress;
members: Record<string, StaticPartyMember>;
/** Runtime only (never persisted) - the live group session, if any. */
livePartyId: string | null;
}
/** The persisted shape of a StaticParty - everything but runtime state. */
export type PersistedStaticParty = Omit<StaticParty, 'livePartyId'>;
/** One item of a static watch party, as the client sees it. */
export interface StaticPartyItemDto extends QueueItem {
groupState: 'watched' | 'next' | 'upcoming';
/** Whether the viewer has watched it (in-app or Emby's Played flag). */
viewerWatched: boolean;
}
/** GET /api/static-parties list entry. */
export interface StaticPartySummaryDto {
id: string;
name: string;
sourceType: StaticPartySourceType;
imageUrl: string;
itemCount: number;
groupWatchedCount: number;
nextItemName: string | null;
memberCount: number;
youAreMember: boolean;
youAreCreator: boolean;
missedCount: number;
livePartyId: string | null;
}
/** GET /api/static-parties/:id - the summary plus every item and member. */
export interface StaticPartyDetailDto extends StaticPartySummaryDto {
items: StaticPartyItemDto[];
missedItemIds: string[];
members: Array<{ userId: string; userName: string; watchedCount: number; isCreator: boolean }>;
}
export interface LeaveResult {

View file

@ -4,7 +4,7 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import type { Request, Response } from 'express';
import { CatalogService, imageUrlFor, seasonQueue, seriesQueueFrom, splitPlaylistByMediaType } from '../src/services/CatalogService';
import { CatalogService, imageUrlFor, splitPlaylistByMediaType } from '../src/services/CatalogService';
import { EmbyClient } from '../src/emby/EmbyClient';
import type { EmbyItem, EmbySession } from '../src/types';
@ -97,18 +97,6 @@ describe('CatalogService', () => {
);
});
it('getRawItem requests Fields=AlbumId - without it, a track started directly (not via its album) always missed imageUrlFor\'s own fallback', async (t) => {
let requestedPath = '';
t.mock.method(EmbyClient, 'fetch', async (pathAndQuery: string) => {
requestedPath = pathAndQuery;
return jsonResponse({ Id: 't1', Name: 'Track', Type: 'Audio', AlbumId: 'al1' });
});
await CatalogService.getRawItem(session, 't1');
assert.ok(requestedPath.includes('Fields=AlbumId'), requestedPath);
});
it('getPlaybackOptions flags an image/bitmap subtitle (PGS) as not text-based, real text subtitles as fine', async (t) => {
t.mock.method(EmbyClient, 'fetch', async () => jsonResponse({
MediaSources: [{
@ -129,42 +117,6 @@ describe('CatalogService', () => {
assert.equal(options.subtitleTracks.find((t) => t.index === 4)?.isTextBased, true, 'ass is text too');
});
it('getPlaybackMarkers picks out Intro/Credits markers by MarkerType, ignoring the auto-generated Chapter ones', async (t) => {
t.mock.method(EmbyClient, 'fetch', async () => jsonResponse({
Chapters: [
{ StartPositionTicks: 0, Name: 'Chapitre 1', MarkerType: 'Chapter' },
{ StartPositionTicks: 50000000, MarkerType: 'IntroStart' },
{ StartPositionTicks: 900000000, MarkerType: 'IntroEnd' },
{ StartPositionTicks: 3000000000, Name: 'Chapitre 2', MarkerType: 'Chapter' },
{ StartPositionTicks: 20000000000, MarkerType: 'CreditsStart' }
]
}));
const markers = await CatalogService.getPlaybackMarkers(session, 'item1');
assert.equal(markers.introStartTicks, 50000000);
assert.equal(markers.introEndTicks, 900000000);
assert.equal(markers.creditsStartTicks, 20000000000);
});
it('getPlaybackMarkers returns nulls, not an error, when the item has no detected markers at all', async (t) => {
t.mock.method(EmbyClient, 'fetch', async () => jsonResponse({
Chapters: [{ StartPositionTicks: 0, Name: 'Chapitre 1', MarkerType: 'Chapter' }]
}));
const markers = await CatalogService.getPlaybackMarkers(session, 'item1');
assert.deepEqual(markers, { introStartTicks: null, introEndTicks: null, creditsStartTicks: null });
});
it('getPlaybackMarkers returns nulls when the item has no Chapters field at all', async (t) => {
t.mock.method(EmbyClient, 'fetch', async () => jsonResponse({}));
const markers = await CatalogService.getPlaybackMarkers(session, 'item1');
assert.deepEqual(markers, { introStartTicks: null, introEndTicks: null, creditsStartTicks: null });
});
it('proxySubtitle asks Emby for this track as WebVTT, using the negotiated MediaSourceId', async (t) => {
t.mock.method(EmbyClient, 'fetch', async () => jsonResponse({ MediaSources: [{ Id: 'src7' }] }));
let capturedTarget = '';
@ -217,35 +169,6 @@ describe('CatalogService', () => {
);
});
it('negotiatePlaybackSession returns the MediaSourceId/PlaySessionId a Sessions-API report needs', async (t) => {
t.mock.method(EmbyClient, 'fetch', async () => jsonResponse({
MediaSources: [{ Id: 'src1' }],
PlaySessionId: 'play1'
}));
const result = await CatalogService.negotiatePlaybackSession(session, 'item1');
assert.deepEqual(result, { mediaSourceId: 'src1', playSessionId: 'play1' });
});
it('negotiatePlaybackSession returns null, not a throw, when Emby has no media source at all', async (t) => {
t.mock.method(EmbyClient, 'fetch', async () => jsonResponse({ MediaSources: [], PlaySessionId: 'play1' }));
assert.equal(await CatalogService.negotiatePlaybackSession(session, 'item1'), null);
});
it('negotiatePlaybackSession returns null when Emby omits PlaySessionId, even with a real source', async (t) => {
t.mock.method(EmbyClient, 'fetch', async () => jsonResponse({ MediaSources: [{ Id: 'src1' }] }));
assert.equal(await CatalogService.negotiatePlaybackSession(session, 'item1'), null);
});
it('negotiatePlaybackSession returns null, not a throw, when the negotiation itself fails', async (t) => {
t.mock.method(EmbyClient, 'fetch', async () => new Response(null, { status: 500 }) as unknown as Response);
assert.equal(await CatalogService.negotiatePlaybackSession(session, 'item1'), null);
});
it('getPlaylistItemsRaw asks Emby\'s own /Playlists endpoint, in order', async (t) => {
let capturedPath = '';
t.mock.method(EmbyClient, 'fetch', async (path: string) => {
@ -314,34 +237,3 @@ describe('imageUrlFor', () => {
assert.equal(imageUrlFor({ Id: 't1', Name: 'Track', Type: 'Audio' }), '/api/catalog/image/t1');
});
});
describe('seriesQueueFrom / seasonQueue', () => {
const ep = (id: string, season: number, seasonId?: string): EmbyItem => ({ Id: id, Name: id, Type: 'Episode', ParentIndexNumber: season, SeasonId: seasonId });
// Emby's own order: specials (season 0) first, then every season in turn.
const all = [ep('sp1', 0, 'se0'), ep('s1e1', 1, 'se1'), ep('s1e2', 1, 'se1'), ep('s2e1', 2, 'se2'), ep('s2e2', 2, 'se2')];
it('the whole series starts at S1E1 - specials sort first and are skipped', () => {
assert.deepEqual(seriesQueueFrom(all).map((e) => e.Id), ['s1e1', 's1e2', 's2e1', 's2e2']);
});
it('a series with only specials still queues them', () => {
assert.deepEqual(seriesQueueFrom([ep('sp1', 0), ep('sp2', 0)]).map((e) => e.Id), ['sp1', 'sp2']);
});
it('from an episode, queues it and everything after it, across seasons', () => {
assert.deepEqual(seriesQueueFrom(all, 's1e2').map((e) => e.Id), ['s1e2', 's2e1', 's2e2']);
});
it('an unknown starting episode yields nothing (the caller falls back to that one item)', () => {
assert.deepEqual(seriesQueueFrom(all, 'nope'), []);
});
it('a season queues only its own episodes', () => {
assert.deepEqual(seasonQueue(all, { Id: 'se1', IndexNumber: 1 }).map((e) => e.Id), ['s1e1', 's1e2']);
});
it('a season falls back to the season number for episodes without a SeasonId', () => {
const noIds = [ep('a', 1), ep('b', 2), ep('c', 2)];
assert.deepEqual(seasonQueue(noIds, { Id: 'se2', IndexNumber: 2 }).map((e) => e.Id), ['b', 'c']);
});
});

View file

@ -1,91 +0,0 @@
// Exercises EmbySessionService directly against a mocked EmbyClient - no
// real Emby server involved. Covers the request shape each of the three
// Sessions-API calls sends and that a failure (a non-ok response, or a
// thrown error) is swallowed rather than propagated - see the class's
// own comment for why none of this may ever be allowed to disrupt real
// playback over an admin-dashboard nicety.
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { EmbySessionService } from '../src/services/EmbySessionService';
import { EmbyClient } from '../src/emby/EmbyClient';
function okResponse(): Response {
return new Response(null, { status: 204 }) as unknown as Response;
}
describe('EmbySessionService', () => {
it('start() POSTs to /Sessions/Playing with the negotiated ids and the given position/pause state', async (t) => {
let capturedPath = '';
let capturedToken = '';
let capturedBody: unknown = null;
t.mock.method(EmbyClient, 'fetch', async (path: string, token: string, options: { body?: unknown }) => {
capturedPath = path;
capturedToken = token;
capturedBody = options.body;
return okResponse();
});
await EmbySessionService.start('tok1', 'item1', 'src1', 'play1', 12_345, false);
assert.equal(capturedPath, '/Sessions/Playing');
assert.equal(capturedToken, 'tok1');
assert.deepEqual(capturedBody, {
ItemId: 'item1',
MediaSourceId: 'src1',
PlaySessionId: 'play1',
PositionTicks: 12_345,
IsPaused: false,
PlayMethod: 'Transcode',
CanSeek: true
});
});
it('progress() POSTs to /Sessions/Playing/Progress', async (t) => {
let capturedPath = '';
t.mock.method(EmbyClient, 'fetch', async (path: string) => {
capturedPath = path;
return okResponse();
});
await EmbySessionService.progress('tok1', 'item1', 'src1', 'play1', 999, true);
assert.equal(capturedPath, '/Sessions/Playing/Progress');
});
it('stop() POSTs to /Sessions/Playing/Stopped', async (t) => {
let capturedPath = '';
let capturedBody: unknown = null;
t.mock.method(EmbyClient, 'fetch', async (path: string, _token: string, options: { body?: unknown }) => {
capturedPath = path;
capturedBody = options.body;
return okResponse();
});
await EmbySessionService.stop('tok1', 'item1', 'src1', 'play1', 42);
assert.equal(capturedPath, '/Sessions/Playing/Stopped');
assert.deepEqual(capturedBody, { ItemId: 'item1', MediaSourceId: 'src1', PlaySessionId: 'play1', PositionTicks: 42 });
});
it('rounds a fractional PositionTicks rather than sending a non-integer', async (t) => {
let capturedBody: { PositionTicks?: number } = {};
t.mock.method(EmbyClient, 'fetch', async (_path: string, _token: string, options: { body?: unknown }) => {
capturedBody = options.body as { PositionTicks?: number };
return okResponse();
});
await EmbySessionService.progress('tok1', 'item1', 'src1', 'play1', 12_345.6, false);
assert.equal(capturedBody.PositionTicks, 12_346);
});
it('a non-ok response is logged, not thrown', async (t) => {
t.mock.method(EmbyClient, 'fetch', async () => new Response(null, { status: 401 }) as unknown as Response);
await assert.doesNotReject(() => EmbySessionService.start('tok1', 'item1', 'src1', 'play1', 0, false));
});
it('a thrown network error is logged, not propagated', async (t) => {
t.mock.method(EmbyClient, 'fetch', async () => { throw new Error('network down'); });
await assert.doesNotReject(() => EmbySessionService.stop('tok1', 'item1', 'src1', 'play1', 0));
});
});

View file

@ -49,8 +49,7 @@ function fakeMember(userId: string, userName: string, embyToken: string): PartyM
embyToken,
isPaused: false,
positionTicks: 0,
lastUpdatedUtc: Date.now(),
embySession: null
lastUpdatedUtc: Date.now()
};
}
@ -77,60 +76,6 @@ describe('MusicQueueService', () => {
assert.equal(music(party).queue.length, 1);
});
it('addTracks queues a whole batch (an album, a discography) in the given order, behind whatever is already playing', () => {
const party = fakeParty('p2b');
MusicQueueService.init(party, { itemId: 't1', itemName: 'Track One', itemImageUrl: '/img/t1', durationTicks: 10_000_000 }, 'u-alice', 'Alice');
MusicQueueService.addTracks(party, [
{ itemId: 't2', itemName: 'Track Two', itemImageUrl: '/img/t2', durationTicks: 20_000_000 },
{ itemId: 't3', itemName: 'Track Three', itemImageUrl: '/img/t3', durationTicks: 30_000_000 }
], 'u-bob', 'Bob');
assert.equal(music(party).current?.track.itemId, 't1', 'still the track already playing');
assert.deepEqual(music(party).queue.map((t) => t.itemId), ['t2', 't3']);
});
it('addTracks auto-starts the first track (and only the first) when the queue was empty', () => {
const party = fakeParty('p2c');
MusicQueueService.init(party, null, 'u-alice', 'Alice'); // empty queue - nothing playing yet
MusicQueueService.addTracks(party, [
{ itemId: 't1', itemName: 'Track One', itemImageUrl: '/img/t1', durationTicks: 10_000_000 },
{ itemId: 't2', itemName: 'Track Two', itemImageUrl: '/img/t2', durationTicks: 20_000_000 }
], 'u-alice', 'Alice');
assert.equal(music(party).current?.track.itemId, 't1', 'the batch\'s own first track starts playing');
assert.deepEqual(music(party).queue.map((t) => t.itemId), ['t2']);
});
it('addTracks broadcasts once for the whole batch, not once per track', () => {
const party = fakeParty('p2d');
MusicQueueService.init(party, { itemId: 't1', itemName: 'Track One', itemImageUrl: '/img/t1', durationTicks: 10_000_000 }, 'u-alice', 'Alice');
let broadcasts = 0;
// Same "attach a fresh fake io" pattern socketGateway.test.ts's own
// attach() helper uses - MusicQueueService is a singleton, but every
// test that cares about broadcasts re-attaches its own fake at the
// start rather than resetting afterward, and every test that doesn't
// care never looks at this.io's side effects at all.
MusicQueueService.attachIo({ to: () => ({ emit: () => { broadcasts++; } }) } as unknown as Parameters<typeof MusicQueueService.attachIo>[0]);
MusicQueueService.addTracks(party, [
{ itemId: 't2', itemName: 'Track Two', itemImageUrl: '/img/t2', durationTicks: 20_000_000 },
{ itemId: 't3', itemName: 'Track Three', itemImageUrl: '/img/t3', durationTicks: 30_000_000 },
{ itemId: 't4', itemName: 'Track Four', itemImageUrl: '/img/t4', durationTicks: 40_000_000 }
], 'u-bob', 'Bob');
assert.equal(broadcasts, 1);
});
it('addTracks is a harmless no-op for an empty batch', () => {
const party = fakeParty('p2e');
MusicQueueService.init(party, { itemId: 't1', itemName: 'Track One', itemImageUrl: '/img/t1', durationTicks: 10_000_000 }, 'u-alice', 'Alice');
assert.doesNotThrow(() => MusicQueueService.addTracks(party, [], 'u-alice', 'Alice'));
assert.equal(music(party).queue.length, 0);
});
it('voteSkip needs a strict majority of connected members, not any count', () => {
const party = fakeParty('p3');
MusicQueueService.init(party, { itemId: 't1', itemName: 'Track One', itemImageUrl: '/img/t1', durationTicks: 10_000_000 }, 'u-alice', 'Alice');
@ -198,67 +143,6 @@ describe('MusicQueueService', () => {
MusicQueueService.destroy(party); // avoid leaking this test's own long-duration timer
});
it('seek while paused jumps the frozen elapsed time directly, without needing a resume', () => {
const party = fakeParty('p4b');
MusicQueueService.init(party, { itemId: 't1', itemName: 'Track One', itemImageUrl: '/img/t1', durationTicks: 600_000_000 /* 60s */ }, 'u-alice', 'Alice');
MusicQueueService.setPaused(party, true);
MusicQueueService.seek(party, 30);
assert.equal(music(party).current?.isPaused, true, 'seeking must not itself resume playback');
assert.equal(MusicQueueService.getElapsedSeconds(party), 30);
});
it('seek clamps into [0, duration] rather than trusting an out-of-range value verbatim', () => {
const party = fakeParty('p4c');
MusicQueueService.init(party, { itemId: 't1', itemName: 'Track One', itemImageUrl: '/img/t1', durationTicks: 100_000_000 /* 10s */ }, 'u-alice', 'Alice');
MusicQueueService.setPaused(party, true);
MusicQueueService.seek(party, 999);
assert.equal(MusicQueueService.getElapsedSeconds(party), 10, 'clamped to the track\'s own duration');
MusicQueueService.seek(party, -5);
assert.equal(MusicQueueService.getElapsedSeconds(party), 0, 'clamped to zero, not left negative');
});
it('seek while playing re-arms the auto-advance timer against the new position, not the old one', async () => {
const party = fakeParty('p4d');
// Exactly a 2s track (20_000_000 ticks / 10_000_000 per second - no
// rounding to worry about) - seeking to 1.7s leaves ~300ms remaining.
// If the timer were left un-re-armed (still counting down from the
// original full 2s mark instead), it would still be playing at the
// 600ms mark below - comfortably short of the original ~2000ms, so
// this genuinely distinguishes the two.
MusicQueueService.init(party, { itemId: 't1', itemName: 'Track One', itemImageUrl: '/img/t1', durationTicks: 20_000_000 }, 'u-alice', 'Alice');
MusicQueueService.seek(party, 1.7);
assert.equal(music(party).current?.track.itemId, 't1', 'still the same track right after seeking');
await sleep(600); // past the re-armed ~300ms, comfortably short of the original ~2000ms
assert.equal(music(party).current, null, 'nothing queued behind it - should have auto-advanced off the re-armed timer');
});
it('seek while playing clears the old timer before scheduling a new one - never double-scheduled', (t) => {
const realClearTimeout = global.clearTimeout;
const cleared = mock.fn(realClearTimeout);
global.clearTimeout = cleared as typeof clearTimeout;
t.after(() => { global.clearTimeout = realClearTimeout; });
const party = fakeParty('p4e');
MusicQueueService.init(party, { itemId: 't1', itemName: 'Track One', itemImageUrl: '/img/t1', durationTicks: 600_000_000 /* 60s - stays pending */ }, 'u-alice', 'Alice');
MusicQueueService.seek(party, 10);
assert.equal(cleared.mock.callCount(), 1, 'the pre-seek timer must be cleared, not left running alongside the new one');
MusicQueueService.destroy(party); // avoid leaking this test's own long-duration timer
});
it('seek is a harmless no-op when nothing is currently playing', () => {
const party = fakeParty('p4f');
MusicQueueService.init(party, null, 'u-alice', 'Alice');
assert.doesNotThrow(() => MusicQueueService.seek(party, 30));
});
it('a short track auto-advances on its own via a real timer, with no client reporting "ended"', async () => {
const party = fakeParty('p5');
MusicQueueService.init(party, { itemId: 't1', itemName: 'Track One', itemImageUrl: '/img/t1', durationTicks: 10_000_000 /* 1s */ }, 'u-alice', 'Alice');

View file

@ -4,7 +4,6 @@ import { PartyService } from '../src/services/PartyService';
import { MusicQueueService } from '../src/services/MusicQueueService';
import { PlaystateService } from '../src/services/PlaystateService';
import { CastTokenService } from '../src/services/CastTokenService';
import { EmbySessionService } from '../src/services/EmbySessionService';
import type { AppSocket } from '../src/types';
describe('PartyService', () => {
@ -128,68 +127,6 @@ describe('PartyService', () => {
assert.deepEqual(calls, [[party.id, 'u-bob']]);
});
it('leave() stops whatever Emby Sessions-API entry the leaving member had open', (t) => {
const calls: unknown[][] = [];
t.mock.method(EmbySessionService, 'stop', async (...args: unknown[]) => { calls.push(args); });
const party = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video');
const bob = PartyService.join(party, 'u-bob', 'Bob');
bob.embyToken = 'tok-bob';
bob.positionTicks = 555;
bob.embySession = { itemId: 'm1', mediaSourceId: 'src1', playSessionId: 'play1' };
PartyService.leave(party, 'u-bob');
assert.deepEqual(calls, [['tok-bob', 'm1', 'src1', 'play1', 555]]);
});
it('leave() does not call EmbySessionService.stop for a member with no open session', (t) => {
let called = false;
t.mock.method(EmbySessionService, 'stop', async () => { called = true; });
const party = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video');
PartyService.join(party, 'u-bob', 'Bob'); // embyToken/embySession both still null - see newMember
PartyService.leave(party, 'u-bob');
assert.equal(called, false);
});
it('isStaleDevice(): false when the deviceId matches the member\'s own', () => {
const party = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video');
const alice = party.members.get('u-alice')!;
alice.deviceId = 'device-a';
assert.equal(PartyService.isStaleDevice(alice, 'device-a'), false);
});
it('isStaleDevice(): true when the deviceId differs from the member\'s own - the actual takeover case', () => {
const party = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video');
const alice = party.members.get('u-alice')!;
alice.deviceId = 'device-a'; // device B's takeover already moved this forward
assert.equal(PartyService.isStaleDevice(alice, 'device-b'), true);
});
it('isStaleDevice(): false when the member has no recorded deviceId yet - nothing to conflict with, same reasoning as the socket "join" handler', () => {
const party = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video');
const alice = party.members.get('u-alice')!; // deviceId still null - no socket "join" has succeeded yet
assert.equal(PartyService.isStaleDevice(alice, 'device-a'), false);
});
it('isStaleDevice(): false when the caller sent no deviceId at all - only ever blocks on positive evidence of a mismatch', () => {
const party = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video');
const alice = party.members.get('u-alice')!;
alice.deviceId = 'device-a';
assert.equal(PartyService.isStaleDevice(alice, null), false);
});
it('isStaleDevice(): false for an unknown member (already left some other way)', () => {
assert.equal(PartyService.isStaleDevice(undefined, 'device-a'), false);
});
it('join() is idempotent: re-joining an existing member returns it as-is instead of resetting it', () => {
const party = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video');
const bob = PartyService.join(party, 'u-bob', 'Bob');

View file

@ -56,7 +56,7 @@ function attach(io: ReturnType<typeof fakeIo>) {
}
describe('SocketGateway', () => {
it('voteSkip/musicPlaystate/musicSeek are no-ops on a video party', () => {
it('voteSkip/musicPlaystate are no-ops on a video party', () => {
const io = fakeIo();
attach(io);
const party = PartyService.create('u-host', 'Host', 'm1', 'Un Film', '/img/m1', null, 'video');
@ -67,8 +67,7 @@ describe('SocketGateway', () => {
const before = io.roomEmits.length;
socket.trigger('voteSkip');
socket.trigger('musicPlaystate', { isPaused: true });
socket.trigger('musicSeek', { positionSeconds: 30 });
assert.equal(io.roomEmits.length, before, 'none of these events should produce any broadcast on a video party');
assert.equal(io.roomEmits.length, before, 'neither event should produce any broadcast on a video party');
});
it('a non-host member can pause and resume a music party directly (no host check)', () => {
@ -89,23 +88,6 @@ describe('SocketGateway', () => {
assert.equal(party.music?.current?.isPaused, false);
});
it('a non-host member can seek a music party directly (no host check)', () => {
const io = fakeIo();
attach(io);
const party = PartyService.create('u-alice', 'Alice', 't1', 'Track One', '/img/t1', null, 'music');
MusicQueueService.init(party, { itemId: 't1', itemName: 'Track One', itemImageUrl: '/img/t1', durationTicks: 600_000_000 }, 'u-alice', 'Alice');
MusicQueueService.setPaused(party, true); // frozen elapsed time makes the resulting position deterministic below
PartyService.join(party, 'u-bob', 'Bob');
const bobSocket = fakeSocket('u-bob');
io.trigger('connection', bobSocket as unknown as AppSocket);
bobSocket.trigger('join', { partyId: party.id });
assert.notEqual(party.hostUserId, 'u-bob');
bobSocket.trigger('musicSeek', { positionSeconds: 30 });
assert.equal(MusicQueueService.getElapsedSeconds(party), 30);
});
it('voteSkip needs a majority of connected members and broadcasts queueUpdated on the party room', () => {
const io = fakeIo();
attach(io);

View file

@ -1,299 +0,0 @@
import { describe, it, before, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { StaticPartyService, computeMissedIds, mergeItemLists } from '../src/services/StaticPartyService';
import { StaticPartyStore } from '../src/services/StaticPartyStore';
import { PartyService } from '../src/services/PartyService';
import { PlaystateService } from '../src/services/PlaystateService';
import type { AppSocket, QueueItem } from '../src/types';
const item = (id: string): QueueItem => ({ itemId: id, itemName: `Episode ${id}`, itemImageUrl: `/img/${id}` });
const ITEMS = ['e1', 'e2', 'e3', 'e4'].map(item);
const alice = { userId: 'u-alice', userName: 'Alice' };
const bob = { userId: 'u-bob', userName: 'Bob' };
const NONE: ReadonlySet<string> = new Set();
let dataDir: string;
before(() => {
dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wp-static-'));
StaticPartyStore.configure(dataDir);
});
const partiesDir = () => path.join(dataDir, 'static-parties');
beforeEach(() => {
StaticPartyStore.flushSync(); // no debounced save from a previous test may land mid-test
fs.rmSync(partiesDir(), { recursive: true, force: true });
for (const name of fs.readdirSync(dataDir)) fs.rmSync(path.join(dataDir, name), { recursive: true, force: true });
StaticPartyService.init();
});
describe('computeMissedIds', () => {
it('is every group-completed item, in the items\' own order', () => {
assert.deepEqual(computeMissedIds(ITEMS, ['e2', 'e1'], [], NONE), ['e1', 'e2']);
});
it('drops what the viewer watched in-app and what Emby already has as Played', () => {
assert.deepEqual(computeMissedIds(ITEMS, ['e1', 'e2', 'e3'], ['e1'], new Set(['e3'])), ['e2']);
});
it('never lists an item the group has not completed yet, even if the viewer watched ahead', () => {
assert.deepEqual(computeMissedIds(ITEMS, ['e1'], ['e4'], NONE), ['e1']);
});
});
describe('mergeItemLists', () => {
it('keeps the existing order, appends new items and keeps ones gone from the source', () => {
const { items, added } = mergeItemLists([item('e1'), item('e2')], [item('e2'), item('e3')]);
assert.deepEqual(items.map((i) => i.itemId), ['e1', 'e2', 'e3']);
assert.equal(added, 1);
});
it('refreshes a known item\'s name from the fresh fetch', () => {
const { items } = mergeItemLists([item('e1')], [{ ...item('e1'), itemName: 'Renamed' }]);
assert.equal(items[0]!.itemName, 'Renamed');
});
});
describe('StaticPartyService', () => {
it('create() makes the creator the first member, with nothing watched yet', () => {
const sp = StaticPartyService.create(alice, { type: 'series', sourceId: 's1' }, 'My Show', '/img/s1', ITEMS);
assert.deepEqual(Object.keys(sp.members), ['u-alice']);
assert.equal(sp.group.nextIndex, 0);
assert.deepEqual(StaticPartyService.groupQueue(sp).map((i) => i.itemId), ['e1', 'e2', 'e3', 'e4']);
});
it('a group completion advances the group and credits only connected participants', () => {
const sp = StaticPartyService.create(alice, { type: 'series', sourceId: 's1' }, 'My Show', '/img/s1', ITEMS);
StaticPartyService.join(sp, bob);
const link = { staticPartyId: sp.id, mode: 'group' as const };
StaticPartyService.onItemCompleted(link, 'e1', [alice, bob]);
StaticPartyService.onItemCompleted(link, 'e2', [alice]); // Bob missed this one
assert.equal(sp.group.nextIndex, 2);
assert.deepEqual(sp.group.completedItemIds, ['e1', 'e2']);
assert.deepEqual(StaticPartyService.missedIds(sp, bob.userId, NONE), ['e2']);
assert.deepEqual(StaticPartyService.missedIds(sp, alice.userId, NONE), []);
assert.deepEqual(StaticPartyService.groupQueue(sp).map((i) => i.itemId), ['e3', 'e4']);
});
it('a late joiner sees everything the group completed before them as missed', () => {
const sp = StaticPartyService.create(alice, { type: 'series', sourceId: 's1' }, 'My Show', '/img/s1', ITEMS);
StaticPartyService.onItemCompleted({ staticPartyId: sp.id, mode: 'group' }, 'e1', [alice]);
StaticPartyService.join(sp, bob);
assert.deepEqual(StaticPartyService.missedIds(sp, bob.userId, NONE), ['e1']);
});
it('a connected participant who never joined the static party becomes a member on completion', () => {
const sp = StaticPartyService.create(alice, { type: 'series', sourceId: 's1' }, 'My Show', '/img/s1', ITEMS);
StaticPartyService.onItemCompleted({ staticPartyId: sp.id, mode: 'group' }, 'e1', [alice, bob]);
assert.deepEqual(sp.members[bob.userId]!.watchedItemIds, ['e1']);
});
it('a catch-up completion credits the participant without moving the group', () => {
const sp = StaticPartyService.create(alice, { type: 'series', sourceId: 's1' }, 'My Show', '/img/s1', ITEMS);
StaticPartyService.onItemCompleted({ staticPartyId: sp.id, mode: 'group' }, 'e1', [alice]);
StaticPartyService.join(sp, bob);
StaticPartyService.onItemCompleted({ staticPartyId: sp.id, mode: 'catchUp' }, 'e1', [bob]);
assert.deepEqual(StaticPartyService.missedIds(sp, bob.userId, NONE), []);
assert.equal(sp.group.nextIndex, 1, 'unchanged by a catch-up');
assert.deepEqual(sp.group.completedItemIds, ['e1']);
});
it('markWatched removes an item from the missed list, and refuses an unknown item', () => {
const sp = StaticPartyService.create(alice, { type: 'series', sourceId: 's1' }, 'My Show', '/img/s1', ITEMS);
StaticPartyService.onItemCompleted({ staticPartyId: sp.id, mode: 'group' }, 'e1', [alice]);
assert.equal(StaticPartyService.markWatched(sp, bob, 'e1'), true);
assert.deepEqual(StaticPartyService.missedIds(sp, bob.userId, NONE), []);
assert.equal(StaticPartyService.markWatched(sp, bob, 'nope'), false);
});
it('recordPosition only keeps a position for the group\'s next item, and a completion resets it', () => {
const sp = StaticPartyService.create(alice, { type: 'series', sourceId: 's1' }, 'My Show', '/img/s1', ITEMS);
const link = { staticPartyId: sp.id, mode: 'group' as const };
StaticPartyService.recordPosition(link, 'e3', 999);
assert.equal(sp.group.resumePositionTicks, 0, 'not the next item - ignored');
StaticPartyService.recordPosition(link, 'e1', 123);
assert.equal(sp.group.resumePositionTicks, 123);
StaticPartyService.recordPosition({ ...link, mode: 'catchUp' }, 'e1', 456);
assert.equal(sp.group.resumePositionTicks, 123, 'a catch-up session never moves the group');
StaticPartyService.onItemCompleted(link, 'e1', [alice]);
assert.equal(sp.group.resumePositionTicks, 0);
});
it('refreshItems appends new episodes without losing progress', () => {
const sp = StaticPartyService.create(alice, { type: 'series', sourceId: 's1' }, 'My Show', '/img/s1', ITEMS.slice(0, 2));
StaticPartyService.onItemCompleted({ staticPartyId: sp.id, mode: 'group' }, 'e1', [alice]);
StaticPartyService.refreshItems(sp, ITEMS);
assert.deepEqual(sp.items.map((i) => i.itemId), ['e1', 'e2', 'e3', 'e4']);
assert.equal(sp.group.nextIndex, 1);
assert.deepEqual(sp.group.completedItemIds, ['e1']);
});
it('only the creator can delete; leaving keeps the party', () => {
const sp = StaticPartyService.create(alice, { type: 'series', sourceId: 's1' }, 'My Show', '/img/s1', ITEMS);
StaticPartyService.join(sp, bob);
assert.equal(StaticPartyService.delete(sp, bob.userId), false);
StaticPartyService.leave(sp, bob.userId);
assert.ok(StaticPartyService.getById(sp.id));
assert.equal(StaticPartyService.delete(sp, alice.userId), true);
assert.equal(StaticPartyService.getById(sp.id), null);
});
it('onPartyDestroyed only clears the matching live session', () => {
const sp = StaticPartyService.create(alice, { type: 'series', sourceId: 's1' }, 'My Show', '/img/s1', ITEMS);
StaticPartyService.setLiveParty(sp, 'p1');
StaticPartyService.onPartyDestroyed({ staticPartyId: sp.id, mode: 'catchUp' }, 'p2');
assert.equal(sp.livePartyId, 'p1');
StaticPartyService.onPartyDestroyed({ staticPartyId: sp.id, mode: 'group' }, 'p1');
assert.equal(sp.livePartyId, null);
});
it('toDetail marks each item\'s group state and the viewer\'s own (in-app or Emby Played)', () => {
const sp = StaticPartyService.create(alice, { type: 'series', sourceId: 's1' }, 'My Show', '/img/s1', ITEMS);
StaticPartyService.onItemCompleted({ staticPartyId: sp.id, mode: 'group' }, 'e1', [alice]);
const detail = StaticPartyService.toDetail(sp, bob.userId, new Set(['e4']), null);
assert.deepEqual(detail.items.map((i) => i.groupState), ['watched', 'next', 'upcoming', 'upcoming']);
assert.deepEqual(detail.items.map((i) => i.viewerWatched), [false, false, false, true]);
assert.deepEqual(detail.missedItemIds, ['e1']);
assert.equal(detail.youAreMember, false);
assert.equal(detail.missedCount, 1);
});
});
describe('StaticPartyStore', () => {
it('writes one file per party, and round-trips it without runtime-only state', async () => {
const sp = StaticPartyService.create(alice, { type: 'playlist', sourceId: 'pl1' }, 'Soirées', '/img/pl1', ITEMS);
const other = StaticPartyService.create(bob, { type: 'series', sourceId: 's1' }, 'My Show', '/img/s1', ITEMS);
StaticPartyService.onItemCompleted({ staticPartyId: sp.id, mode: 'group' }, 'e1', [alice]);
StaticPartyService.setLiveParty(sp, 'live-1');
await StaticPartyStore.writeNow();
assert.deepEqual(fs.readdirSync(partiesDir()).sort(), [`${sp.id}.json`, `${other.id}.json`].sort());
const onDisk = JSON.parse(fs.readFileSync(path.join(partiesDir(), `${sp.id}.json`), 'utf8'));
assert.equal(onDisk.version, 1);
assert.equal('livePartyId' in onDisk.party, false);
StaticPartyService.init();
const reloaded = StaticPartyService.getById(sp.id)!;
assert.equal(reloaded.name, 'Soirées');
assert.deepEqual(reloaded.group.completedItemIds, ['e1']);
assert.equal(reloaded.livePartyId, null, 'a restart ends every live session');
assert.ok(StaticPartyService.getById(other.id));
});
it('a change only rewrites the party that changed', async () => {
const sp = StaticPartyService.create(alice, { type: 'series', sourceId: 's1' }, 'A', '/img/s1', ITEMS);
const other = StaticPartyService.create(bob, { type: 'series', sourceId: 's1' }, 'B', '/img/s1', ITEMS);
await StaticPartyStore.writeNow();
const otherFile = path.join(partiesDir(), `${other.id}.json`);
const past = new Date(Date.now() - 60000);
fs.utimesSync(otherFile, past, past);
// Compared against what the filesystem itself reports back, not
// past.getTime(): Linux returns mtimeMs as a float that can land a
// hair off the exact millisecond set (e.g. ...780.999 for ...781).
const mtimeBefore = fs.statSync(otherFile).mtimeMs;
StaticPartyService.markWatched(sp, alice, 'e1');
await StaticPartyStore.writeNow();
assert.equal(fs.statSync(otherFile).mtimeMs, mtimeBefore, 'the untouched party\'s file was not rewritten');
});
it('deleting a party removes its file, even with a save still pending', async () => {
const sp = StaticPartyService.create(alice, { type: 'series', sourceId: 's1' }, 'A', '/img/s1', ITEMS);
await StaticPartyStore.writeNow();
StaticPartyService.markWatched(sp, alice, 'e1'); // schedules a save...
StaticPartyService.delete(sp, alice.userId); // ...that must not recreate the file
await StaticPartyStore.writeNow();
assert.equal(fs.existsSync(path.join(partiesDir(), `${sp.id}.json`)), false);
});
it('a corrupt file is moved aside and only that party is lost', async () => {
const good = StaticPartyService.create(alice, { type: 'series', sourceId: 's1' }, 'Good', '/img/s1', ITEMS);
await StaticPartyStore.writeNow();
fs.writeFileSync(path.join(partiesDir(), 'broken-id.json'), '{ not json', 'utf8');
StaticPartyService.init();
assert.deepEqual(StaticPartyService.list().map((p) => p.id), [good.id]);
assert.ok(fs.existsSync(path.join(partiesDir(), 'broken-id.json.corrupt')));
});
it('a leftover .tmp from an interrupted write is cleaned up, the real file kept', async () => {
const sp = StaticPartyService.create(alice, { type: 'series', sourceId: 's1' }, 'A', '/img/s1', ITEMS);
await StaticPartyStore.writeNow();
fs.writeFileSync(path.join(partiesDir(), `${sp.id}.json.tmp`), '{ half-writ', 'utf8');
StaticPartyService.init();
assert.ok(StaticPartyService.getById(sp.id));
assert.equal(fs.existsSync(path.join(partiesDir(), `${sp.id}.json.tmp`)), false);
});
it('migrates the first release\'s single static-parties.json into one file per party', () => {
const legacy = { version: 1, parties: [
{ id: 'aaa-1', name: 'One', source: { type: 'series', sourceId: 's1' }, imageUrl: '', createdByUserId: 'u-alice', createdUtc: 1,
items: ITEMS, group: { nextIndex: 1, resumePositionTicks: 0, completedItemIds: ['e1'], lastSessionUtc: null }, members: {} },
{ id: 'bbb-2', name: 'Two', source: { type: 'series', sourceId: 's1' }, imageUrl: '', createdByUserId: 'u-bob', createdUtc: 2,
items: ITEMS, group: { nextIndex: 0, resumePositionTicks: 0, completedItemIds: [], lastSessionUtc: null }, members: {} }
] };
fs.writeFileSync(path.join(dataDir, 'static-parties.json'), JSON.stringify(legacy), 'utf8');
StaticPartyService.init();
assert.deepEqual(StaticPartyService.list().map((p) => p.id).sort(), ['aaa-1', 'bbb-2']);
assert.deepEqual(StaticPartyService.getById('aaa-1')!.group.completedItemIds, ['e1']);
assert.ok(fs.existsSync(path.join(partiesDir(), 'aaa-1.json')));
assert.equal(fs.existsSync(path.join(dataDir, 'static-parties.json')), false);
assert.ok(fs.existsSync(path.join(dataDir, 'static-parties.json.migrated')), 'the original is kept, not deleted');
});
it('an empty data directory is a normal first start', () => {
StaticPartyService.init();
assert.equal(StaticPartyService.list().length, 0);
});
});
describe('PartyService <-> static watch party', () => {
const connected = { emit() {}, disconnect() {} } as unknown as AppSocket;
it('advanceQueue credits the outgoing item to a linked static party, once', (t) => {
t.mock.method(PlaystateService, 'markPlayed', async () => {});
const sp = StaticPartyService.create(alice, { type: 'series', sourceId: 's1' }, 'My Show', '/img/s1', ITEMS);
const party = PartyService.create(alice.userId, alice.userName, 'e1', 'Episode e1', '/img/e1', ITEMS, 'video');
party.staticLink = { staticPartyId: sp.id, mode: 'group' };
party.members.get(alice.userId)!.connection = connected;
PartyService.completeCurrentItem(party); // "itemEnded"...
PartyService.advanceQueue(party); // ...then "next" on the same item: must not double-count
assert.deepEqual(sp.group.completedItemIds, ['e1']);
assert.deepEqual(sp.members[alice.userId]!.watchedItemIds, ['e1']);
assert.equal(sp.group.nextIndex, 1);
});
it('an ordinary party never touches any static party', (t) => {
t.mock.method(PlaystateService, 'markPlayed', async () => {});
const completed = t.mock.method(StaticPartyService, 'onItemCompleted');
const party = PartyService.create(alice.userId, alice.userName, 'e1', 'Episode e1', '/img/e1', ITEMS, 'video');
party.members.get(alice.userId)!.connection = connected;
PartyService.advanceQueue(party);
assert.equal(completed.mock.callCount(), 0);
});
it('the last member leaving a group session clears the static party\'s live session', () => {
const sp = StaticPartyService.create(alice, { type: 'series', sourceId: 's1' }, 'My Show', '/img/s1', ITEMS);
const party = PartyService.create(alice.userId, alice.userName, 'e1', 'Episode e1', '/img/e1', ITEMS, 'video');
party.staticLink = { staticPartyId: sp.id, mode: 'group' };
StaticPartyService.setLiveParty(sp, party.id);
PartyService.leave(party, alice.userId);
assert.equal(sp.livePartyId, null);
});
});

View file

@ -1,166 +0,0 @@
# Lounge party (watch party statique)
## Les trois types de party
| Nom dans l'interface | Quoi | Où |
|---|---|---|
| **Watch party** | party vidéo classique, éphémère | accueil, « Parties en cours » |
| **Listen party** | party audio classique, éphémère (barre musique) | accueil, « Parties en cours » |
| **Lounge party** | party statique : une série ou une playlist vidéo suivie sur plusieurs séances | onglet « Lounge parties » |
Côté code, la lounge party reste nommée « static party » (`StaticPartyService`,
route `/static`, `staticLink`...). Seuls les libellés visibles ont changé. Sur
l'accueil, chaque carte affiche un badge « Watch party » ou « Listen party ».
## Objectif
Suivre une série entière, ou une playlist vidéo, en groupe sur plusieurs
soirées. L'app mémorise :
- **l'avancement du groupe** : les éléments terminés ensemble, et où la
dernière séance s'est arrêtée ;
- **l'avancement de chaque personne** : ce qu'elle a vu.
Une personne qui revient ou qui rejoint en cours de route voit la liste de
ce qu'elle a **manqué** et peut le rattraper.
Contrairement à une watch party classique, qui vit en mémoire et disparaît
avec son dernier membre, une watch party statique est **persistée sur
disque** et survit aux redémarrages.
## Modèle
| Entité | Où | Rôle |
|---|---|---|
| `StaticParty` | `server/src/types.ts`, persistée | la « saison » du groupe : items, progression du groupe, membres |
| `StaticPartyMember` | dans `StaticParty.members` | `watchedItemIds` = ce que la personne a vu dans l'app |
| Séance | une `Party` live ordinaire + `party.staticLink` | la lecture elle-même : lecteur, sync, flux partagé et lobby réutilisés tels quels |
`party.staticLink.mode` :
- `group` : la séance du groupe. Un item terminé fait avancer le groupe
**et** est crédité à chaque participant connecté.
- `catchUp` : un rattrapage. L'item est crédité aux participants connectés
uniquement, le groupe ne bouge pas.
`party.hiddenFromLobby` : un rattrapage en solo n'apparaît pas dans la liste
des watch parties en cours.
## Règles
- **Item terminé** :
- l'hôte passe à l'item suivant (`next`),
- ou le dernier item de la file se termine (`itemEnded`, nouvel event socket réservé à l'hôte).
Les deux passent par `PartyService.completeCurrentItem`, qui gère aussi le
flag `Played` d'Emby. Un même item n'est jamais compté deux fois de suite
(`lastCompletedItemId`). C'est la même heuristique que
`PlaystateService.markPlayed` : « le groupe est passé à la suite » vaut
« vu ».
- **Seuls les membres connectés** au moment où l'item se termine sont
crédités. Une personne partie en cours d'épisode l'a donc manqué ; elle
peut le marquer comme vu.
- **Manqués d'une personne** : les items terminés par le groupe, dans
l'ordre de la liste, moins ceux qu'elle a vus dans l'app
(`watchedItemIds`), moins ceux qu'Emby a déjà en `Played` pour son compte
(vus ailleurs). C'est la fonction pure `computeMissedIds`.
- **Reprise** : le sweep existant des 20 s enregistre la position de l'hôte
(`group.resumePositionTicks`). La séance suivante démarre avec cette
position comme position de l'hôte, que `Player.ts` utilise déjà pour
placer la première image.
- **Nouveaux épisodes** : chaque lecture de la page, et chaque lancement de
séance, recharge la source. Les nouveaux items sont ajoutés à la fin ; les
items retirés de la source sont conservés dans l'historique
(`mergeItemLists`).
- **Accès** : ouverte à tous les comptes Emby.
- Rejoindre = devenir membre. Rejoindre la séance du groupe depuis le
lobby rend aussi membre de la watch party statique.
- N'importe quel membre peut lancer la séance ; celui qui la lance en
devient l'hôte.
- Quitter efface sa propre progression.
- Seul le créateur peut supprimer la watch party.
- **Contenus** : une série entière, **une seule saison**, ou une playlist
vidéo. Une playlist musicale est refusée.
- Série entière : tous les épisodes de toutes les saisons à partir de S1E1.
Les spéciaux (saison 0) sont exclus : Emby les trie en premier, et une
séance commencerait par eux (`CatalogService.seriesQueueFrom`). Les
lounge parties créées avant ce changement gardent leurs spéciaux déjà
en liste.
- Saison : uniquement les épisodes de cette saison
(`CatalogService.seasonQueue`, par `SeasonId`, sinon par numéro de
saison). La source garde `seriesId` et `seasonNumber`, car les
épisodes sont récupérés par série puis filtrés. Nom par défaut :
« Série - Saison », avec l'affiche de la série.
## API (`server/src/index.ts`)
| Méthode | Route | Rôle |
|---|---|---|
| GET | `/api/static-parties` | liste, avec le nombre de manqués de l'utilisateur et la séance en cours |
| POST | `/api/static-parties` | `{sourceId, name?}` : série, saison, épisode (sa série) ou playlist |
| GET | `/api/static-parties/:id` | détail : items + état groupe / utilisateur, membres, `missedItemIds` |
| POST | `/:id/join`, `/:id/leave` | devenir membre / quitter |
| DELETE | `/:id` | supprimer (créateur uniquement) |
| POST | `/:id/session` | lancer la séance du groupe, ou rejoindre celle en cours, puis renvoyer `{partyId}` |
| POST | `/:id/items/:itemId/watched` | marquer comme vu |
| POST | `/:id/catch-up` | `{itemIds, mode: 'solo'\|'party'}`, renvoie `{partyId}` |
## Persistance
- `StaticPartyStore` écrit **un fichier JSON par watch party** :
`${DATA_DIR}/static-parties/<id>.json`, au format `{version: 1, party}`.
- `DATA_DIR` vaut `./data` par défaut, et `/data` dans l'image Docker.
`docker-compose.yml` y monte `./data`.
- Un fichier par party limite les dégâts : une écriture ne touche que la
party modifiée, et un fichier corrompu ne coûte que cette party-là.
- L'écriture est atomique (`.tmp` puis `rename`) et debouncée (2 s) party
par party. Elle est flushée de façon synchrone sur `SIGTERM`/`SIGINT`.
- Au démarrage, un `.tmp` restant (écriture interrompue) est supprimé ; le
vrai fichier, s'il existe, est la dernière version complète.
- Un fichier corrompu (ou dont le nom ne correspond pas à l'id) est
renommé en `.corrupt` et ignoré ; les autres parties se chargent
normalement. Le serveur ne crashe jamais pour ça.
- Supprimer une party supprime son fichier et annule toute sauvegarde en
attente pour elle.
- Migration : l'ancien fichier unique `${DATA_DIR}/static-parties.json` de
la première version est éclaté en un fichier par party au premier
démarrage, puis renommé en `.migrated` (conservé, pas supprimé).
- Pas de SQLite : l'image tourne en Node 20 (pas de `node:sqlite`), une
dépendance native serait une première dans l'image, et les données sont
minuscules.
- `livePartyId` n'est jamais persisté : un redémarrage termine toutes les
séances, et la reprise se fait depuis la position enregistrée.
## Client
- `views/staticParty/` (route `/static/:id`) contient :
- l'en-tête, avec la progression du groupe et le bouton lancer / rejoindre
la séance ;
- « Tu as manqué », avec pour chaque élément cases à cocher, *Regarder en
solo* et *Marquer comme vu*, plus deux actions groupées : rattraper en
solo, ou lancer une watch party de rattrapage ;
- les membres ;
- la liste complète des items.
- Page série (`views/series`) :
- en-tête : « Lancer toute la série » (watch party) et « Créer une lounge
party » (série entière) ;
- au-dessus des épisodes de l'onglet de saison sélectionné : « Lancer
cette saison » (watch party) et « Lounge party de cette saison ».
- « Créer une lounge party » sur `views/playlist` (vidéo uniquement).
- Onglet dédié « Lounge parties » (sidebar et menu mobile), route
`/static` (`views/staticParties/`) : une carte par watch party avec la
progression, le prochain élément, un badge « N manqués » et une pastille
« séance en cours ». L'onglet reste actif sur la page d'une party.
- Séparation avec les watch/listen parties classiques : la liste « Parties
en cours » de l'accueil exclut les séances de lounge party
(`staticPartyId` non nul) ; elles ne s'affichent que dans leur onglet.
## Limites connues
- « Vu » reste une heuristique : passer à la suite compte comme vu, comme
pour le flag `Played` d'Emby. Il n'y a pas de seuil de pourcentage.
- La liste et le détail relisent la source Emby à chaque affichage (une
requête par watch party pour la liste). C'est acceptable à l'échelle d'un
serveur perso ; à mettre en cache si la liste grossit.
- Plusieurs watch parties statiques peuvent exister sur la même série
(des groupes différents) : c'est voulu, sans déduplication.