Compare commits

..

No commits in common. "master" and "fix/mobile-nav-swipe-close" have entirely different histories.

63 changed files with 157 additions and 4555 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`.
@ -366,52 +361,6 @@ Then put it behind your existing reverse proxy like everything else
- [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

@ -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

@ -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> {
@ -150,9 +144,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;
@ -233,9 +224,6 @@ export function connectParty(partyId: string, callbacks: PartyConnectionCallback
next() {
socket.emit('next');
},
itemEnded() {
socket.emit('itemEnded');
},
requestPause() {
socket.emit('requestPause');
},

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

@ -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

@ -13,7 +13,6 @@
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. */
.musicBar {
position: relative; /* containing block for .musicBarQueue's own position: absolute - see its comment */
background: var(--bg-header);
border-top: 1px solid rgba(255, 255, 255, .08);
box-shadow: 0 -4px 16px rgba(0, 0, 0, .3);
@ -26,20 +25,8 @@
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. */
::before something to size itself against. */
.musicBarProgressTrack {
grid-column: 1 / -1;
position: relative;
height: 3px;
background: rgba(255, 255, 255, .12);
@ -259,26 +246,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 +267,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 {
@ -365,62 +323,13 @@
.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; }
/* Positioned + given a real z-index only here: a normal-flow sibling
otherwise loses to .musicBarPanel's fixed overlay above it once
that's open (its own z-index applies regardless of DOM order) -
opening the queue while the now-playing panel is expanded needs
to still show it on top, matching desktop's existing "queue pops
up above the bar" behavior. */
.musicBarQueue { position: relative; z-index: 26; margin: 0; border-radius: 0; }
}
/* One queue entry - a plain flex row (image, then a shrinking/truncating

View file

@ -1,11 +1,6 @@
<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>

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
@ -107,7 +105,6 @@ export function initMusicBar(root: HTMLElement): void {
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')!;
@ -122,21 +119,6 @@ export function initMusicBar(root: HTMLElement): void {
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
@ -163,18 +145,14 @@ export function initMusicBar(root: HTMLElement): void {
// 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);
const seconds = fraction * currentDurationSeconds;
activeDriver!.currentTime = seconds;
updatePositionDisplay();
conn?.seekMusic(seconds);
}
trackProgress.addEventListener('pointerdown', (e) => {
@ -189,15 +167,6 @@ export function initMusicBar(root: HTMLElement): void {
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
@ -233,7 +202,6 @@ function updatePositionDisplay(): void {
// 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);
}
/**
@ -265,25 +233,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,7 +266,6 @@ export function leaveMusicParty(): void {
currentItemId = null;
lastMusicState = null;
latestMembers = [];
clearMediaSession();
conn?.leave();
conn = null;
@ -369,7 +319,6 @@ 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
@ -463,10 +412,6 @@ function loadTrack(track: MusicCurrentPublic, elapsedSeconds: number, isPaused:
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 || '');
if (isCasting) {
castTrack([track.itemName, track.artistName].filter(Boolean).join(' - ')).catch((err) => {
@ -576,11 +521,6 @@ function applyMusicState(music: MusicPublicState): void {
// "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
@ -604,7 +544,6 @@ function applyMusicState(music: MusicPublicState): void {
if (!music.current) {
currentItemId = null;
setTrackMeta('', 'Rien ne joue pour le moment.', '');
setMediaSessionIdle();
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

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

@ -79,8 +79,7 @@
position: sticky;
top: 0;
z-index: 3;
height: var(--mobile-topbar-h);
padding: 0 16px;
padding: 14px 16px;
background: var(--bg-header);
}

View file

@ -3,7 +3,6 @@
<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>
@ -42,7 +41,6 @@
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>

View file

@ -5,7 +5,6 @@ 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
@ -18,8 +17,6 @@ const SHOWS_ICON_SVG = '<svg viewBox="0 0 24 24"><rect x="3" y="5.5" width="18"
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
@ -55,12 +52,10 @@ 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;
// 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
@ -101,11 +96,8 @@ export async function renderShell(appEl: HTMLElement): Promise<HTMLElement> {
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);
const isHome = a.getAttribute('href') === '#/home';
a.classList.toggle('active', a.getAttribute('href') === location.hash || (isHome && !location.hash));
});
};
highlight();

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

@ -42,3 +42,8 @@
.trackRow button.trackIconBtn:hover { border-color: var(--text); color: var(--text); background: none; }
.trackRow button.trackIconBtn svg { width: 12px; height: 12px; }
@media (max-width: 600px) {
.trackRow { grid-template-columns: 24px 1fr; }
.trackRow .trackDuration, .trackRow button { grid-column: 2; justify-self: start; }
}

View file

@ -20,18 +20,9 @@
.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;
}

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 {
@ -61,9 +60,7 @@ const DRILL_DOWN: Record<string, { route: string; label: string }> = {
function posterTileHtml(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>` : ''}
@ -118,7 +115,7 @@ export function tileHtml(item: ItemDto): string {
}
/**
* 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 +131,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;
@ -171,42 +167,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

@ -6,7 +6,6 @@ import { addTrackToQueue, createParty, queueItem } 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';
@ -69,7 +68,7 @@ export async function renderAlbum(root: HTMLElement, params: RouteParams): Promi
// 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';
const startLabel = 'Écouter ensemble';
const addLabel = 'Ajouter à la file';
const startAlbumLabel = 'Écouter cet album';
const addAlbumLabel = "Ajouter l'album à la file";
@ -131,7 +130,7 @@ export async function renderAlbum(root: HTMLElement, params: RouteParams): Promi
btn.innerHTML = CHECK_ICON_SVG;
setTimeout(() => { btn.disabled = false; refreshLabels(); }, 1500);
} catch (err) {
showToast((err as Error).message, { variant: 'error' });
alert((err as Error).message);
btn.disabled = false;
refreshLabels();
}
@ -159,7 +158,7 @@ export async function renderAlbum(root: HTMLElement, params: RouteParams): Promi
launchBtnLabel.textContent = 'Ajouté !';
setTimeout(() => { launchBtn.disabled = false; refreshLabels(); }, 1500);
} catch (err) {
showToast((err as Error).message, { variant: 'error' });
alert((err as Error).message);
launchBtn.disabled = false;
refreshLabels();
}

View file

@ -6,7 +6,6 @@ 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
@ -87,7 +86,7 @@ export async function renderArtist(root: HTMLElement, params: RouteParams): Prom
launchBtnLabel.textContent = 'Ajouté !';
setTimeout(() => { launchBtn.disabled = false; refreshLabel(); }, 1500);
} catch (err) {
showToast((err as Error).message, { variant: 'error' });
alert((err as Error).message);
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>
<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,30 +17,17 @@ 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>';
root.innerHTML = '<p class="error">Impossible de charger les bibliothèques.</p>';
return;
}
if (cancelled) return;
const library = libraries.find((l) => l.id === params.id);
if (!library) {
@ -60,32 +45,20 @@ export function renderLibrary(root: HTMLElement, params: RouteParams): CleanupFn
? 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;
}
renderGroupedTilesInChunks(grid, items);
renderTilesInChunks(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();
};
wireSearchFilter(root.querySelector('#searchInput') as HTMLInputElement, root, '.tile');
}

View file

@ -8,12 +8,10 @@ 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 { 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');
});
@ -229,14 +227,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;
@ -423,7 +413,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,7 +436,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);
}
/**
@ -528,23 +516,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
@ -683,7 +661,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 +668,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 +679,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) {
@ -1063,10 +1037,8 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
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
@ -1214,42 +1186,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);
@ -1263,20 +1215,7 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
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 +1239,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 +1254,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>
</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

View file

@ -851,23 +851,6 @@ player against the new `itemId` on that message. A movie or a
standalone-started episode just gets a one-item queue, so this is a strict
superset of the old single-item behavior, not a separate code path.
**Later: a whole series or one season, not just "from this episode".**
`POST /api/parties` also accepts a `Series` id and a `Season` id, not
only an episode.
- **Series**: queues every season from S1E1. Specials (season 0) sort
first in Emby's list and are left out, so launching the whole series
builds the same queue as clicking S1E1. This is the pure
`CatalogService.seriesQueueFrom`, which the episode path now uses too.
- **Season**: queues only that season's episodes
(`CatalogService.seasonQueue`).
- **Where it's launched from**: the series page's "Lancer toute la
série" / "Lancer cette saison" buttons. A series tile in a library
also gets the normal "Lancer une watch party" button: `Series` joined
`WATCH_PARTY_TYPES`.
- **Lounge parties** take the same two sources (a whole series or one
season). See spec/static-watch-party.md.
### Player: language, subtitles, quality
`GET /api/catalog/item/:id/playback-options` reads `PlaybackInfo`'s
@ -3847,775 +3830,6 @@ mouse or dispatched `PointerEvent`) on a real phone - no device
reachable from this sandbox, same standing gap as this project's other
touch-specific work (the video/music seek bars' own drag handling).
## Toast notifications, replacing every blocking `alert()`
Asked for directly, prompted by the host-handoff message specifically
(`"L'hôte précédent a quitté la party - tu es maintenant l'hôte."`) -
a blocking `alert()` freezes the whole tab and demands a click to
dismiss, for what's genuinely just a heads-up. Once looking at that
one, every OTHER `alert()` in the client turned out to be the exact
same pattern (`alert((err as Error).message)` after a failed
create/join, or a bare `onError: (message) => alert(message)` on a
socket connection) - ten call sites total, across `Player.ts`,
`MusicBar.ts`, `tiles.ts`'s shared `wireTileButtons`, and every one of
`Home`/`Series`/`Playlist`/`Artist`/`Album`'s own "start a party"
buttons. All ten now go through one new module instead.
**New `toast.ts`/`toast.css`** - a plain module-level singleton, not
something Shell.ts mounts like `musicBar/MusicBar` (there's no
per-party state here, just somewhere to drop text), so it lazily
creates its own fixed-position container on first use and works from
literally any file that imports `showToast()`. Visually the same
light pill/auto-dismiss language `views/player/Player.css`'s own
`.requestBanner` already used for pause/resume requests, generalized
to `position: fixed` against the viewport (not `.playerWrap`
specifically) so it works from any page, stacked (a flex column with
`gap`, so an error and a moment-later status update don't clobber each
other) and layered above everything else in the app (`z-index: 100`,
comfortably clear of the highest anything else currently uses).
**Two variants, one restrained look** - `variant: 'info'` (default,
the host-handoff message, every status-only case) and `variant:
'error'` (every migrated `alert()` above) differ by exactly one thing,
a plain `border-left` accent in `var(--danger)`. Deliberately not a
red banner/fill even for errors: a failed "start a party" click is
still just a message, not something this app should suddenly shout
about - matching the restraint the rest of this app's UI already
favors (subtle background tints, not loud fills, for e.g.
`.avatarActionRequested`). No manual dismiss button, same as
`.requestBanner` before it - every toast fades itself out after
~4.5s, respecting `prefers-reduced-motion` (an instant show/hide
instead of the opacity/transform fade, same guard `.spinner` already
uses elsewhere).
Verified: full `typecheck`/`build` (client only). Confirmed there are
no `alert(` calls left in `client/src` beyond comments that merely
mention the word. The fade-in double-`requestAnimationFrame` trick
(needed because adding the "visible" class in the same frame as the
insert can get coalesced with it, silently skipping the transition)
was checked directly against a real element in a real browser rather
than assumed from the general pattern.
## Library/artist grid: the tile minimum was too wide for a real phone
Reported directly, against a real device: a phone in the
~360-393px-wide range (an ordinary width, not a rare one) was showing
exactly one tile per row in every library/artist grid. The grid's
`minmax(160px, 1fr))` floor was never wrong on its own terms - it's
just wider than what's left once `.mainContent`'s own side padding is
subtracted from a real phone's width, and there was no narrower-screen
override for it at all (unlike the sidebar/nav or the music bar,
`tiles.css`'s `.grid` had never gotten one).
Fixed with `minmax(clamp(100px, 28vw, 160px), 1fr)` - a fluid minimum,
not a `max-width` breakpoint. `clamp()` scales continuously with the
real viewport instead of jumping at one arbitrary width, so every size
in between "phone" and "desktop" (a tablet, a half-width browser
window) already gets a reasonable column count too, not just the two
sizes a breakpoint would have explicitly targeted - and it reduces to
today's exact `160px` past whatever width `28vw` alone would already
exceed it, so desktop is provably unchanged rather than just "probably
fine."
Verified: full `typecheck`/`build` (client only). Confirmed in a real
browser, not just computed by hand: an `<iframe>` sized to each of
320/360/375/393/430/768/1200px (an iframe gets its own independent
viewport for `vw` units, which this sandbox's own unreliable
`resize_window` emulation can't provide - see the mobile-nav/music-UI
sections above) with the real compiled `tiles.css` loaded inside it,
checking the actual rendered column count at each. Every phone width
from 320 to 393px now gets 2 columns (previously 1), 430px gets 3, and
768/1200px match what the old fixed `160px` floor already gave -
confirmed empirically, not assumed from the formula alone.
## The library header wasn't fully sticky: the title still scrolled away
"A sticky search bar" (above) only ever made the `<input>` itself
sticky - asked for directly again, this time to also keep the page
title (`.pageHeader`'s `<h1>`) pinned alongside it on `views/library`,
so both stay visible together while a long grid scrolls underneath
instead of just the search box.
New `.libraryHeader` wrapper (`views/library/Library.css` - the one
view of this shape that had no dedicated stylesheet yet, everything
borrowed from `tiles.css`/`base.css`) around the existing `.pageHeader`
+ `.searchBar` pair, `position: sticky; top: 0` on the wrapper itself
with its own opaque `var(--bg)` background - the `<h1>` has none of its
own, so without this, tiles scrolling underneath would show through the
title text once stuck. `.searchBar` keeps its own pre-existing
`position: sticky; top: 0` from `tiles.css` unchanged (still needed as-is
on `views/artist`/`views/album`, which don't get this wrapper); nested
inside an already-stuck ancestor that isn't itself scrolling, it's
inert rather than conflicting.
Scoped to `views/library` only, not `tiles.css`'s shared `.pageHeader` -
`views/home` uses that same class for a plain, non-sticky title, and
`views/artist`/`views/album` have since moved to their own hero headers
(see "Music UI rework" above), not `.pageHeader` at all.
One real collision worth catching: below 720px, `shell/Shell.css`'s own
`.mobileTopBar` is *also* `position: sticky; top: 0` (the app's brand
bar, sitting above `.mainContent` as a sibling, not an ancestor) - the
pre-existing sticky `.searchBar` already raced it for the same spot,
just narrowly enough (one slim input) not to get reported. Stacking a
whole title+search block at `top: 0` too would have properly covered
it, later in paint order. `.mobileTopBar` got an explicit
`height: var(--mobile-topbar-h)` (new `base.css` token, `49px` -
replacing its previous padding-derived auto height with a fixed one
anything else can reliably stack against), and `.libraryHeader` sticks
to `top: var(--mobile-topbar-h)` instead of `0` under that same 720px
breakpoint - directly below the brand bar instead of underneath it.
Verified: full `typecheck`/`build` (client only). No Emby session is
needed to see either state, so checked directly: real compiled
`app.css` loaded (same-origin, from the running dev server) into two
`<iframe>`s - 1000px and 375px wide, each its own independent viewport
for the 720px media query, the same workaround this sandbox's own
broken `resize_window` emulation has needed all session - reproducing
`Shell.html`'s `.mobileTopBar`/`.mainContent` plus `Library.html`'s new
markup and a couple dozen dummy tiles, then scrolled deep into the grid
in both and read `getBoundingClientRect()` back: on the 1000px frame
the title and search bar both stayed at the viewport top, tiles
scrolling underneath, opaque background confirmed; on the 375px frame
`.libraryHeader`'s top edge landed exactly at `.mobileTopBar`'s bottom
edge (49px) with zero overlap, both bars' text still visible together.
## Alphabet index: jump straight to a letter in a library
Asked for directly, alongside the sticky header above: a way to jump to
a letter instead of scrolling a long library by hand. Proposed two
mockups first (a right-edge A-Z rail either bare or paired with sticky
per-letter section headings, both with the drag-to-scrub-plus-big-letter-
bubble feel of iOS's own Contacts index) - the section-headings version
was picked.
New `alphaIndex.ts`, deliberately not folded into `tiles.ts`: it's a
`views/library`-only concern (the ask was specifically "dans les
bibliothèques"), and `tiles.ts` stays exactly what every other page
still wants - one flat, ungrouped grid.
**Grouping** buckets each item by its first letter, accents folded to
their base (`normalize('NFD')` + stripping the combining marks) so
"Écho" lands under "E" like a French speaker would expect, not off in
its own accent-specific bucket. No client-side re-sort needed -
`embyApi`'s `getLibraryItems`/`getArtists` both already ask Emby for
`SortBy: SortName`, so a run of same-bucket items is already contiguous.
**Layout**: a `.letterHeading` (`grid-column: 1 / -1`) inserted before
each new bucket, directly inside the *same* `.grid` tiles.ts's own tiles
already populate - not a separate mini-grid per letter, which would have
needed its own column-count logic duplicated 20+ times over. Its `top`
(both for its own `position: sticky` and as every jump target's
`scroll-margin-top`, so a jump doesn't land a heading half-hidden behind
the still-stuck header above it) is measured off `.libraryHeader` at
runtime - `getComputedStyle(libraryHeader).top` picks up whichever of
Library.css's two breakpoint values (`0` desktop, `--mobile-topbar-h`
mobile - see the sticky-header section above) is currently active
without this module needing to know that breakpoint exists at all.
`renderGroupedTilesInChunks` mirrors `renderTilesInChunks`'s own chunked
`insertAdjacentHTML` (see that function's comment - a real library can
run past a thousand items, and one synchronous `innerHTML` write for all
of them is exactly the "front freezes while it loads" failure this app
already fixed once elsewhere); a chunk boundary landing mid-bucket is
harmless, headings and tiles just interleave in whatever order they're
inserted.
**The rail** (`.alphaRail`, fixed to the right edge) is 27 buckets
(`#` + A-Z), each dimmed and inert if `items` has nothing in it. Tap or
drag it - `pointerdown`/`move`/`up` on one dedicated element with
`touch-action: none` and `setPointerCapture`, the identical shape
`shell/Shell.ts`'s own nav-sheet drag handle already uses - and a
center-screen bubble shows the current letter while dragging, the same
"big transient label during a drag" idea as that handle's own gesture,
applied here to a very different one. Scrolling normally (not
dragging) instead runs a small scroll-spy on `window`'s scroll event,
highlighting whichever bucket's heading is currently at the top.
Both the rail and every heading's sticky offset track two elements'
*real* rendered size rather than a guessed constant - a `ResizeObserver`
on `.libraryHeader` (a very long title could wrap to two lines) and on
`shell/Shell.html`'s `.bottomStack` (the music bar floats there whenever
a listen party is active, on desktop as well as mobile, and can
start/stop while this page stays open) - so the rail's own `bottom`
always stops exactly above whichever of those is currently showing,
instead of a listen party's music bar quietly covering "X"-"Z".
**Search** still uses tiles.ts's existing `wireSearchFilter` unchanged
(`.tile`-level show/hide) - `wireSectionSearchSync` just walks each
`.letterHeading`'s following siblings up to the next one and hides it
too if every tile in that run is now `.hide`'d, registered on the same
input *after* `wireSearchFilter` so it always sees the up-to-date
`.hide` state (same-element listener order = registration order).
Without it, searching down to matches in one bucket would leave every
other bucket's heading floating above nothing.
**Cleanup**: `renderLibrary` itself had to change shape - from a plain
`async function` to the synchronous-wrapper-around-an-async-IIFE
`views/player/Player.ts`'s `renderPartyPlayer` already uses, returning
a real `CleanupFn` (see router.ts) instead of the `Promise<void>` an
`async function` can't help but return. Both the rail's two
`ResizeObserver`s and its `window` scroll/resize listeners live past
whatever DOM `router.ts`'s next `innerHTML = ''` clears, so without an
explicit teardown they'd keep running (and, for the scroll listener,
keep doing real work) after leaving the page - the exact leak class
"Realtime transport" up above already covers this app getting bitten by
once.
Verified: full `typecheck`/`build` (client only). No Emby session
available here, so the interaction itself was checked by bundling
`alphaIndex.ts` alone (a throwaway esbuild entry point, deleted after)
into a synthetic page reproducing the real Shell/Library DOM and real
compiled CSS, then driven for real rather than assumed: a genuine
browser click on a rail letter (not a script-dispatched one - dispatched
`PointerEvent`s never register as an active pointer, so `setPointerCapture`
itself only ever proved out under real input) jumped and highlighted
correctly with zero console errors; dragging across several letters
updated the active one and the bubble live at each step; tapping an
empty letter highlighted it but left scroll position untouched; typing
into the search box hid exactly the buckets left with no visible tiles
and none of the others; growing `#musicBar`'s height live moved the
rail's bottom edge with it and back; and calling the returned cleanup
stopped that last reaction cold, confirming the observer was actually
disconnected rather than just idle.
## The alphabet index's headers were in the wrong order
Reported directly, against a real library: header letters coming out
as e.g. `A, B, T, B, L` instead of one run per letter in order. Not a
sorting bug in Emby, and not a bug in `renderGroupedTilesInChunks`'s own
"new bucket = new heading" logic either - both were already correct.
The actual mismatch: `getLibraryItems`/`getArtists` ask Emby to *order*
the array by `SortBy: SortName`, but never asked for each item's
`SortName` *value* back, so `alphaIndex.ts`'s `bucketFor` had nothing to
bucket by except the raw display `name` - and Emby's `SortName` strips
leading articles ("The", "Le", "L'", ...) for exactly the kind of
natural alphabetical browsing this index is for. An item like "The
Bunker" sorts (correctly) among the B's by Emby's own SortName, but
bucketing it by its literal display name instead computed "T" - a
spurious heading mid-run, immediately followed by a second, duplicate
"B" heading once the next real B-titled item came right back around.
Every other letter, with no such item, looked completely fine, which is
why this wasn't caught by the earlier synthetic verification (its fake
titles never happened to include an article-stripped one at all).
Fixed at the source instead of trying to reimplement Emby's own
article-stripping rules client-side (which language's articles, and
which are configurable server-side, isn't this app's business to know):
`getLibraryItems`/`getArtists` now also request `Fields: SortName`, new
`ItemDto.sortName` carries it through (falling back to `name` when a
caller doesn't request it - same "populated where it matters, harmless
default elsewhere" shape `durationTicks`/`indexNumber` already use), and
`alphaIndex.ts` buckets by *that*, not `name`. Bucketing by the exact
same key Emby used to order the array is what guarantees a bucket is
always one contiguous run - not bucketing by something that's usually
close enough.
Verified: full `typecheck`/`test`/`build` on both projects (147/147
server tests). Reproduced the exact reported shape first - a small
fake item list in Emby's own SortName order, one entry's display name
starting with a different letter than its `sortName` (`"The Bunker"`,
`sortName: "Bunker"`, positioned between two other B titles) - confirmed
it produced the same `A, B, T, B, L` heading sequence against the old
bucket-by-`name` code, then confirmed the fix collapses it to the
correct `A, B, L` against the exact same input.
## The music bar's queue popup: broken on desktop, unclosable on mobile
Two reports that turned out to be the same missing `position` rule.
`.musicBarQueue`'s own comment already said it should "pop up ABOVE the
bar" (like `Player.css`'s `.settingsPanel` above its gear button), but
the CSS itself never actually gave it a `position` on desktop at all -
a plain normal-flow block, first child of `.musicBar`. Toggling it
visible didn't float anything; it pushed `#musicBarPanel` (the actual
transport controls) out of the way, growing `.bottomStack`'s own
fixed-to-viewport box from the bottom up - reported directly as the
player looking broken the moment the queue was opened.
Mobile had a different-looking but related bug. `#musicBarPanel`
becomes a full-screen `position: fixed` overlay under 720px; a normal-
flow `.musicBarQueue` sibling would have rendered *behind* it, so it
had already been given `position: relative; z-index: 26` specifically
to still show on top once opened from inside that full-screen panel
(see the git history for that rule's own reasoning). That's a real fix
for visibility, but not for layout: `position: relative` leaves an
element sized and placed by normal flow, which put the queue somewhere
in the middle of the screen rather than covering it - directly
overlapping the very `#toggleQueueBtn` used to open it, the only way
that existed to close it again. Reported directly as "can't be closed."
Fixed with a `position` on both sides, matching each context's actual
shape - `.musicBar` gets `position: relative` (a containing block),
desktop's `.musicBarQueue` becomes `position: absolute; left: 0;
right: 0; bottom: 100%` (floats directly above the bar, out of normal
flow, without touching `#musicBarPanel`'s own layout at all), and
mobile's becomes `position: fixed; inset: 0` (a proper full-screen
overlay of its own, `max-height: none` overriding the base rule's
`50vh` so it isn't clipped) - the same "size for what this context
actually needs" as `#musicBarPanel`'s own fixed/inset:0 treatment right
next to it.
Also added, so closing it never again depends on precisely where the
toggle button happens to render underneath: a dedicated `#queueCloseBtn`
inside the queue's own header, plus a click-outside-closes listener on
`document` (`views/player/Player.ts`'s own `settingsPanel` pattern) for
the desktop popover case.
Verified: full `typecheck`/`build` (client only). No Emby session here,
so checked directly - `initMusicBar` called against a synthetic
`.bottomStack`/`#musicBar` reproducing the real Shell markup, real
compiled CSS, a fake queue list: on the (real, 1280px) desktop viewport,
opening the queue landed its bottom edge exactly on the bar's own top
edge (zero gap, zero overlap) without moving the bar at all, and both
the close button and a click on unrelated page content behind it closed
it correctly. On a 375px-wide `<iframe>` (this sandbox's own
`resize_window` viewport emulation is unreliable - see the mobile-nav/
music-UI/library-grid sections above), with the now-playing panel
opened first: the queue rendered as a genuine full-viewport overlay,
`elementFromPoint` at the close button's own center hit the close button
itself (not something on top of it), and closing the queue correctly
returned to the still-open now-playing panel rather than closing
everything.
## Library tile buttons rendering on top of the sticky header
Reported directly: scrolling a library, a tile's own action buttons
("Lancer une watch party" / "Voir sur Emby") would briefly render
*above* the sticky title+search header instead of scrolling behind it.
Both `.libraryHeader` and the alphabet index's `.letterHeading` were
`z-index: 2` - copied from `tiles.css`'s own pre-existing `.searchBar`,
sticky at that same value long before either of them existed. What
that copy missed: a tile's own `.tileOverlay` (its hover-revealed
action buttons - always-visible rather than hover-revealed on a touch
device, see `tiles.css`'s `(hover: none)` block) is *also* `z-index: 2`.
Equal z-index is a tie, and a tie breaks toward whichever renders later
in the DOM - a tile, inside `#grid`, always after the header. The
original slim `.searchBar` never showed this in practice (a single
input has little vertical range to actually overlap a scrolling tile
during the transition); wrapping it into the much taller
`.libraryHeader` gave a scrolling tile far more room to pass directly
underneath before fully clearing it, is what actually surfaced it.
Fixed by raising `.libraryHeader` and `.letterHeading` to `z-index: 3`
- comfortably clear of `.tileOverlay`'s 2, and matching
`shell/Shell.css`'s own `.mobileTopBar` (the other sticky header
`.libraryHeader` already has to stack correctly under on mobile).
Verified: full `typecheck`/`build` (client only). Confirmed as a real
regression, not just reasoned about - real compiled CSS loaded into a
page reproducing `.libraryHeader` + a real `.grid` of tiles (their
`.tileOverlay` forced visible, simulating a touch device), scrolled so
a tile genuinely overlapped the header's own rect, then
`elementsFromPoint` read the actual paint order at that point: with
the header's z-index forced back to the old `2`, `.tileOverlay` topped
the stack exactly as reported; restored to `3`, the header (and the
search input inside it) topped it instead, `.tileOverlay` three layers
down, unreachable.
## The music bar's compact layout: the progress bar was never actually full-width
Reported directly, again, after the queue-popup fix above - the desktop
bar still didn't look right: the progress bar needed to span the full
width, with everything else in one row underneath.
`.musicBarProgressTrack`'s own comment already described it as "a thin
strip along the bar's own top edge" - the intent was always a full-width
strip above a row of controls, mirroring Spotify/YouTube Music's own
compact players. But `#trackProgress` (that element) is `.musicBarPanel`'s
*first* child inside a 3-column grid (`.musicBarPanelTrack`/
`.musicBarPanelTransport`/`.musicBarPanelSide`), with no `grid-column`
of its own - so grid auto-placement dropped it into column 1 of row 1
like any other item, squeezing it to that column's own width and
wrapping `.musicBarPanelSide` onto a second row underneath it instead.
Easy to miss at a glance: with no track loaded, `.musicBarPanelTrack`'s
own content (a small image + short placeholder text) still left column
1 wide enough that the squeeze didn't look obviously broken - see the
queue-popup section above for the *other* half of "cassé en desktop",
found and fixed first without this one.
Fixed with one line: `.musicBarProgressTrack { grid-column: 1 / -1; }`.
Spanning all 3 columns in row 1 is what pushes every sibling after it
(auto-placed, no changes needed to any of them) into row 2, across the
full width - track/transport/side exactly where the 3-column rule
already intended them, just no longer sharing row 1 with the progress
bar.
Verified: full `typecheck`/`build` (client only). Checked directly - a
synthetic page with real compiled CSS, `initMusicBar` called against a
reproduction of the real Shell markup, a fake track's name/artist/
position set so `.musicBarPanelTrack` has realistic content width. Read
back with `getBoundingClientRect()`: the progress bar's own width
matched `.musicBarPanel`'s full content width exactly (not the viewport,
which include its own outer padding); its bottom edge sat at or above
every other row's top edge; and the other three elements' vertical
ranges genuinely overlapped each other (not just similar `top` values,
which `align-items: center` legitimately varies by each one's own
content height) - confirming one shared row below, in the original
track/transport/side left-to-right order.
## An album's track rows collapsed to 3 lines on mobile for no reason
Reported directly: there was room to fit an album's track rows
(index/name/duration/a small play-or-queue button) on one line on
mobile, but `trackList.css`'s own `@media (max-width: 600px)` forced
`.trackRow` down to a 2-column grid and pinned both `.trackDuration`
and the button to that same second column - auto-placement then
stacked the two of them into their own rows underneath the track name,
three rows total instead of one.
Removed outright rather than tuned - the numbers don't support it
having ever been needed. `.trackRow`'s fixed overhead (28px index +
2×12px gaps + `auto`-sized duration/button + `12px` horizontal padding)
comes to roughly 155-165px, and even the narrowest common phone width
(320px) leaves comfortably more than that for `.mainContent`'s own
side padding plus a `1fr` name column - `.trackName`'s existing
`text-overflow: ellipsis` was already doing exactly the job this
breakpoint assumed it couldn't.
Verified: full `typecheck`/`build` (client only). Checked directly, not
assumed: real compiled CSS, a handful of `.trackRow`s (short and
deliberately overlong names) loaded into two `<iframe>`s at 320px and
375px - this sandbox's own `resize_window` viewport emulation is
unreliable, see the mobile-nav/music-UI sections above. At both widths
every row's index/name/duration/button sat on one line (their vertical
ranges genuinely overlapping, not just similar by coincidence), left to
right in that order, with the long names confirmed actually truncated
(`scrollWidth > clientWidth`) rather than merely happening to fit.
## A mobile install nudge for the PWA
Asked for directly: the PWA (see "Progressive Web App" above) existed,
but nothing ever told a mobile visitor it could be installed at all -
Chrome's own default mini-infobar is easy to miss/dismiss, and Safari
has no browser-driven prompt whatsoever.
New `pwaInstall.ts`, self-mounting like `toast.ts` (no per-route/
per-party state to attach) but with one real difference from that
module's own lazy-init pattern: `beforeinstallprompt` (Chrome/Edge/
Android WebView's event for this - not in `lib.dom.d.ts`, Safari/
Firefox never implement it) can fire before any UI exists to react to
it, and the browser never replays it for a listener that attached too
late. The listener itself is a top-level side effect of importing the
module - `shell/Shell.ts` does that (and calls the module's own
`initPwaInstallPrompt()`, after `initMusicBar`), and since ES module
imports all evaluate before an entry file's own top-level code runs,
that listener is live well before `app.ts`'s `boot()` even starts its
async login check, regardless of how soon the event actually fires.
Two real install paths exist, and the banner speaks to whichever one
applies:
- **Chrome/Edge/Android WebView**: `event.preventDefault()` stashes the
captured event (this banner replaces the browser's own default one);
clicking "Installer" calls the real `prompt()` and reads back
`userChoice` - only an outright 'accepted' removes the banner, since
the picker's own 'dismissed' just means "not right now", not "stop
asking" (that's what the banner's own close button is for).
- **iOS Safari**: no such event exists at all, ever - detected instead
by user-agent sniffing (there's no clean feature-detection for "is
this iOS Safari"), showing manual instructions ("appuie sur Partager
puis Sur l'écran d'accueil") with no button, since there's nothing to
programmatically trigger.
Mobile-only (`matchMedia('(max-width: 720px)')`, this app's own
existing breakpoint, re-checked on resize for a live rotate/unfold) and
suppressed entirely once already running installed
(`display-mode: standalone`, or `navigator.standalone` for iOS Safari's
own equivalent flag, which has no `display-mode` query at all). A
close click remembers "don't ask again" in `localStorage` - not
re-shown even against a fresh `beforeinstallprompt` on a later visit.
Rendered as a plain normal-flow banner at the very top of `<body>`,
before `#app` - not a fixed/sticky overlay - specifically to avoid
needing to coordinate yet another offset with `shell/Shell.css`'s own
already-stacked `.mobileTopBar`/`.libraryHeader` pair (see the sticky-
header sections above) or `alphaIndex.css`'s rail. It simply pushes the
app down by its own height and scrolls away with the page, the same as
a browser's own native install infobar would.
Verified: full `typecheck`/`build` (client only). No real device
install flow is triggerable in this sandbox (nothing can fabricate a
genuine browser installability signal), so checked everything around
that boundary directly instead: a synthetic `beforeinstallprompt`-
shaped event (real `Event`, stubbed `prompt`/`userChoice`) dispatched
into real compiled CSS/JS confirmed the banner appears with the
install button, that button calls the stub `prompt()` and removes the
banner only on an 'accepted' outcome, and that the close button both
removes it and blocks a *second* dispatched event from reopening it.
Overriding `navigator.userAgent` to an iPhone string confirmed the
iOS-flavored copy with no install button. A same-event dispatch into a
real ≥900px-wide `<iframe>` confirmed the banner never renders there
at all, regardless of the event firing.
## OS-level media controls (Windows' SMTC, macOS' Now Playing, ...)
Asked for directly: linking whatever's playing to Windows' own media
popup (the one media keys/the volume flyout show) - the standard
Media Session API (`navigator.mediaSession`), which the same code path
also surfaces to macOS' Now Playing widget and Android's notification
for free, on whichever OS/browser combination actually implements it
(Chromium family; Firefox has never implemented it at all).
New `mediaSession.ts`, a thin wrapper - every export is a one-line
`'mediaSession' in navigator` guard around the exact browser call, not
a new abstraction of its own - shared by `views/player/Player.ts`
(video) and `musicBar/MusicBar.ts` (a listen party), which can never
both be active at once (starting/joining either already leaves the
other first - see docs above), so there's no risk of the two fighting
over this one global.
**Metadata/position/playback state** hook into each player's own
existing single-source-of-truth functions rather than adding parallel
call sites everywhere: Player.ts's `updatePlayPauseBtn`/
`updateProgressBar` (already the one place `#playPauseBtn`'s icon and
the seek bar's fill/label get synced, from every driver-changed event,
the heartbeat, and the ticker alike) also call
`setMediaSessionPlaybackState`/`setMediaSessionPositionState` now;
MusicBar.ts's `setPlayIcon`/`updatePositionDisplay` do the same. Title/
artwork update at both places an item/track actually changes
(Player.ts's `init`/`onItemChanged`, MusicBar.ts's `loadTrack`) -
artist/album are left empty for video (meaningless there, same as this
app's own UI already treats them).
**Play/pause/seek** are wired once per party (not re-wired on state
changes) via `setMediaSessionActionHandlers` - the handler functions
themselves close over local state (`isHost`, `conn`, ...) and read it
fresh on every OS-triggered call, the same way any other closure in
either file already does, so a handler set at party-join time keeps
working correctly straight through a live host handoff without needing
to know that happened. Two real permission models, unchanged by this:
video's play/pause is host-only - a non-host's OS-level play/pause
routes to the exact same `requestPause`/`requestResume` (now factored
out so both the in-page buttons and this can call them) the request
toolbar already uses, never touching the video directly - and seeking
is gated on `isHost` the same way the in-page seek bar already is
(factored into a shared `seekTo`, reused by both the pointer handler
and this). Music has neither gate (no host concept at all - see
MusicQueueService's own reasoning), so its handlers act directly.
**"Next"** is handled differently from play/pause/seek because a
*registered* `nexttrack` handler is what makes the OS show that
control at all, regardless of whether clicking it would currently do
anything - so unlike the other three, its presence has to track
whatever the in-page equivalent's own visibility already tracks, via a
separate `setMediaSessionNextAction(fn | null)` called alongside it:
Player.ts's `#nextBtn` (`isHost && hasNextQueued`, in `becomeHost` and
`onItemChanged`) and MusicBar.ts's vote-skip button (`music.current`,
in `applyMusicState` - clicking the OS's own "next" casts one skip
vote, same as the in-page button, not an instant skip).
**Clearing**: `clearMediaSession()` (metadata, playback state, *and*
every action handler) runs on Player.ts's route cleanup and
MusicBar.ts's `leaveMusicParty` - an OS-level "now playing" card that
outlives the actual party would be actively wrong, not stale. A
lighter `setMediaSessionIdle()` (metadata/state only, action handlers
left alone) covers MusicBar.ts's own "queue ran empty but still in the
party" case instead - play/pause/seek stay meaningful the instant
something plays again, so there's nothing to re-wire there.
Verified: full `typecheck`/`build` (client only). `mediaSession.ts`
itself checked directly against the real API in a real Chromium
browser (this sandbox's own): metadata resolves to a genuine
`MediaMetadata` with the right fields and a resolved artwork URL (or
none for an empty one), playback state and position state both apply
without throwing, an invalid (non-positive/`NaN`) duration is silently
skipped rather than reaching the browser call that would throw on it,
action-handler registration doesn't throw with or without `onSeek`
supplied, and both `setMediaSessionIdle`/`clearMediaSession` reset
metadata to `null` and playback state to `'none'`. Player.ts's/
MusicBar.ts's own wiring is one-line calls into already-verified
functions at points each file already treats as its single source of
truth for that piece of state (see above) - not independently
exercised end to end against a live party (would need mocking the
entire socket/party layer for no more real signal than reading the
call sites already gives), and the actual OS-level popup itself - the
whole point - isn't something this sandbox can display or click
regardless. Worth a real Windows/Chrome check once deployed.
## Device takeover: both devices "on the session", neither one's controls working
Reported directly: switching a watch/listen party from one device to
another left both devices looking active, with no controls actually
transferring. The device-takeover mechanism itself - `SocketGateway`'s
"join" handler force-disconnecting the old device's socket once the new
one confirms - was already correct and already guarded against the
obvious failure mode (its own comment on the "leave"/"disconnect"
handlers: a stale connection's own eventual disconnect must not be
mistaken for a real leave). The actual bug was two gaps *around* that,
neither in the takeover mechanism itself.
**Gap 1 - the old device never reacted to being kicked.** Both
`Player.ts` and `MusicBar.ts` wired `onClose: () => {}` - a complete
no-op - to the connection's "disconnect" event. So the moment a takeover
force-disconnected the old device's socket, that device's own video/
hls.js instance (or the persistent music bar) just kept running exactly
as before: the socket was dead, but nothing told the local `<video>`/
`<audio>` to stop, and nothing navigated away or reset `isHost`. From
the old device's own screen, everything still looked (and for local-only
playback, sounded) fully live - exactly "both devices on the session."
Fixed: both now react to an *unprompted* close (one that didn't already
run this route/module's own explicit teardown - `leftIntentionally` in
Player.ts, `activePartyId` already being null in MusicBar.ts, both set
synchronously by that explicit teardown well before the async
"disconnect" this checks against ever arrives) by tearing down local
playback and leaving, the same as if the user had clicked "Quitter"
there themselves.
**Gap 2 - reacting to the kick had no safe way to do that.** The obvious
fix for gap 1 - call the same leave path "Quitter" already uses - ran
straight into a second, worse bug: `POST /api/parties/:partyId/leave`
removes a member by `userId` alone, with no equivalent of the socket
handlers' own staleness guard (there's no live connection for a REST
call to compare against). Calling it from the *old*, superseded device
would have ended the *new* device's membership too - silently undoing
the takeover it was supposed to be cleaning up after. Fixed by giving
this route the same guard via `deviceId` instead of a socket reference:
new `PartyService.isStaleDevice(member, deviceId)`, and `leaveParty()`
(client) now sends its own `getDeviceId()` for the server to compare.
Only ever blocks on positive evidence of a mismatch - a member with no
recorded deviceId yet, or a caller that sent none, still leaves exactly
as before this existed.
Verified: full `typecheck`/`test`/`build` on both projects (153/153
server tests - new coverage for `isStaleDevice` covering a matching
deviceId, a mismatched one, an unset member deviceId, an unset caller
deviceId, and an unknown member, each checked against the *reason* it
should or shouldn't block, not just the outcome). The client-side
onClose fix was checked against the exact reported scenario, not just
reasoned about: a fake `io()` standing in for Socket.IO (real `.on`/
`.emit`/a scriptable `.disconnect()` that fires real listeners) let
`joinMusicParty`/`leaveMusicParty` run for real. Firing `disconnect`
right after a real `leaveMusicParty()` call produced no toast and left
the bar hidden (no spurious "connection lost" message on an intentional
leave); firing it with no prior leave - the takeover-kick shape -
produced the "Connexion à la party interrompue" toast and correctly
tore the bar down. Player.ts's own `leftIntentionally` fix mirrors this
exact shape (confirmed by reading, not re-verified with a second live
harness - hls.js/a full player DOM would need mocking for no more
signal than the MusicBar case already gives, given both share the same
logic shape end to end).
## A track started directly never fell back to its album's artwork
Reported directly: the app struggles to find artist/album/track images.
Traced one concrete, confirmed cause: `imageUrlFor`'s own
`Audio -> AlbumId` fallback (added earlier specifically because a track
essentially never has its own Primary image, only its album usually
does) was silently never triggering for a track played directly - not
via its album or artist - because the raw item that decision runs
against, `CatalogService.getRawItem`, never requested `AlbumId` from
Emby in the first place. Emby's default field set for a single-item GET
omits it entirely, so `item.AlbumId` read `undefined` there regardless
of whether the track's actual album genuinely had one - `imageUrlFor`
fell through to the track's own id every time, for every party started
this way.
Every OTHER path that resolves a track's image already asks for it
correctly - `getAlbumTracks`/`getPlaylistItemsRaw` both already include
`AlbumId` in their own `Fields`, which is why launching a whole album/
artist/playlist, or a queue built from one, was never affected. Fixed
the one gap instead of guessing at a broader rewrite: `getRawItem` now
requests `Fields=AlbumId` too. Harmless for every non-Audio item it also
fetches (Movie/Series/Episode/...) - Emby just omits a field that
genuinely doesn't apply to them.
Not the whole story, most likely - a real Emby library can also
legitimately have no Primary image at all for a given artist or album
(nothing for this app to fall back to, no bug to fix), and this fix
only ever covers the one specific path (a track started directly) that
was provably missing a field it needed. Flagged directly rather than
overclaiming "images are fixed" - worth another, more specific report
(which item exactly, from where) if artist/album art still struggles
after this.
Verified: full `typecheck`/`test`/`build` (server only - `CatalogService.ts`
is server-side). New test confirms `getRawItem`'s actual outgoing
request includes `Fields=AlbumId` (a mocked `EmbyClient.fetch` capturing
the real path/query it was called with, not just asserting on the
already-separately-tested `imageUrlFor` logic downstream of it).
## The music bar's mobile now-playing screen didn't fit landscape
Reported directly, along with a fair general complaint: the mobile
now-playing overlay used mostly fixed sizes, and the one that mattered
most - the album art - was sized off `vw` (`min(220px, 60vw)`), the
viewport dimension a phone in landscape has *plenty* of. Stacked
vertically (art, then text, then the transport row, then the side row)
with that same art staying pinned near its 220px cap regardless of how
little vertical room was actually left, the screen ran well past a
typical landscape phone's height and needed to scroll to reach the
transport controls - not adapted to the rotation at all.
Fixed with a dedicated `@media (max-width: 720px) and
(orientation: landscape)` block, not by chasing every fixed value in
the portrait rules: the art moves beside the title/artist instead of
above them (`.musicBarPanelTrack` becomes `flex-direction: row`),
freeing most of the vertical space the portrait layout spends stacking
them - and every size that actually needs to respond to the rotation is
now capped by `vh` (`min(120px, 20vh)`), the dimension landscape
actually constrains, instead of `vw`. A few fixed margins shrink
alongside it in this same block, same reasoning: adapting to less
vertical room, not arbitrary tightening.
Deliberately NOT changed: `.musicBarPlayBig`'s 56px transport button
and the other icon buttons. A touch target's size answers to a
finger, not the screen - shrinking it in landscape to "adapt" would
trade a real usability problem (nothing to tap accurately) for a
cosmetic one, the opposite of the actual ask.
Verified: full `typecheck`/`build` (client only). Checked directly, not
just reasoned about - real compiled CSS with the real markup loaded
into `<iframe>`s sized to a wide (700x320) and a deliberately extreme,
narrow (650x280) landscape shape (this sandbox's own `resize_window`
viewport emulation doesn't reflect orientation reliably - iframes do,
same workaround as every other viewport-shaped check this session).
Both matched `(orientation: landscape)`, both switched
`.musicBarPanelTrack` to a real `row` layout with the art measurably to
the image's left, both image sizes tracked the shrinking `vh` cap
correctly (64px/56px respectively, not the old fixed 220px), and both
fit their entire content within the viewport's own height with zero
overflow - even the extreme 280px-tall case, which the pre-fix numbers
would have overflowed by well over 200px. A separate portrait check
(375x700) confirmed this landscape block changes nothing there: still
`column`, still the original 220px `vw`-capped art, unaffected.
## Static (persistent) watch parties
Asked for directly: a watch party built from a whole series or video
playlist that remembers the group's progress *and* each person's, so
someone coming back or joining late sees what they missed. Full design
in [`spec/static-watch-party.md`](../spec/static-watch-party.md). The
short version:
- **A new persistent entity, not a change to Party.**
`StaticPartyService` owns the group and member progress. Each session
is an ordinary live `Party` linked back through `party.staticLink`,
so the player, sync, shared stream, lobby and host handoff are all
reused unchanged. `PartyService` calls into `StaticPartyService`,
never the other way around.
- **This app's first on-disk state.** One JSON file per static party
(`StaticPartyStore`, `${DATA_DIR}/static-parties/<id>.json`). Writes
are atomic, debounced per party, and flushed on SIGTERM. It started
as a single file for every party; that was split, so a write only
touches the party that changed and a corrupt file costs only that
party. The old single file is migrated once, on first load. "Party
lifecycle" above still holds for ordinary parties.
- **Its own tab and its own name.** The UI calls these **Lounge
parties**, next to **Watch party** (classic video) and **Listen
party** (classic audio). The code keeps "static party". The "Lounge
parties" tab (`/static`) lists them. The home page's live "Parties en
cours" list (each card badged Watch/Listen party) leaves their
sessions out, so classic and lounge parties never share a list.
- **"Completed" gained a second trigger.** `PartyService
.completeCurrentItem` is now the one place an item counts as done. It
handles Emby Played, as before, plus static progress. It runs from
`advanceQueue`, and from a new host-only `"itemEnded"` socket event
for the *last* queued item. That item was previously never marked
Played at all, since there was no "next" to send for it.
- **Missed = group-completed − watched in-app − Emby Played**, computed
by the pure `computeMissedIds`. Each viewer's own Emby `UserData` is
read from the source re-fetch under their own session.
## Known limitations
- Playback always goes through `hls.js` against whatever Emby's
@ -4632,10 +3846,7 @@ short version:
resume), so two listeners' `<audio>` elements could drift apart slightly
during a long stretch with no such event - accepted for v1 given music's
more forgiving tolerance for exact sync compared to video+subtitles.
- No persistence for ordinary parties: server restart or long idle time
ends every one. Only static watch parties (see their own section) are
persisted - and even their live sessions end on a restart, resuming
from the last saved position.
- No persistence: server restart or long idle time ends every party.
- No free seeking within a music track (only vote-skip to the *next* one)
- not asked for, and would need its own protocol addition
(`MusicQueueService` has no notion of "jump to position X" today, only

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/embySessionService.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

@ -83,11 +83,4 @@ export class Logger {
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,10 +5,10 @@ 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';
@ -16,13 +16,8 @@ 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, EmbyItem, EmbySession, ItemDto, MediaType, MusicTrackInput, QueueItem, ServerToClientEvents } from './types';
const app = express();
app.set('trust proxy', 1); // behind openresty/nginx - needed for req.secure to reflect the real scheme
@ -258,7 +253,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 +267,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 +297,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 +313,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'));
@ -356,20 +351,6 @@ app.post('/api/parties', requireSession, asyncRoute(async (req, res) => {
// 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);
@ -386,8 +367,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 +410,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
@ -481,7 +456,11 @@ async function createPartyFromPlaylist(res: Response, session: EmbySession, play
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,45 +471,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 -
@ -608,12 +548,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 || {};
@ -639,12 +579,12 @@ app.post('/api/parties/:partyId/tracks', requireSession, (req, res) => {
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.' });
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 itemId = req.body?.itemId;
@ -668,12 +608,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,17 +615,10 @@ 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);
}
}
res.json({ ok: true });
});
@ -714,7 +641,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 +655,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
@ -1019,9 +723,6 @@ 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);
@ -1058,16 +759,6 @@ setInterval(() => {
}
}, 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
@ -425,11 +410,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 +429,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

@ -8,11 +8,10 @@ import type { EmbyItem, EmbySession, ItemDto, LibraryDto, MediaType, PlaybackMar
// '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.');
@ -548,7 +503,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

@ -8,7 +8,6 @@ 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 +144,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
@ -210,7 +185,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 +206,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 +217,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 +253,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,7 +266,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

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,11 +29,6 @@ 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
@ -162,19 +157,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 +174,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 +183,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;
@ -371,25 +343,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 +399,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: [{
@ -314,34 +302,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

@ -155,41 +155,6 @@ describe('PartyService', () => {
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

@ -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.