Compare commits
67 commits
fix/player
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 5ceaa2e050 | |||
| 1a10e5bf57 | |||
| 363cf44ebe | |||
| 56f969ba78 | |||
| b0d2100210 | |||
| db3cf68843 | |||
| 58d51f5f74 | |||
| 1e437e4a91 | |||
| 2de375ac62 | |||
| 5182def5c9 | |||
| 04a9116f68 | |||
| 7fbf22419d | |||
| 076cd7a624 | |||
| 83592f1ee9 | |||
| 310dcd422e | |||
| 6bec09ae20 | |||
| b31377c1e9 | |||
| 5b2b832be7 | |||
| 3ae1fce806 | |||
| 071184e3c6 | |||
| 6582ad855b | |||
| 9cb25f4dc0 | |||
| d79b49f34d | |||
| e9e288c93c | |||
| 7f0dbc28e3 | |||
| c12e34e1e7 | |||
| 021f272a7d | |||
| 5ceceb262d | |||
| fa9d87b130 | |||
| f923fa24e7 | |||
| 658ad1d01d | |||
| d23fcc028b | |||
| fc2c4e551f | |||
| 292fec9c19 | |||
| 6b6a57a5f7 | |||
| b34e1ce8ff | |||
| 3ba0de341e | |||
| 0bd95b03b9 | |||
| 3fda483688 | |||
| 0a9b3fb498 | |||
| acb45c8a1a | |||
| d10a35e541 | |||
| afae977b49 | |||
| ff664f2c2b | |||
| 2772cc70d6 | |||
| 617a3d0d49 | |||
| e9b6a2541c | |||
| 2f10a46664 | |||
| b5a9a0bc7d | |||
| c8655fc7c3 | |||
| e65ba271fd | |||
| 098f447e57 | |||
| 40a897cf1b | |||
| 75a9fef18a | |||
| dfd109fc50 | |||
| d6a65ce8b7 | |||
| b922e4236f | |||
| 62afa88bb7 | |||
| b41437e23e | |||
| 7bf8197bfe | |||
| f680f72b1b | |||
| bc878c8d1c | |||
| ff2b9e46f2 | |||
| ceea7432ef | |||
| fafee359b9 | |||
| 57bb297bff | |||
| c141d0e8b7 |
83 changed files with 7410 additions and 314 deletions
|
|
@ -55,13 +55,16 @@ 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.ref_name }}"
|
||||
git fetch -q --depth 1 origin "${{ github.head_ref || github.ref_name }}"
|
||||
git checkout -q FETCH_HEAD
|
||||
|
||||
- name: Serveur - installer les dépendances
|
||||
|
|
|
|||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -21,6 +21,9 @@ server/dist/
|
|||
client/dist/
|
||||
*.tsbuildinfo
|
||||
|
||||
## Runtime data (static watch parties - see server/src/services/StaticPartyStore.ts)
|
||||
data/
|
||||
|
||||
## OS cruft
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
|
|
|||
88
README.md
88
README.md
|
|
@ -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)
|
||||
src/config.ts env-derived config (EMBY_SERVER_URL, PORT, DATA_DIR)
|
||||
src/types.ts shared interfaces (Party, ItemDto, the
|
||||
Socket.IO event maps, ...)
|
||||
src/Logger.ts party/stream lifecycle logging
|
||||
|
|
@ -245,9 +245,14 @@ build stage, so nothing needs building beforehand:
|
|||
|
||||
```bash
|
||||
docker build -f server/Dockerfile -t watchparty .
|
||||
docker run -d --name watchparty -p 3000:3000 -e EMBY_SERVER_URL=https://theater.media.kyuno.fr watchparty
|
||||
docker run -d --name watchparty -p 3000:3000 -v "$(pwd)/data:/data" -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`.
|
||||
|
|
@ -328,6 +333,85 @@ Then put it behind your existing reverse proxy like everything else
|
|||
as its own always-on-top bar, duplicating the cast icon and
|
||||
overlapping the new bottom bar in fullscreen) in favor of fully
|
||||
custom play/pause, a seek bar, and mute — see docs/architecture.md
|
||||
- [x] Sticky search bar on the library/artist/album pages, and letting
|
||||
anyone seek within the current music track (same no-host-check
|
||||
model pause/resume already had) — see docs/architecture.md
|
||||
- [x] Skip intro / jump to next episode at end credits, using Emby's own
|
||||
native marker detection (`EnableMarkerDetection` + its "Detect
|
||||
Episode Intros" task) rather than building fingerprinting
|
||||
ourselves — see docs/architecture.md
|
||||
- [x] Report each party member's own playback to Emby's real Sessions
|
||||
API (`/Sessions/Playing`, `/Progress`, `/Stopped`), so the admin
|
||||
dashboard sees watch-party streams the same way it sees a native
|
||||
client's — one session per member, under their own account — see
|
||||
docs/architecture.md
|
||||
- [x] Queue a whole album, or an artist's entire discography, in one
|
||||
action — resolved server-side (one atomic operation, not one
|
||||
request per track) the same way launching a playlist already
|
||||
expands into everything it contains — see docs/architecture.md
|
||||
- [x] Picture-in-picture for the video player (standard
|
||||
`requestPictureInPicture`, confirmed present on Android Chrome) —
|
||||
asked for directly for mobile, where there's no second window to
|
||||
put this app in while switching apps — see docs/architecture.md
|
||||
- [x] Installable as a PWA (manifest + icons, standalone display,
|
||||
no service worker — this app has nothing worth caching offline)
|
||||
— see docs/architecture.md
|
||||
- [x] Mobile navigation redesigned as a bottom sheet (Accueil /
|
||||
Bibliothèques / Listes de lecture) instead of a horizontally
|
||||
scrolling sidebar — see docs/architecture.md
|
||||
- [x] Music UI reworked to look like a music app instead of reusing
|
||||
movie-poster tiles — circular artist tiles, square album tiles,
|
||||
a real hero header, and a mobile "now playing" full-screen player
|
||||
— see docs/architecture.md
|
||||
- [x] Mobile menu's bottom sheet is now swipe-to-close (a real drag
|
||||
handle, not just a decorative grabber) instead of fighting the
|
||||
browser's own pull-to-refresh gesture — see docs/architecture.md
|
||||
- [x] Every blocking `alert()` (host handoff, every "couldn't start/join
|
||||
a party" error) replaced with a light, auto-dismissing toast — see
|
||||
docs/architecture.md
|
||||
- [x] Library/artist grid tiles shrink fluidly on a narrow phone instead
|
||||
of a fixed 160px floor collapsing to one column per row — see
|
||||
docs/architecture.md
|
||||
- [x] Library page title now stays pinned alongside the search bar while
|
||||
scrolling, instead of just the search bar — see docs/architecture.md
|
||||
- [x] Library alphabet index: sticky per-letter section headings plus a
|
||||
right-edge A-Z rail to jump straight to one, drag-to-scrub like
|
||||
iOS's own Contacts — see docs/architecture.md
|
||||
- [x] Fixed the alphabet index's headers landing in the wrong order —
|
||||
it was bucketing by display name instead of Emby's own SortName,
|
||||
so an article-stripped title ("The ...") threw a whole run off —
|
||||
see docs/architecture.md
|
||||
- [x] Fixed the music bar's queue popup — a missing `position` broke
|
||||
the desktop layout when opened and left it unclosable on mobile
|
||||
(its own close button was reachable now) — see docs/architecture.md
|
||||
- [x] Fixed library tile buttons rendering on top of the sticky header
|
||||
while scrolling — an equal z-index tie that a scrolling tile won
|
||||
— see docs/architecture.md
|
||||
- [x] Fixed the music bar's desktop layout for real — the progress bar
|
||||
was missing a grid-column span, so it (and not just the queue
|
||||
popup above) was still squeezed and wrapping controls onto a
|
||||
second row — see docs/architecture.md
|
||||
- [x] Fixed an album's track rows collapsing to 3 lines on mobile —
|
||||
there was always room for one, the breakpoint just assumed
|
||||
otherwise — see docs/architecture.md
|
||||
- [x] A mobile banner nudging an install of the PWA — the real
|
||||
Chrome/Android install prompt, manual instructions on iOS Safari
|
||||
(no such prompt exists there) — see docs/architecture.md
|
||||
- [x] Linked whatever's playing (video or music) to the OS's own media
|
||||
controls (Windows' SMTC, macOS' Now Playing, ...) — title/artwork,
|
||||
play/pause/seek/next — see docs/architecture.md
|
||||
- [x] Fixed switching a party between devices leaving both "on the
|
||||
session" with no working controls — the old device never reacted
|
||||
to being kicked, and the leave route had no way to tell a stale
|
||||
device apart from the current one — see docs/architecture.md
|
||||
- [x] Fixed a track started directly (not via its album/artist) never
|
||||
falling back to its album's artwork — see docs/architecture.md
|
||||
(probably not the whole "images are hard to find" story — flagged
|
||||
there)
|
||||
- [x] Fixed the music bar's mobile now-playing screen not fitting a
|
||||
phone rotated to landscape — the art now sits beside the title
|
||||
instead of above it, sized off the viewport height instead of a
|
||||
fixed width-based cap — see docs/architecture.md
|
||||
- [ ] Try a full party end-to-end with two real accounts (still the main gap)
|
||||
- [ ] Try casting to a real Chromecast (only the SDK's own bootstrap is
|
||||
confirmed here — no real device/session)
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
// npm run watch -> rebuilds on save, for local dev (see README's
|
||||
// Running locally section)
|
||||
import * as esbuild from 'esbuild';
|
||||
import { copyFileSync, mkdirSync } from 'fs';
|
||||
import { copyFileSync, cpSync, mkdirSync } from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
|
|
@ -20,6 +20,11 @@ const outdir = path.join(here, 'dist');
|
|||
|
||||
mkdirSync(outdir, { recursive: true });
|
||||
copyFileSync(path.join(here, 'src', 'index.html'), path.join(outdir, 'index.html'));
|
||||
// PWA manifest + icons (manifest.webmanifest, icons/*) - see
|
||||
// docs/architecture.md's "Progressive Web App" section. Copied wholesale
|
||||
// rather than named file-by-file like index.html above, so adding another
|
||||
// icon later needs no build.mjs change.
|
||||
cpSync(path.join(here, 'src', 'public'), outdir, { recursive: true });
|
||||
|
||||
const buildOptions = {
|
||||
entryPoints: [path.join(here, 'src', 'app.ts')],
|
||||
|
|
|
|||
81
client/src/alphaIndex.css
Normal file
81
client/src/alphaIndex.css
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
/* ---- 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; }
|
||||
}
|
||||
218
client/src/alphaIndex.ts
Normal file
218
client/src/alphaIndex.ts
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
// 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);
|
||||
};
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@
|
|||
// module just wraps fetch calls to our own /api/* routes. See
|
||||
// docs/architecture.md for why this changed from the first version (CORS,
|
||||
// plus not wanting the browser to hold an Emby token at all).
|
||||
import type { ItemDto, LibraryDto, PlaybackOptionsDto, SeasonDto } from '../types';
|
||||
import type { ItemDto, LibraryDto, PlaybackMarkersDto, PlaybackOptionsDto, SeasonDto } from '../types';
|
||||
|
||||
async function apiFetch<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const res = await fetch(path, {
|
||||
|
|
@ -116,6 +116,10 @@ export function getPlaybackOptions(itemId: string): Promise<PlaybackOptionsDto>
|
|||
return apiFetch(`/api/catalog/item/${itemId}/playback-options`);
|
||||
}
|
||||
|
||||
export function getPlaybackMarkers(itemId: string): Promise<PlaybackMarkersDto> {
|
||||
return apiFetch(`/api/catalog/item/${itemId}/markers`);
|
||||
}
|
||||
|
||||
/** The party's own shared Emby stream (server/src/services/
|
||||
* PartyStreamService.ts) - one negotiated PlaybackInfo session reused
|
||||
* by every member, instead of each viewer's own <video>/<audio>
|
||||
|
|
|
|||
|
|
@ -39,8 +39,14 @@ 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' });
|
||||
return apiFetch(`/api/parties/${partyId}/leave`, { method: 'POST', body: JSON.stringify({ deviceId: getDeviceId() }) });
|
||||
}
|
||||
|
||||
export function getParty(partyId: string): Promise<PartyPublicState> {
|
||||
|
|
@ -61,6 +67,21 @@ export function addTrackToQueue(partyId: string, track: MusicTrackInput): Promis
|
|||
return apiFetch(`/api/parties/${partyId}/tracks`, { method: 'POST', body: JSON.stringify(track) });
|
||||
}
|
||||
|
||||
/**
|
||||
* Music only: queues `itemId` into a party's shared queue, whatever it
|
||||
* turns out to be - a single track queues itself, an album or an
|
||||
* artist expands into every track inside it (see index.ts's
|
||||
* resolveMusicTracksForItem) - the "add to queue" half of
|
||||
* views/album's/views/artist's own whole-album/whole-discography
|
||||
* button (createParty(itemId) is the other half, for when there's no
|
||||
* party active yet - see Album.ts/Artist.ts). Distinct from
|
||||
* addTrackToQueue above, which takes an already-built track object a
|
||||
* caller has in hand rather than an id to resolve server-side.
|
||||
*/
|
||||
export function queueItem(partyId: string, itemId: string): Promise<PartyPublicState> {
|
||||
return apiFetch(`/api/parties/${partyId}/queue`, { method: 'POST', body: JSON.stringify({ itemId }) });
|
||||
}
|
||||
|
||||
/**
|
||||
* Mints a short-lived, party-scoped token a Chromecast can use to reach
|
||||
* this party's own shared stream routes, which otherwise only accept the
|
||||
|
|
@ -129,6 +150,9 @@ 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;
|
||||
|
|
@ -140,6 +164,10 @@ export interface PartyConnection {
|
|||
voteSkip(): void;
|
||||
/** Music only: no host check server-side, unlike sendPlaystate above. */
|
||||
setMusicPaused(isPaused: boolean): void;
|
||||
/** Music only: jump to `positionSeconds` in the current track - same
|
||||
* no-host-check treatment as setMusicPaused above (see
|
||||
* MusicQueueService.seek's own comment for why). */
|
||||
seekMusic(positionSeconds: number): void;
|
||||
/** Re-sends "join" with force:true after the user confirms they want to
|
||||
* disconnect their other device and take over here - call this from
|
||||
* onDeviceConflict's handler once the user says yes. */
|
||||
|
|
@ -205,6 +233,9 @@ export function connectParty(partyId: string, callbacks: PartyConnectionCallback
|
|||
next() {
|
||||
socket.emit('next');
|
||||
},
|
||||
itemEnded() {
|
||||
socket.emit('itemEnded');
|
||||
},
|
||||
requestPause() {
|
||||
socket.emit('requestPause');
|
||||
},
|
||||
|
|
@ -217,6 +248,9 @@ export function connectParty(partyId: string, callbacks: PartyConnectionCallback
|
|||
setMusicPaused(isPaused) {
|
||||
socket.emit('musicPlaystate', { isPaused });
|
||||
},
|
||||
seekMusic(positionSeconds) {
|
||||
socket.emit('musicSeek', { positionSeconds });
|
||||
},
|
||||
confirmDeviceTakeover() {
|
||||
socket.emit('join', { partyId, deviceId, force: true });
|
||||
},
|
||||
|
|
|
|||
74
client/src/api/staticParty.ts
Normal file
74
client/src/api/staticParty.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
// 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 }) });
|
||||
}
|
||||
|
|
@ -10,6 +10,8 @@ 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);
|
||||
|
|
@ -20,6 +22,8 @@ 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 +
|
||||
|
|
|
|||
|
|
@ -4,6 +4,24 @@
|
|||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Watch Party</title>
|
||||
|
||||
<!-- PWA - see docs/architecture.md's "Progressive Web App" section.
|
||||
theme-color/background_color both come straight from base.css's
|
||||
--bg-header/--bg (the manifest can't read CSS custom properties,
|
||||
so these are duplicated by hand - if that palette ever changes,
|
||||
update manifest.webmanifest to match). -->
|
||||
<link rel="manifest" href="manifest.webmanifest" />
|
||||
<meta name="theme-color" content="#221d24" />
|
||||
<link rel="icon" type="image/png" href="icons/favicon.png" />
|
||||
|
||||
<!-- iOS ignores the manifest above for installed-app chrome - Safari
|
||||
needs its own separate meta tags/link for the equivalent
|
||||
standalone/home-screen-icon behavior. -->
|
||||
<link rel="apple-touch-icon" href="icons/apple-touch-icon.png" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Watch Party" />
|
||||
|
||||
<link rel="stylesheet" href="app.css" />
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/hls.js/1.5.17/hls.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.7.5/socket.io.min.js"></script>
|
||||
|
|
|
|||
123
client/src/mediaSession.ts
Normal file
123
client/src/mediaSession.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
// 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.
|
||||
}
|
||||
}
|
||||
|
|
@ -6,13 +6,14 @@
|
|||
able to browse/add tracks while it plays). Sits above .mainContent, at
|
||||
a lower z-index than .playerWrap (20) so a video party's takeover still
|
||||
correctly covers it - moot in practice since joining a video party
|
||||
calls leaveMusicParty() first (see Player.ts), but cheap to get right. */
|
||||
calls leaveMusicParty() first (see Player.ts), but cheap to get right.
|
||||
The fixed-to-viewport positioning itself lives one level up, on
|
||||
Shell.css's .bottomStack wrapper - not here - so that on a narrow
|
||||
screen the mobile nav trigger right below it in that wrapper always
|
||||
sits flush above this bar's own actual rendered height (its content
|
||||
can wrap taller on a narrow screen), never a hardcoded guess at it. */
|
||||
.musicBar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 15;
|
||||
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);
|
||||
|
|
@ -20,26 +21,139 @@
|
|||
|
||||
/* A thin strip along the bar's own top edge, not inside .musicBarRow -
|
||||
the same "compact player" convention Spotify/YouTube Music use for
|
||||
their mini-players. Read-only (see MusicBar.ts's own comment) - no
|
||||
click/drag handling, just a visual position indicator. */
|
||||
their mini-players. Click/drag to seek (see MusicBar.ts) - the
|
||||
generous ::before hit-area below (not just the 3px visual track)
|
||||
matters most for drag on a touch screen, same trick
|
||||
views/player/Player.css's own .progressBar::before uses.
|
||||
`position: relative` is what gives that absolutely-positioned
|
||||
::before something to size itself against.
|
||||
`grid-column: 1 / -1` is the fix, not decoration: #trackProgress is
|
||||
.musicBarPanel's first child, and without an explicit span a 3-column
|
||||
grid places it in column 1 alone - squeezing it down to the width of
|
||||
.musicBarPanelTrack's own column and wrapping .musicBarPanelSide onto
|
||||
a second row underneath it, instead of the intended full-width strip
|
||||
with everything else in one row below. Reported directly - never
|
||||
looked obviously wrong at a glance since .musicBarPanelTrack's own
|
||||
content (a small image + short text) left column 1 wide enough to
|
||||
mostly hide the squeeze. Spanning this row is what pushes every
|
||||
sibling after it into row 2, across all 3 columns, with no change
|
||||
needed to them. */
|
||||
.musicBarProgressTrack {
|
||||
grid-column: 1 / -1;
|
||||
position: relative;
|
||||
height: 3px;
|
||||
background: rgba(255, 255, 255, .12);
|
||||
cursor: pointer;
|
||||
touch-action: none; /* this app's own pointer handlers drive the drag, not the browser's default touch scroll/zoom gestures */
|
||||
}
|
||||
|
||||
.musicBarProgressTrack::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
bottom: -8px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.musicBarProgressFill {
|
||||
height: 100%;
|
||||
width: 0%;
|
||||
background: var(--accent);
|
||||
transition: width .25s linear;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.musicBarRow {
|
||||
/* ---- Collapsed mobile preview (see MusicBar.html) - display:none by
|
||||
default since #musicBarPanel below is already always visible on
|
||||
desktop, nothing to collapse into a preview of there. Turned on
|
||||
under the 720px breakpoint further down. ---- */
|
||||
.mobileMiniBar {
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Passive - not draggable, unlike #trackProgress inside the panel below.
|
||||
Glanceable progress while collapsed is enough; dragging is one tap
|
||||
away once expanded, same as most real players' own mini-bar. */
|
||||
.mobileMiniProgress {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: rgba(255, 255, 255, .12);
|
||||
}
|
||||
|
||||
.mobileMiniProgressFill {
|
||||
height: 100%;
|
||||
width: 0%;
|
||||
background: var(--accent-bright);
|
||||
}
|
||||
|
||||
.mobileMiniArt {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
object-fit: cover;
|
||||
border-radius: 6px;
|
||||
background: var(--panel);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mobileMiniArt.noImage { visibility: hidden; }
|
||||
|
||||
.mobileMiniMeta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.mobileMiniMeta strong {
|
||||
font-size: 13px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ---- The actual controls - always in place on desktop (this IS the
|
||||
bar, same as before); a full-screen "now playing" overlay on mobile,
|
||||
opened via #mobileMiniBar above and closed via #panelCloseBtn - see
|
||||
the 720px breakpoint further down for the fixed-overlay/transform
|
||||
part of this, unchanged here. ---- */
|
||||
.musicBarPanel {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
.panelCloseBtn { display: none; }
|
||||
|
||||
.musicBarPanelTrack {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 16px;
|
||||
flex-wrap: wrap;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.musicBarPanelTransport {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.musicBarPanelSide {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* Smaller than the video player's presence row (Player.css/presence.css's
|
||||
|
|
@ -79,6 +193,60 @@
|
|||
|
||||
.musicBarPosition { font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||
|
||||
/* The one primary transport action, bigger and filled so it clearly
|
||||
outranks the plain small .iconBtn (skip-vote/queue) either side of
|
||||
it - same "obviously the main button" treatment
|
||||
views/player/Player.ts's own controls already get, instead of
|
||||
blending into a row of equally-weighted buttons. Shared between
|
||||
#playPauseBtn (inside .musicBarPanel) and #miniPlayPauseBtn (the
|
||||
collapsed mobile preview) - same look, same size, both wired to the
|
||||
exact same toggle in MusicBar.ts. */
|
||||
/* .iconBtn.musicBarPlayBig, not just .musicBarPlayBig: both classes are
|
||||
always present together on these two buttons (see MusicBar.html), so
|
||||
this compound selector's higher specificity is what reliably beats
|
||||
.iconBtn's own background/border-radius regardless of which of the
|
||||
two files esbuild happens to bundle later - two plain single-class
|
||||
selectors at equal specificity would otherwise depend on that bundle
|
||||
order, which isn't something to build a visual guarantee on. */
|
||||
.iconBtn.musicBarPlayBig {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 50%;
|
||||
background: var(--text);
|
||||
color: var(--bg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.iconBtn.musicBarPlayBig:hover { background: var(--accent-bright); color: #fff; }
|
||||
.musicBarPlayBig svg { width: 14px; height: 14px; display: block; }
|
||||
|
||||
/* #voteSkipBtn's own vote tally (see MusicBar.ts's updateVoteSkipLabel) -
|
||||
a badge instead of the old "Passer (vote) - X/Y" text label, now that
|
||||
the button itself is icon-only like every other transport control
|
||||
here. Hidden (see MusicBar.html) until there's an actual tally to
|
||||
show - matches every other conditionally-shown badge/pill elsewhere
|
||||
in this app rather than showing "0/1" before anyone's voted. */
|
||||
#voteSkipBtn { position: relative; }
|
||||
|
||||
.voteBadge {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
right: -6px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
border-radius: 99px;
|
||||
padding: 1px 4px;
|
||||
min-width: 14px;
|
||||
text-align: center;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* #castBtn while actively casting - same "clearly-on" treatment as
|
||||
views/player/Player.css's own .iconBtn[aria-pressed="true"] (ID-scoped
|
||||
here rather than a shared .iconBtn rule since #playPauseBtn's own
|
||||
|
|
@ -91,14 +259,26 @@
|
|||
}
|
||||
|
||||
/* Pops up ABOVE the bar (same pattern as Player.css's .settingsPanel above
|
||||
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. */
|
||||
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. */
|
||||
.musicBarQueue {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 100%;
|
||||
max-height: 50vh;
|
||||
margin: 0 16px;
|
||||
padding: 14px 14px 0;
|
||||
|
|
@ -112,6 +292,23 @@
|
|||
|
||||
.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 {
|
||||
|
|
@ -119,9 +316,111 @@
|
|||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.musicBarRow { justify-content: center; }
|
||||
.musicBarInfo { margin-right: 0; flex-basis: 100%; order: -1; text-align: center; }
|
||||
.musicBarQueue { margin: 0; border-radius: 0; }
|
||||
.mobileMiniBar { display: flex; }
|
||||
|
||||
/* Same fixed-overlay/slide-up-from-bottom pattern as Shell.css's
|
||||
.mobileNavSheet - see docs/architecture.md's "Mobile navigation".
|
||||
display stays flex unconditionally (not toggled) so the transform
|
||||
transition below actually animates instead of jumping. */
|
||||
.musicBarPanel {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 25;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 20px 20px calc(20px + env(safe-area-inset-bottom, 0px));
|
||||
background: var(--bg);
|
||||
transform: translateY(100%);
|
||||
transition: transform .25s ease;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.musicBarPanel.open { transform: translateY(0); }
|
||||
|
||||
.panelCloseBtn {
|
||||
display: flex;
|
||||
color: var(--muted);
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.musicBarPanelTrack {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.musicBarPanelTrack .musicBarImage {
|
||||
width: min(220px, 60vw);
|
||||
height: min(220px, 60vw);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.musicBarInfo { margin-right: 0; align-items: center; }
|
||||
.musicBarInfo strong { font-size: 19px; white-space: normal; }
|
||||
.musicBarPosition { margin-top: 6px; }
|
||||
|
||||
.musicBarPanelTransport { gap: 28px; margin-bottom: 28px; }
|
||||
.musicBarPanelTransport .musicBarPlayBig { width: 56px; height: 56px; }
|
||||
.musicBarPanelTransport .musicBarPlayBig svg { width: 22px; height: 22px; }
|
||||
|
||||
.musicBarPanelSide { margin-top: auto; justify-content: center; gap: 20px; }
|
||||
|
||||
/* A full-screen overlay of its own here, not desktop's floating popup
|
||||
(there's no "above the bar" to float over once .musicBarPanel is
|
||||
itself a full-screen overlay) - z-index: 26 (above .musicBarPanel's
|
||||
25) is still what puts it on top, but `position: relative` used to
|
||||
leave it in .musicBar's own normal flow, sized by `max-height: 50vh`
|
||||
inherited from the base rule above and positioned whichever the
|
||||
surrounding layout happened to allow - overlapping the very
|
||||
#toggleQueueBtn used to open it, with no other way to close a
|
||||
`position: relative` sibling that isn't just a small popup here.
|
||||
`inset: 0` + `max-height: none` makes it genuinely full-screen like
|
||||
.musicBarPanel itself, so its own #queueCloseBtn is always reachable
|
||||
regardless of queue length. */
|
||||
.musicBarQueue {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 26;
|
||||
max-height: none;
|
||||
margin: 0;
|
||||
border-radius: 0;
|
||||
padding: 20px 20px calc(20px + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
}
|
||||
|
||||
/* Rotated: the block above stacks the art on top of everything else and
|
||||
sizes it off the viewport's WIDTH (60vw) - reported directly as
|
||||
overflowing/needing to scroll once width and height swap roles in
|
||||
landscape, where width is the one dimension this screen never lacks.
|
||||
Same fixed-overlay itself, just laid out differently - the art moves
|
||||
beside the title/artist instead of above them (freeing most of the
|
||||
vertical stack this screen otherwise spends on it), and every size
|
||||
below is capped by `vh`, the scarce dimension here, not `vw`. Button
|
||||
sizes are deliberately left alone (56px .musicBarPlayBig, the same
|
||||
above) - a touch target sizes to a finger, not the screen, in any
|
||||
orientation. */
|
||||
@media (max-width: 720px) and (orientation: landscape) {
|
||||
.musicBarPanel {
|
||||
padding-top: 10px;
|
||||
padding-bottom: calc(10px + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
.panelCloseBtn { margin-bottom: 6px; }
|
||||
|
||||
.musicBarPanelTrack {
|
||||
flex-direction: row;
|
||||
text-align: left;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.musicBarPanelTrack .musicBarImage {
|
||||
width: min(120px, 20vh);
|
||||
height: min(120px, 20vh);
|
||||
}
|
||||
|
||||
.musicBarInfo { align-items: flex-start; }
|
||||
|
||||
.musicBarPanelTransport { margin-bottom: 8px; }
|
||||
}
|
||||
|
||||
/* One queue entry - a plain flex row (image, then a shrinking/truncating
|
||||
|
|
|
|||
|
|
@ -1,26 +1,68 @@
|
|||
<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>
|
||||
|
||||
<!-- Read-only - see MusicBar.ts's own comment on why this doesn't seek
|
||||
(no host to arbitrate a jump, and MusicQueueService has no "seek"
|
||||
concept server-side today - just play/pause/advance). -->
|
||||
<div id="trackProgress" class="musicBarProgressTrack"><div id="trackProgressFill" class="musicBarProgressFill"></div></div>
|
||||
<!-- Mobile-only collapsed preview (see MusicBar.css's 720px breakpoint) -
|
||||
#musicBarPanel below is already always visible on desktop, so this
|
||||
only ever renders there. Tapping anywhere on it except the play
|
||||
button opens the panel - see MusicBar.ts. -->
|
||||
<div id="mobileMiniBar" class="mobileMiniBar">
|
||||
<div class="mobileMiniProgress"><div id="miniProgressFill" class="mobileMiniProgressFill"></div></div>
|
||||
<img id="miniTrackImage" class="mobileMiniArt" alt="" onerror="this.classList.add('noImage')" />
|
||||
<div class="mobileMiniMeta">
|
||||
<strong id="miniTrackName">-</strong>
|
||||
<span class="hint" id="miniTrackArtist"></span>
|
||||
</div>
|
||||
<button class="iconBtn musicBarPlayBig" id="miniPlayPauseBtn" title="Lecture / pause" aria-label="Lecture / pause"></button>
|
||||
</div>
|
||||
|
||||
<div class="musicBarRow">
|
||||
<div id="presenceOverlay" class="presenceOverlay musicBarPresence"></div>
|
||||
<!-- Always in place on desktop (the bar itself, unconditionally visible
|
||||
like before); a full-screen "now playing" overlay on mobile,
|
||||
opened via #mobileMiniBar above and closed via #panelCloseBtn. -->
|
||||
<div id="musicBarPanel" class="musicBarPanel">
|
||||
<button class="linklike panelCloseBtn" id="panelCloseBtn" aria-label="Réduire">
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true">
|
||||
<path d="M6 9l6 6 6-6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Click/drag to seek - see MusicBar.ts's pointerdown/move/up
|
||||
handlers. No host check needed to enable this, unlike the video
|
||||
player's own seek bar: a listen party has no host at all - see
|
||||
MusicQueueService.seek's own comment for why that's the right
|
||||
model for this, not voteSkip's majority gate. -->
|
||||
<div id="trackProgress" class="musicBarProgressTrack"><div id="trackProgressFill" class="musicBarProgressFill"></div></div>
|
||||
|
||||
<div class="musicBarPanelTrack">
|
||||
<img id="trackImage" class="musicBarImage" alt="" onerror="this.classList.add('noImage')" />
|
||||
<div class="musicBarInfo">
|
||||
<strong id="trackName">-</strong>
|
||||
<span class="hint" id="trackArtist"></span>
|
||||
</div>
|
||||
|
||||
<span id="trackPosition" class="hint musicBarPosition">0:00 / 0:00</span>
|
||||
<button class="iconBtn" id="playPauseBtn" title="Lecture / pause" aria-label="Lecture / pause">▶</button>
|
||||
</div>
|
||||
|
||||
<div class="musicBarPanelTransport">
|
||||
<button class="iconBtn" id="voteSkipBtn" title="Passer (vote)" aria-label="Passer (vote)">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path d="M5 5v14l10-7Z" fill="currentColor"></path><rect x="17" y="5" width="2.5" height="14" rx="1" fill="currentColor"></rect></svg>
|
||||
<span id="voteBadge" class="voteBadge hide"></span>
|
||||
</button>
|
||||
<button class="iconBtn musicBarPlayBig" id="playPauseBtn" title="Lecture / pause" aria-label="Lecture / pause"></button>
|
||||
<button class="iconBtn" id="toggleQueueBtn" title="File d'attente" aria-label="File d'attente">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><circle cx="4.5" cy="7" r="1.3" fill="currentColor"></circle><circle cx="4.5" cy="12" r="1.3" fill="currentColor"></circle><circle cx="4.5" cy="17" r="1.3" fill="currentColor"></circle><line x1="9" y1="7" x2="20" y2="7" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"></line><line x1="9" y1="12" x2="20" y2="12" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"></line><line x1="9" y1="17" x2="20" y2="17" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"></line></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="musicBarPanelSide">
|
||||
<div id="presenceOverlay" class="presenceOverlay musicBarPresence"></div>
|
||||
<button class="iconBtn hide" id="castBtn" title="Caster sur un Chromecast" aria-label="Caster sur un Chromecast" aria-pressed="false">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
|
||||
<rect x="1" y="4" width="22" height="16" rx="2" fill="none" stroke="currentColor" stroke-width="2"></rect>
|
||||
|
|
@ -29,8 +71,9 @@
|
|||
<circle cx="4" cy="20" r="1.5" fill="currentColor"></circle>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="linklike" id="voteSkipBtn">Passer (vote)</button>
|
||||
<button class="linklike" id="toggleQueueBtn">File d'attente</button>
|
||||
<button class="linklike" id="leaveBtn">Quitter</button>
|
||||
<button class="iconBtn" id="leaveBtn" title="Quitter" aria-label="Quitter">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path d="M9 4.5H6a1.5 1.5 0 0 0-1.5 1.5v12A1.5 1.5 0 0 0 6 19.5h3" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"></path><path d="M13 8l4 4-4 4M9.5 12H20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"></path></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<audio id="barAudio"></audio>
|
||||
|
|
|
|||
|
|
@ -6,9 +6,11 @@ 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
|
||||
|
|
@ -34,6 +36,12 @@ import type { PlaybackDriver } from '../cast';
|
|||
* for music parties.
|
||||
*/
|
||||
|
||||
// #playPauseBtn/#miniPlayPauseBtn's two states - real SVGs (not the old
|
||||
// '▶'/'⏸' text glyphs, which never matched Player.ts's own icon-button
|
||||
// convention used everywhere else this app shows a play/pause control).
|
||||
const PLAY_ICON_SVG = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 4.5v15l14-7.5Z" fill="currentColor"></path></svg>';
|
||||
const PAUSE_ICON_SVG = '<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="5" y="4" width="5" height="16" rx="1" fill="currentColor"></rect><rect x="14" y="4" width="5" height="16" rx="1" fill="currentColor"></rect></svg>';
|
||||
|
||||
// See views/player/Player.ts's own HLS_CONFIG for why these are raised
|
||||
// well above hls.js's defaults (10s manifest/level/frag load timeouts) -
|
||||
// every track here also goes through CatalogService.proxyStream into a
|
||||
|
|
@ -94,21 +102,102 @@ export function initMusicBar(root: HTMLElement): void {
|
|||
|
||||
const audio = barEl.querySelector('#barAudio') as HTMLAudioElement;
|
||||
const playPauseBtn = barEl.querySelector('#playPauseBtn') as HTMLButtonElement;
|
||||
const miniPlayPauseBtn = barEl.querySelector('#miniPlayPauseBtn') as HTMLButtonElement;
|
||||
const castBtn = barEl.querySelector('#castBtn') as HTMLButtonElement;
|
||||
const voteSkipBtn = barEl.querySelector('#voteSkipBtn') as HTMLButtonElement;
|
||||
const toggleQueueBtn = barEl.querySelector('#toggleQueueBtn')!;
|
||||
const queuePanel = barEl.querySelector('#musicBarQueue')!;
|
||||
const queueCloseBtn = barEl.querySelector('#queueCloseBtn')!;
|
||||
const leaveBtn = barEl.querySelector('#leaveBtn')!;
|
||||
const trackProgress = barEl.querySelector('#trackProgress') as HTMLElement;
|
||||
const mobileMiniBar = barEl.querySelector('#mobileMiniBar')!;
|
||||
const musicBarPanel = barEl.querySelector('#musicBarPanel')!;
|
||||
const panelCloseBtn = barEl.querySelector('#panelCloseBtn')!;
|
||||
|
||||
activeDriver = localDriver(audio);
|
||||
|
||||
audio.addEventListener('play', () => { if (!suppressLocalEvents && conn) conn.setMusicPaused(false); });
|
||||
audio.addEventListener('pause', () => { if (!suppressLocalEvents && conn) conn.setMusicPaused(true); });
|
||||
playPauseBtn.addEventListener('click', () => conn?.setMusicPaused(!activeDriver!.paused));
|
||||
miniPlayPauseBtn.addEventListener('click', () => conn?.setMusicPaused(!activeDriver!.paused));
|
||||
voteSkipBtn.addEventListener('click', () => conn?.voteSkip());
|
||||
toggleQueueBtn.addEventListener('click', () => queuePanel.classList.toggle('hide'));
|
||||
queueCloseBtn.addEventListener('click', () => queuePanel.classList.add('hide'));
|
||||
// Desktop-popover convention (same as views/player/Player.ts's own
|
||||
// settingsPanel) - closes on any click that lands neither inside the
|
||||
// panel nor on the button that opens it. Harmless on mobile's
|
||||
// full-screen layout too (there's no "outside" to click there, but
|
||||
// #queueCloseBtn above is the real fix for that case - see
|
||||
// MusicBar.css's own comment on why the old position: relative left
|
||||
// that button unreachable once the queue was open).
|
||||
document.addEventListener('click', (e) => {
|
||||
if (queuePanel.classList.contains('hide')) return;
|
||||
const target = e.target as Node;
|
||||
if (!queuePanel.contains(target) && !toggleQueueBtn.contains(target)) {
|
||||
queuePanel.classList.add('hide');
|
||||
}
|
||||
});
|
||||
leaveBtn.addEventListener('click', () => leaveMusicParty());
|
||||
|
||||
// Mobile only (see MusicBar.css's 720px breakpoint - .musicBarPanel is
|
||||
// already always visible on desktop, so these two are simply never
|
||||
// clicked/reachable there). The mini bar itself is the tap target
|
||||
// (not just an icon on it) so there's a generous hit area on a touch
|
||||
// screen - #miniPlayPauseBtn's own click above stops that one
|
||||
// specific tap from also opening the panel via this listener.
|
||||
mobileMiniBar.addEventListener('click', (e) => {
|
||||
if ((e.target as HTMLElement).closest('#miniPlayPauseBtn')) return;
|
||||
musicBarPanel.classList.add('open');
|
||||
});
|
||||
panelCloseBtn.addEventListener('click', () => musicBarPanel.classList.remove('open'));
|
||||
|
||||
// Click/drag to seek - no host check, unlike the video player's own
|
||||
// seek bar, since a listen party has no host at all (see activeDriver's
|
||||
// own comment) - anyone dragging this updates their own driver
|
||||
// immediately (for instant visual feedback, same as the video player's
|
||||
// own seekFromPointerEvent) and broadcasts it, same "anyone, straight
|
||||
// away" treatment play/pause already has (see MusicQueueService.seek).
|
||||
// Pointer events (not click/drag) so mouse and touch share one
|
||||
// implementation, and setPointerCapture keeps the drag tracking even if
|
||||
// the pointer leaves the track's own bounds mid-gesture - same pattern
|
||||
// as Player.ts's progressBar.
|
||||
let isDraggingProgress = false;
|
||||
|
||||
function seekTo(seconds: number): void {
|
||||
if (currentDurationSeconds <= 0) return;
|
||||
activeDriver!.currentTime = Math.min(Math.max(0, seconds), currentDurationSeconds);
|
||||
updatePositionDisplay();
|
||||
conn?.seekMusic(activeDriver!.currentTime);
|
||||
}
|
||||
|
||||
function seekFromPointerEvent(e: PointerEvent): void {
|
||||
if (currentDurationSeconds <= 0) return;
|
||||
const rect = trackProgress.getBoundingClientRect();
|
||||
const fraction = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width));
|
||||
seekTo(fraction * currentDurationSeconds);
|
||||
}
|
||||
|
||||
trackProgress.addEventListener('pointerdown', (e) => {
|
||||
isDraggingProgress = true;
|
||||
trackProgress.setPointerCapture(e.pointerId);
|
||||
seekFromPointerEvent(e);
|
||||
});
|
||||
trackProgress.addEventListener('pointermove', (e) => {
|
||||
if (!isDraggingProgress) return;
|
||||
seekFromPointerEvent(e);
|
||||
});
|
||||
trackProgress.addEventListener('pointerup', () => { isDraggingProgress = false; });
|
||||
trackProgress.addEventListener('pointercancel', () => { isDraggingProgress = false; });
|
||||
|
||||
// Wired once, like Player.ts's own equivalent - no host concept here
|
||||
// (see activeDriver's own comment) so, unlike there, play/pause never
|
||||
// need to branch into a request-instead-of-act path.
|
||||
setMediaSessionActionHandlers({
|
||||
onPlay: () => conn?.setMusicPaused(false),
|
||||
onPause: () => conn?.setMusicPaused(true),
|
||||
onSeek: seekTo
|
||||
});
|
||||
|
||||
// Visible to any member the moment a Chromecast is on the network - not
|
||||
// gated on anything party-related, same as Player.ts's own castBtn
|
||||
// (see its comment: casting is available to any member, and this bar
|
||||
|
|
@ -126,15 +215,25 @@ export function initMusicBar(root: HTMLElement): void {
|
|||
// server updates, same "derive it client-side from a snapshot" approach
|
||||
// as Home's party-elapsed labels and the old MusicPlayer.ts - runs
|
||||
// continuously rather than only while a party is active (cheap: a
|
||||
// no-op while !currentItemId).
|
||||
positionTicker = window.setInterval(() => {
|
||||
// no-op while !currentItemId). Also called directly right after a local
|
||||
// seek (see seekFromPointerEvent above), for instant feedback instead
|
||||
// of waiting up to 500ms for this same tick to catch up.
|
||||
positionTicker = window.setInterval(updatePositionDisplay, 500);
|
||||
}
|
||||
|
||||
function updatePositionDisplay(): void {
|
||||
if (!currentItemId) return;
|
||||
const currentTime = activeDriver!.currentTime;
|
||||
const position = barEl!.querySelector('#trackPosition')!;
|
||||
position.textContent = `${formatDuration(currentTime)} / ${formatDuration(currentDurationSeconds)}`;
|
||||
const fill = barEl!.querySelector('#trackProgressFill') as HTMLElement;
|
||||
fill.style.width = currentDurationSeconds > 0 ? `${Math.min(100, (currentTime / currentDurationSeconds) * 100)}%` : '0%';
|
||||
}, 500);
|
||||
const percent = currentDurationSeconds > 0 ? `${Math.min(100, (currentTime / currentDurationSeconds) * 100)}%` : '0%';
|
||||
(barEl!.querySelector('#trackProgressFill') as HTMLElement).style.width = percent;
|
||||
// Same passive indicator on the collapsed mobile preview - see
|
||||
// MusicBar.css's .mobileMiniProgress. Kept a plain width sync rather
|
||||
// than its own pointer-seek wiring: dragging is one tap (expand the
|
||||
// panel) away, see #mobileMiniBar's own click handler.
|
||||
(barEl!.querySelector('#miniProgressFill') as HTMLElement).style.width = percent;
|
||||
setMediaSessionPositionState(currentDurationSeconds, currentTime);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -166,8 +265,25 @@ export function joinMusicParty(partyId: string, party: PartyPublicState): void {
|
|||
if (takeOver) conn?.confirmDeviceTakeover();
|
||||
else leaveMusicParty();
|
||||
},
|
||||
onError: (message) => alert(message),
|
||||
onClose: () => {}
|
||||
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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -199,12 +315,14 @@ export function leaveMusicParty(): void {
|
|||
currentItemId = null;
|
||||
lastMusicState = null;
|
||||
latestMembers = [];
|
||||
clearMediaSession();
|
||||
|
||||
conn?.leave();
|
||||
conn = null;
|
||||
|
||||
barEl!.classList.add('hide');
|
||||
barEl!.querySelector('#musicBarQueue')!.classList.add('hide');
|
||||
barEl!.querySelector('#musicBarPanel')!.classList.remove('open');
|
||||
|
||||
leaveParty(partyId).catch(() => {});
|
||||
}
|
||||
|
|
@ -238,11 +356,37 @@ async function castTrack(title: string): Promise<void> {
|
|||
* pause listeners already do (see initMusicBar), since casting here has
|
||||
* no host gate to check first (see activeDriver's own comment). */
|
||||
function onCastStateChanged(): void {
|
||||
const playPauseBtn = barEl!.querySelector('#playPauseBtn') as HTMLButtonElement;
|
||||
playPauseBtn.textContent = activeDriver!.paused ? '▶' : '⏸';
|
||||
setPlayIcon(!activeDriver!.paused);
|
||||
if (!suppressLocalEvents) conn?.setMusicPaused(activeDriver!.paused);
|
||||
}
|
||||
|
||||
/** Both play/pause buttons - the main one inside .musicBarPanel and the
|
||||
* collapsed mobile preview's own - always show the same state, so
|
||||
* every call site that used to touch just #playPauseBtn's textContent
|
||||
* updates both here instead of duplicating the innerHTML swap at each
|
||||
* one. */
|
||||
function setPlayIcon(isPlaying: boolean): void {
|
||||
const icon = isPlaying ? PAUSE_ICON_SVG : PLAY_ICON_SVG;
|
||||
(barEl!.querySelector('#playPauseBtn') as HTMLButtonElement).innerHTML = icon;
|
||||
(barEl!.querySelector('#miniPlayPauseBtn') as HTMLButtonElement).innerHTML = icon;
|
||||
setMediaSessionPlaybackState(isPlaying);
|
||||
}
|
||||
|
||||
/** Same pairing as setPlayIcon above, for the track metadata shown in
|
||||
* both .musicBarPanel and the collapsed mobile preview. */
|
||||
function setTrackMeta(imageUrl: string, name: string, artist: string): void {
|
||||
const trackImage = barEl!.querySelector('#trackImage') as HTMLImageElement;
|
||||
const miniTrackImage = barEl!.querySelector('#miniTrackImage') as HTMLImageElement;
|
||||
trackImage.src = imageUrl;
|
||||
trackImage.classList.remove('noImage');
|
||||
miniTrackImage.src = imageUrl;
|
||||
miniTrackImage.classList.remove('noImage');
|
||||
barEl!.querySelector('#trackName')!.textContent = name;
|
||||
barEl!.querySelector('#miniTrackName')!.textContent = name;
|
||||
barEl!.querySelector('#trackArtist')!.textContent = artist;
|
||||
barEl!.querySelector('#miniTrackArtist')!.textContent = artist;
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts casting - available to any listener, not just... there is no
|
||||
* "just", a listen party has no host (see activeDriver's own comment).
|
||||
|
|
@ -316,16 +460,13 @@ function endCastAndResync(): void {
|
|||
* (isCasting) delegated to castTrack/loadTrackLocal below.
|
||||
*/
|
||||
function loadTrack(track: MusicCurrentPublic, elapsedSeconds: number, isPaused: boolean): void {
|
||||
const trackImage = barEl!.querySelector('#trackImage') as HTMLImageElement;
|
||||
const trackName = barEl!.querySelector('#trackName')!;
|
||||
const trackArtist = barEl!.querySelector('#trackArtist')!;
|
||||
|
||||
currentItemId = track.itemId;
|
||||
currentDurationSeconds = track.durationSeconds || 0;
|
||||
trackImage.src = track.itemImageUrl || '';
|
||||
trackImage.classList.remove('noImage');
|
||||
trackName.textContent = track.itemName || '';
|
||||
trackArtist.textContent = [track.artistName, track.albumName].filter(Boolean).join(' - ');
|
||||
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) => {
|
||||
|
|
@ -403,37 +544,43 @@ function loadTrackLocal(track: MusicCurrentPublic, elapsedSeconds: number, isPau
|
|||
*/
|
||||
function updateVoteSkipLabel(): void {
|
||||
const voteSkipBtn = barEl!.querySelector('#voteSkipBtn') as HTMLButtonElement;
|
||||
const voteBadge = barEl!.querySelector('#voteBadge') as HTMLElement;
|
||||
const current = lastMusicState?.current;
|
||||
voteSkipBtn.disabled = !current;
|
||||
if (!current) {
|
||||
voteSkipBtn.textContent = 'Passer (vote)';
|
||||
voteBadge.classList.add('hide');
|
||||
return;
|
||||
}
|
||||
const connectedCount = latestMembers.filter((m) => m.connected).length || 1;
|
||||
const needed = Math.floor(connectedCount / 2) + 1;
|
||||
voteSkipBtn.textContent = `Passer (vote) - ${lastMusicState!.skipVotes}/${needed}`;
|
||||
voteBadge.textContent = `${lastMusicState!.skipVotes}/${needed}`;
|
||||
voteBadge.classList.remove('hide');
|
||||
}
|
||||
|
||||
function applyMusicState(music: MusicPublicState): void {
|
||||
lastMusicState = music;
|
||||
|
||||
const playPauseBtn = barEl!.querySelector('#playPauseBtn') as HTMLButtonElement;
|
||||
const miniPlayPauseBtn = barEl!.querySelector('#miniPlayPauseBtn') as HTMLButtonElement;
|
||||
const castBtn = barEl!.querySelector('#castBtn') as HTMLButtonElement;
|
||||
const queueList = barEl!.querySelector('#queueList')!;
|
||||
const trackName = barEl!.querySelector('#trackName')!;
|
||||
const trackArtist = barEl!.querySelector('#trackArtist')!;
|
||||
const trackImage = barEl!.querySelector('#trackImage') as HTMLImageElement;
|
||||
const audio = barEl!.querySelector('#barAudio') as HTMLAudioElement;
|
||||
|
||||
// Queue/vote-button/play-icon updates happen first, unconditionally,
|
||||
// deliberately BEFORE the track-loading branch below (see loadTrack's
|
||||
// own comment on why - same ordering as Player.ts/the old MusicPlayer.ts).
|
||||
playPauseBtn.textContent = music.current && !music.current.isPaused ? '⏸' : '▶';
|
||||
setPlayIcon(Boolean(music.current && !music.current.isPaused));
|
||||
playPauseBtn.disabled = !music.current;
|
||||
miniPlayPauseBtn.disabled = !music.current;
|
||||
// Nothing for startCasting to actually cast with an empty queue - same
|
||||
// "disable, don't just silently no-op" treatment as playPauseBtn above.
|
||||
castBtn.disabled = !music.current && !isCasting;
|
||||
updateVoteSkipLabel();
|
||||
// A *registered* nexttrack handler is what makes the OS show the
|
||||
// control at all (see setMediaSessionNextAction's own comment) - same
|
||||
// "disable, don't just silently no-op" case as playPauseBtn/castBtn
|
||||
// above, just with no disabled state of its own to set instead.
|
||||
setMediaSessionNextAction(music.current ? () => conn?.voteSkip() : null);
|
||||
|
||||
// Its own .queueRow, NOT views/album's shared .trackRow (see
|
||||
// styles/trackList.css) - that one is a CSS grid whose 4 columns
|
||||
|
|
@ -456,13 +603,13 @@ function applyMusicState(music: MusicPublicState): void {
|
|||
|
||||
if (!music.current) {
|
||||
currentItemId = null;
|
||||
trackName.textContent = 'Rien ne joue pour le moment.';
|
||||
trackArtist.textContent = '';
|
||||
trackImage.src = '';
|
||||
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
|
||||
(barEl!.querySelector('#trackProgressFill') as HTMLElement).style.width = '0%';
|
||||
(barEl!.querySelector('#miniProgressFill') as HTMLElement).style.width = '0%';
|
||||
} else if (music.current.itemId !== currentItemId) {
|
||||
loadTrack(music.current, music.current.elapsedSeconds, music.current.isPaused);
|
||||
} else {
|
||||
|
|
|
|||
BIN
client/src/public/icons/apple-touch-icon.png
Normal file
BIN
client/src/public/icons/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.6 KiB |
BIN
client/src/public/icons/favicon.png
Normal file
BIN
client/src/public/icons/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1,003 B |
BIN
client/src/public/icons/icon-192.png
Normal file
BIN
client/src/public/icons/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.8 KiB |
BIN
client/src/public/icons/icon-512-maskable.png
Normal file
BIN
client/src/public/icons/icon-512-maskable.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
client/src/public/icons/icon-512.png
Normal file
BIN
client/src/public/icons/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
16
client/src/public/manifest.webmanifest
Normal file
16
client/src/public/manifest.webmanifest
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"name": "Watch Party",
|
||||
"short_name": "Watch Party",
|
||||
"description": "Regardez et écoutez en même temps que les autres utilisateurs de votre serveur Emby.",
|
||||
"lang": "fr",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#151216",
|
||||
"theme_color": "#221d24",
|
||||
"categories": ["entertainment", "multimedia"],
|
||||
"icons": [
|
||||
{ "src": "icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
|
||||
{ "src": "icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
|
||||
{ "src": "icons/icon-512-maskable.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
|
||||
]
|
||||
}
|
||||
46
client/src/pwaInstall.css
Normal file
46
client/src/pwaInstall.css
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
/* ---- 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; }
|
||||
}
|
||||
108
client/src/pwaInstall.ts
Normal file
108
client/src/pwaInstall.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
// 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">×</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">×</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);
|
||||
}
|
||||
|
|
@ -62,20 +62,190 @@
|
|||
padding: 24px clamp(16px, 3vw, 48px) 80px;
|
||||
}
|
||||
|
||||
/* Sidebar becomes a horizontal scrollable topbar on narrow screens. */
|
||||
/* ==== Mobile navigation (<=720px) ====
|
||||
Replaces the sidebar outright - see docs/architecture.md's "Mobile
|
||||
navigation" section for why a bottom sheet was chosen over reshaping
|
||||
the sidebar into a horizontal bar. Every rule below the base
|
||||
`display: none` only ever applies once the 720px media query turns
|
||||
it back on, so none of this affects desktop. */
|
||||
.mobileTopBar,
|
||||
.mobileNavTrigger,
|
||||
.mobileNavScrim,
|
||||
.mobileNavSheet {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobileTopBar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 3;
|
||||
height: var(--mobile-topbar-h);
|
||||
padding: 0 16px;
|
||||
background: var(--bg-header);
|
||||
}
|
||||
|
||||
.mobileBrand { font-size: 16px; font-weight: 700; }
|
||||
|
||||
/* position:fixed, not sticky - #musicBar below already relies on a
|
||||
fixed wrapper to stay docked regardless of how far #mainContent's
|
||||
own (whole-page, not internally-scrolled) content runs. */
|
||||
.bottomStack {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 15;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.mobileNavTrigger {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
background: var(--bg-header);
|
||||
border: none;
|
||||
border-top: 1px solid rgba(255, 255, 255, .08);
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
padding: 13px;
|
||||
padding-bottom: calc(13px + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
.mobileNavScrim {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 20;
|
||||
background: rgba(0, 0, 0, .5);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity .2s;
|
||||
/* A touch starting here has no scrollable content of its own to
|
||||
"contain" (overscroll-behavior wouldn't apply) - this is what
|
||||
stops the browser reading a drag over the dimmed backdrop as a
|
||||
page-level gesture (pull-to-refresh) instead of just dismissing
|
||||
the sheet via the existing tap-to-close below. */
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.mobileNavScrim.open { opacity: 1; pointer-events: auto; }
|
||||
|
||||
.mobileNavSheet {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 21;
|
||||
max-height: 75vh;
|
||||
overflow-y: auto;
|
||||
/* Stops a drag that hits the top of the sheet's OWN scroll (its
|
||||
library grid can be taller than 75vh) from chaining past this
|
||||
element to the page underneath and surfacing as pull-to-refresh -
|
||||
complements touch-action:none on the dedicated grabber handle
|
||||
below, which instead covers a drag that never scrolls anything. */
|
||||
overscroll-behavior: contain;
|
||||
background: var(--bg-header);
|
||||
border-radius: 16px 16px 0 0;
|
||||
padding: 8px 16px calc(20px + env(safe-area-inset-bottom, 0px));
|
||||
transform: translateY(100%);
|
||||
transition: transform .22s ease;
|
||||
box-shadow: 0 -8px 24px rgba(0, 0, 0, .3);
|
||||
}
|
||||
|
||||
.mobileNavSheet.open { transform: translateY(0); }
|
||||
|
||||
/* Dragging (see Shell.ts) suspends the transition above for 1:1
|
||||
tracking under the finger, then this class restores it for the
|
||||
snap-back/close animation - same "instant while dragging, animated
|
||||
on release" split views/player/Player.css's own seek bar uses. */
|
||||
.mobileNavSheet.dragging { transition: none; }
|
||||
|
||||
/* Bigger drag/touch target than the pill it contains (36x4px is far
|
||||
below a real touch-target minimum) - see Shell.html/Shell.ts. */
|
||||
.sheetGrabberHandle {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 14px 0 10px;
|
||||
touch-action: none;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.sheetGrabber {
|
||||
width: 36px;
|
||||
height: 4px;
|
||||
border-radius: 99px;
|
||||
background: var(--muted);
|
||||
}
|
||||
|
||||
/* Accueil and the (conditional) Listes de lecture row both use this -
|
||||
same visual weight, bookending the "Bibliothèques" group between
|
||||
them, so the one part whose size actually varies (however many
|
||||
libraries Emby returns) reads as visually distinct from the two
|
||||
fixed rows around it. */
|
||||
.sheetPrimary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
background: var(--panel);
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
padding: 12px;
|
||||
margin: 4px 0;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.sheetPrimary.active { background: var(--accent); color: #fff; }
|
||||
|
||||
.sheetGrid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
margin: 4px 0 10px;
|
||||
}
|
||||
|
||||
.sheetItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
background: var(--panel);
|
||||
border-radius: 10px;
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
padding: 12px 10px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.sheetItem.active { background: var(--accent); color: #fff; font-weight: 600; }
|
||||
|
||||
.navIcon { width: 20px; height: 20px; flex-shrink: 0; display: block; }
|
||||
.navIcon svg { width: 100%; height: 100%; }
|
||||
|
||||
.sheetLogout {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
padding: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, .15);
|
||||
border-radius: 10px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.appShell { flex-direction: column; }
|
||||
.sidebar {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 10px 12px;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
.sidebarBrand { padding: 0 12px 0 0; }
|
||||
.sidebarNav { flex-direction: row; flex: none; }
|
||||
.navSectionLabel { display: none; }
|
||||
.sidebarLogout { margin: 0 0 0 12px; }
|
||||
.sidebar { display: none; }
|
||||
.mobileTopBar { display: flex; align-items: center; }
|
||||
.mobileNavTrigger { display: flex; }
|
||||
.mobileNavScrim { display: block; }
|
||||
.mobileNavSheet { display: block; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,11 +3,49 @@
|
|||
<div class="sidebarBrand">Watch Party</div>
|
||||
<nav class="sidebarNav" id="sidebarNav">
|
||||
<a href="#/home" class="navItem">Accueil</a>
|
||||
<a href="#/static" class="navItem">Lounge parties</a>
|
||||
<div class="navSectionLabel">Bibliothèques</div>
|
||||
<p class="hint" id="libStatus"><span class="spinner"></span>Chargement...</p>
|
||||
</nav>
|
||||
<button class="linklike sidebarLogout" id="logout"></button>
|
||||
<button class="linklike sidebarLogout logoutBtn" id="logout"></button>
|
||||
</aside>
|
||||
|
||||
<!-- Mobile-only (see Shell.css's 720px breakpoint) - the sidebar
|
||||
above is display:none there instead of reshaping into a
|
||||
horizontal bar. See docs/architecture.md's "Mobile navigation". -->
|
||||
<header class="mobileTopBar">
|
||||
<span class="mobileBrand">Watch Party</span>
|
||||
</header>
|
||||
|
||||
<main id="mainContent" class="mainContent"></main>
|
||||
|
||||
<!-- Wraps #musicBar so the trigger below always sits directly above
|
||||
whatever height the bar actually renders at - see Shell.css. -->
|
||||
<div class="bottomStack">
|
||||
<div id="musicBar" class="musicBar"></div>
|
||||
<button class="mobileNavTrigger" id="mobileNavTrigger" aria-expanded="false" aria-controls="mobileNavSheet">
|
||||
Parcourir
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">
|
||||
<path d="M6 9l6 6 6-6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mobileNavScrim" id="mobileNavScrim"></div>
|
||||
<div class="mobileNavSheet" id="mobileNavSheet">
|
||||
<!-- Bigger invisible drag target than the visible pill itself (see
|
||||
.sheetGrabberHandle) - touch-action:none so this app's own
|
||||
pointer handlers (Shell.ts) drive the drag instead of the
|
||||
browser's default touch handling, which otherwise reads a
|
||||
downward drag here as pull-to-refresh (the sheet sits right at
|
||||
the top of the page's own scroll position, same conflict a
|
||||
plain scrim tap-only close used to leave unsolved). -->
|
||||
<div class="sheetGrabberHandle" id="sheetGrabberHandle"><div class="sheetGrabber"></div></div>
|
||||
<a href="#/home" class="navItem sheetPrimary"><span class="navIcon" data-icon="home"></span>Accueil</a>
|
||||
<a href="#/static" class="navItem sheetPrimary"><span class="navIcon" data-icon="static"></span>Lounge parties</a>
|
||||
<div class="navSectionLabel">Bibliothèques</div>
|
||||
<div class="sheetGrid" id="mobileLibGrid"></div>
|
||||
<div id="mobilePlaylistsSlot"></div>
|
||||
<button class="linklike sheetLogout logoutBtn" id="mobileLogout"></button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -5,47 +5,169 @@ import './Shell.css';
|
|||
import html from './Shell.html';
|
||||
import { auth, getLibraries, logout } from '../api/embyApi';
|
||||
import { initMusicBar } from '../musicBar/MusicBar';
|
||||
import { initPwaInstallPrompt } from '../pwaInstall';
|
||||
import type { LibraryDto } from '../types';
|
||||
|
||||
// One icon per Emby CollectionType - only used by the mobile bottom
|
||||
// sheet (see docs/architecture.md's "Mobile navigation"); the desktop
|
||||
// sidebar stays plain text, unchanged. Same small inline-SVG-constant
|
||||
// convention as views/player/Player.ts's own icon strings.
|
||||
const HOME_ICON_SVG = '<svg viewBox="0 0 24 24"><path d="M3.5 11.5 12 4l8.5 7.5" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/><path d="M5.5 10v9a1 1 0 0 0 1 1H10v-5.5a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1V20h3.5a1 1 0 0 0 1-1v-9" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>';
|
||||
const MOVIES_ICON_SVG = '<svg viewBox="0 0 24 24"><rect x="3.5" y="6" width="17" height="13" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.8"/><path d="M3.5 6 7 3h3l-2.5 3M11 6l2.5-3h3L14 6m3.5 0 2.5-3" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>';
|
||||
const SHOWS_ICON_SVG = '<svg viewBox="0 0 24 24"><rect x="3" y="5.5" width="18" height="12" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.8"/><path d="M9 20.5h6M12 17.5v3" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>';
|
||||
const MUSIC_ICON_SVG = '<svg viewBox="0 0 24 24"><circle cx="8" cy="17" r="2.6" fill="none" stroke="currentColor" stroke-width="1.8"/><path d="M10.5 17V5.5l7-1.5v10" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/><circle cx="17.5" cy="14" r="2.6" fill="none" stroke="currentColor" stroke-width="1.8"/></svg>';
|
||||
const MUSICVIDEOS_ICON_SVG = '<svg viewBox="0 0 24 24"><rect x="3" y="5.5" width="18" height="13" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.8"/><path d="M10 9.5 15.5 12 10 14.5Z" fill="currentColor"/></svg>';
|
||||
const PLAYLISTS_ICON_SVG = '<svg viewBox="0 0 24 24"><circle cx="4.5" cy="7" r="1.3" fill="currentColor"/><circle cx="4.5" cy="12" r="1.3" fill="currentColor"/><circle cx="4.5" cy="17" r="1.3" fill="currentColor"/><line x1="9" y1="7" x2="20" y2="7" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/><line x1="9" y1="12" x2="20" y2="12" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/><line x1="9" y1="17" x2="20" y2="17" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>';
|
||||
// The "Lounge parties" tab (static watch parties) - a circular "resume" arrow.
|
||||
const STATIC_PARTIES_ICON_SVG = '<svg viewBox="0 0 24 24"><path d="M19.5 12a7.5 7.5 0 1 1-2.2-5.3" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/><path d="M19.5 4v4h-4" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/><path d="M10 9l5 3-5 3Z" fill="currentColor"/></svg>';
|
||||
// Emby could in principle add a CollectionType this app doesn't map yet
|
||||
// (server/src/services/CatalogService.ts's VISIBLE_COLLECTION_TYPES
|
||||
// would need updating first either way) - falls back to a plain folder
|
||||
// glyph rather than rendering an empty icon slot.
|
||||
const GENERIC_LIBRARY_ICON_SVG = '<svg viewBox="0 0 24 24"><rect x="3.5" y="5" width="17" height="14" rx="2" fill="none" stroke="currentColor" stroke-width="1.8"/></svg>';
|
||||
|
||||
const COLLECTION_ICONS: Record<string, string> = {
|
||||
movies: MOVIES_ICON_SVG,
|
||||
tvshows: SHOWS_ICON_SVG,
|
||||
music: MUSIC_ICON_SVG,
|
||||
musicvideos: MUSICVIDEOS_ICON_SVG,
|
||||
playlists: PLAYLISTS_ICON_SVG,
|
||||
};
|
||||
|
||||
/** One `<a>` per library, shared by the desktop sidebar (plain text)
|
||||
* and the mobile sheet (icon + text) - `withIcon` is the only
|
||||
* difference between the two call sites below. */
|
||||
function createLibraryLink(lib: LibraryDto, className: string, withIcon: boolean): HTMLAnchorElement {
|
||||
const a = document.createElement('a');
|
||||
a.href = `#/library/${lib.id}`;
|
||||
a.className = className;
|
||||
if (withIcon) {
|
||||
const icon = document.createElement('span');
|
||||
icon.className = 'navIcon';
|
||||
icon.innerHTML = COLLECTION_ICONS[lib.collectionType] || GENERIC_LIBRARY_ICON_SVG;
|
||||
a.appendChild(icon);
|
||||
}
|
||||
a.appendChild(document.createTextNode(lib.name));
|
||||
return a;
|
||||
}
|
||||
|
||||
export async function renderShell(appEl: HTMLElement): Promise<HTMLElement> {
|
||||
appEl.innerHTML = html;
|
||||
appEl.querySelector('#logout')!.textContent = `Se déconnecter (${auth.userName || ''})`;
|
||||
|
||||
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();
|
||||
|
||||
appEl.querySelector('#logout')!.addEventListener('click', async () => {
|
||||
// Both the sidebar's and the mobile sheet's logout button - same
|
||||
// label, same handler, one line each instead of a shared DOM node
|
||||
// fought over by two very differently-laid-out containers.
|
||||
appEl.querySelectorAll<HTMLButtonElement>('.logoutBtn').forEach((btn) => {
|
||||
btn.textContent = `Se déconnecter (${auth.userName || ''})`;
|
||||
btn.addEventListener('click', async () => {
|
||||
await logout();
|
||||
// Simplest reliable way back to a bare login screen: a real reload,
|
||||
// rather than hand-tearing-down every shell/router listener in place.
|
||||
location.hash = '';
|
||||
location.reload();
|
||||
});
|
||||
});
|
||||
|
||||
const nav = appEl.querySelector('#sidebarNav')!;
|
||||
const status = appEl.querySelector('#libStatus')!;
|
||||
const mobileLibGrid = appEl.querySelector('#mobileLibGrid')!;
|
||||
const mobilePlaylistsSlot = appEl.querySelector('#mobilePlaylistsSlot')!;
|
||||
try {
|
||||
const libraries = await getLibraries();
|
||||
status.remove();
|
||||
libraries.forEach((lib) => {
|
||||
const a = document.createElement('a');
|
||||
a.href = `#/library/${lib.id}`;
|
||||
a.className = 'navItem';
|
||||
a.textContent = lib.name;
|
||||
nav.appendChild(a);
|
||||
nav.appendChild(createLibraryLink(lib, 'navItem', false));
|
||||
// 'playlists' gets pulled out of the "Bibliothèques" group into
|
||||
// its own fixed row - see docs/architecture.md. Everything else
|
||||
// (movies/tvshows/music/musicvideos, whatever mix this server
|
||||
// actually has) lands in the grid, unchanged in count or order.
|
||||
if (lib.collectionType === 'playlists') {
|
||||
mobilePlaylistsSlot.appendChild(createLibraryLink(lib, 'navItem sheetPrimary', true));
|
||||
} else {
|
||||
mobileLibGrid.appendChild(createLibraryLink(lib, 'navItem sheetItem', true));
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
status.textContent = 'Bibliothèques indisponibles.';
|
||||
}
|
||||
|
||||
const highlight = () => {
|
||||
nav.querySelectorAll('.navItem').forEach((a) => {
|
||||
const isHome = a.getAttribute('href') === '#/home';
|
||||
a.classList.toggle('active', a.getAttribute('href') === location.hash || (isHome && !location.hash));
|
||||
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);
|
||||
});
|
||||
};
|
||||
highlight();
|
||||
window.addEventListener('hashchange', highlight);
|
||||
|
||||
// ---- Mobile bottom sheet - see Shell.css's "Mobile navigation". ----
|
||||
const navTrigger = appEl.querySelector('#mobileNavTrigger') as HTMLButtonElement;
|
||||
const navSheet = appEl.querySelector('#mobileNavSheet') as HTMLElement;
|
||||
const navScrim = appEl.querySelector('#mobileNavScrim')!;
|
||||
const closeMobileNav = () => {
|
||||
navSheet.classList.remove('open');
|
||||
navScrim.classList.remove('open');
|
||||
navTrigger.setAttribute('aria-expanded', 'false');
|
||||
};
|
||||
navTrigger.addEventListener('click', () => {
|
||||
const opening = !navSheet.classList.contains('open');
|
||||
navSheet.classList.toggle('open', opening);
|
||||
navScrim.classList.toggle('open', opening);
|
||||
navTrigger.setAttribute('aria-expanded', String(opening));
|
||||
});
|
||||
navScrim.addEventListener('click', closeMobileNav);
|
||||
// Any of the three zones (Accueil, a library, Listes de lecture) -
|
||||
// the sheet itself has no other clickable children, so this one
|
||||
// delegated listener covers all of them without a per-link handler.
|
||||
navSheet.addEventListener('click', (e) => {
|
||||
if ((e.target as HTMLElement).closest('.navItem')) closeMobileNav();
|
||||
});
|
||||
window.addEventListener('hashchange', closeMobileNav);
|
||||
|
||||
// Drag-to-dismiss on the grabber handle only - not the whole sheet,
|
||||
// which would fight the library grid's own scroll/tap targets (see
|
||||
// docs/architecture.md's "Mobile navigation, take two"). Reported
|
||||
// directly: without this, a scrim tap was the ONLY way to close the
|
||||
// sheet, and the instinctive swipe-it-down gesture instead fell
|
||||
// through to the browser's own pull-to-refresh (see Shell.css's
|
||||
// touch-action/overscroll-behavior on the scrim/sheet themselves -
|
||||
// this is the other half, giving that swipe something to actually do).
|
||||
const CLOSE_DRAG_THRESHOLD_PX = 90;
|
||||
const grabberHandle = appEl.querySelector('#sheetGrabberHandle') as HTMLElement;
|
||||
let dragStartY: number | null = null;
|
||||
let dragDeltaY = 0;
|
||||
|
||||
grabberHandle.addEventListener('pointerdown', (e) => {
|
||||
dragStartY = e.clientY;
|
||||
dragDeltaY = 0;
|
||||
grabberHandle.setPointerCapture(e.pointerId);
|
||||
navSheet.classList.add('dragging');
|
||||
});
|
||||
grabberHandle.addEventListener('pointermove', (e) => {
|
||||
if (dragStartY === null) return;
|
||||
dragDeltaY = Math.max(0, e.clientY - dragStartY); // only follows a downward drag - closing is the only thing this gesture does
|
||||
navSheet.style.transform = `translateY(${dragDeltaY}px)`;
|
||||
});
|
||||
function endDrag() {
|
||||
if (dragStartY === null) return;
|
||||
dragStartY = null;
|
||||
navSheet.classList.remove('dragging');
|
||||
navSheet.style.transform = ''; // hands back to the .open class's own rule - either translateY(0) (snap back) or the closeMobileNav() call below immediately overrides it anyway
|
||||
if (dragDeltaY > CLOSE_DRAG_THRESHOLD_PX) closeMobileNav();
|
||||
}
|
||||
grabberHandle.addEventListener('pointerup', endDrag);
|
||||
grabberHandle.addEventListener('pointercancel', endDrag);
|
||||
|
||||
return appEl.querySelector('#mainContent') as HTMLElement;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
--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; }
|
||||
|
|
|
|||
|
|
@ -20,9 +20,25 @@
|
|||
.trackIndex { color: var(--muted); font-size: 13px; text-align: center; }
|
||||
.trackName { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.trackDuration { color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums; }
|
||||
.trackRow button { flex-shrink: 0; padding: 6px 10px; font-size: 12px; }
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.trackRow { grid-template-columns: 24px 1fr; }
|
||||
.trackRow .trackDuration, .trackRow button { grid-column: 2; justify-self: start; }
|
||||
/* A compact circle, not the wide text button this used to be (views/album's
|
||||
own "Écouter ensemble"/"Ajouter à la file") - with a whole album's worth
|
||||
of these stacked in a list, one word-length pill per row read as
|
||||
noisier than the same action shown as a plain play/plus icon, the
|
||||
same density real track lists use. */
|
||||
.trackRow button.trackIconBtn {
|
||||
flex-shrink: 0;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border-radius: 50%;
|
||||
background: none;
|
||||
border: 1px solid rgba(255, 255, 255, .18);
|
||||
color: var(--muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.trackRow button.trackIconBtn:hover { border-color: var(--text); color: var(--text); background: none; }
|
||||
.trackRow button.trackIconBtn svg { width: 12px; height: 12px; }
|
||||
|
|
|
|||
|
|
@ -4,17 +4,34 @@
|
|||
/* base.css's global `input {}` rule already gives this the right look
|
||||
(padding/border/background) - this just bounds its width and spaces it
|
||||
from the grid below, shared by views/library, views/artist, views/album -
|
||||
see tiles.ts's wireSearchFilter. */
|
||||
see tiles.ts's wireSearchFilter. Sticky, not just top-of-page: asked
|
||||
for directly, so it stays reachable while scrolling a long grid
|
||||
instead of scrolling away with the page title above it. Its own
|
||||
background (base.css's input rule already gives it var(--panel), not
|
||||
transparent) is what keeps tiles scrolling underneath from showing
|
||||
through its own bounds once stuck. */
|
||||
.searchBar {
|
||||
max-width: 320px;
|
||||
margin-bottom: 16px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.searchEmpty { margin-top: 8px; }
|
||||
|
||||
/* The 160px floor was too wide for a real phone once .mainContent's own
|
||||
side padding is subtracted - reported directly: a ~360-393px-wide
|
||||
viewport (a common real Android width, not a rare edge case) only
|
||||
ever fit one column. clamp(), not a max-width media query breakpoint:
|
||||
this scales the minimum continuously with the viewport instead of
|
||||
jumping at one arbitrary width, so it's already right at every size
|
||||
in between, not just "mobile" and "desktop" - 28vw lands close to
|
||||
100px on a narrow phone and hits the 160px ceiling (today's exact
|
||||
desktop value, unchanged) well before any actual desktop width. */
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
grid-template-columns: repeat(auto-fill, minmax(clamp(100px, 28vw, 160px), 1fr));
|
||||
gap: 20px 16px;
|
||||
}
|
||||
|
||||
|
|
@ -127,3 +144,33 @@
|
|||
}
|
||||
.tileBtn { font-size: 11px; padding: 6px 4px; }
|
||||
}
|
||||
|
||||
/* Artist/album tiles (tiles.ts's artistTileHtml/albumTileHtml) - an <a>
|
||||
directly, not a <div> with a separate absolutely-positioned
|
||||
.tileLink/.tileOverlay on top of it: there's only ever the one
|
||||
action (open the artist/album page), so the whole tile just being
|
||||
the link is simpler than reusing machinery built for a second,
|
||||
hover-revealed set of actions these two never have. */
|
||||
.tile.tileArtist,
|
||||
.tile.tileAlbum {
|
||||
text-decoration: none;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.tile.tileArtist img,
|
||||
.tile.tileAlbum img {
|
||||
aspect-ratio: 1 / 1;
|
||||
}
|
||||
|
||||
.tile.tileArtist img {
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.tile.tileArtist .tileLabel {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.tile.tileArtist:hover .tileLabel,
|
||||
.tile.tileAlbum:hover .tileLabel {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
// 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 {
|
||||
|
|
@ -49,10 +50,20 @@ const DRILL_DOWN: Record<string, { route: string; label: string }> = {
|
|||
Playlist: { route: 'playlist', label: 'Voir la playlist' }
|
||||
};
|
||||
|
||||
export function tileHtml(item: ItemDto): string {
|
||||
/**
|
||||
* A poster tile - movies/shows/musicvideos/playlists, anything that
|
||||
* still gets Emby's own portrait artwork. MusicArtist/MusicAlbum get a
|
||||
* completely different shape below (a circle/a square, no hover
|
||||
* overlay) since a movie poster's proportions and "hover for actions"
|
||||
* pattern never fit an artist photo or an album cover - see
|
||||
* docs/architecture.md's "Music UI rework".
|
||||
*/
|
||||
function posterTileHtml(item: ItemDto): string {
|
||||
const drill = item.type ? DRILL_DOWN[item.type] : undefined;
|
||||
const drillHref = drill ? `#/${drill.route}/${item.id}` : null;
|
||||
const partyLabel = item.type === 'Audio' ? 'Écouter ensemble' : 'Lancer une watch party';
|
||||
// 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';
|
||||
return `
|
||||
<div class="tile">
|
||||
${drillHref ? `<a href="${drillHref}" class="tileLink" aria-label="${escapeHtml(item.name)}"></a>` : ''}
|
||||
|
|
@ -67,7 +78,47 @@ export function tileHtml(item: ItemDto): string {
|
|||
}
|
||||
|
||||
/**
|
||||
* Wires up every "Lancer une watch party" button inside `container`, now
|
||||
* An artist tile - a circle, the one shape every music app uses for a
|
||||
* person/act, never a rectangle. No hover overlay at all: unlike a
|
||||
* poster tile, there's only ever one thing to do with an artist photo
|
||||
* (open the artist page), so the whole tile is just that one link -
|
||||
* see DRILL_DOWN's own MusicArtist entry.
|
||||
*/
|
||||
function artistTileHtml(item: ItemDto): string {
|
||||
return `
|
||||
<a href="#/artist/${item.id}" class="tile tileArtist" aria-label="${escapeHtml(item.name)}">
|
||||
<img src="${item.imageUrl}" alt="" loading="lazy" onerror="this.classList.add('noImage')" />
|
||||
<span class="tileLabel">${escapeHtml(item.name)}</span>
|
||||
</a>`;
|
||||
}
|
||||
|
||||
/** An album tile - square, matching the cover art's own real proportions
|
||||
* (a movie-poster 2:3 box only ever stretched/cropped it oddly). Same
|
||||
* "whole tile is the one link" reasoning as artistTileHtml above. */
|
||||
function albumTileHtml(item: ItemDto): string {
|
||||
return `
|
||||
<a href="#/album/${item.id}" class="tile tileAlbum" aria-label="${escapeHtml(item.name)}">
|
||||
<img src="${item.imageUrl}" alt="" loading="lazy" onerror="this.classList.add('noImage')" />
|
||||
<span class="tileLabel">${escapeHtml(item.name)}${item.year ? ` (${item.year})` : ''}</span>
|
||||
</a>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders one item as a tile - the shape depends entirely on its Emby
|
||||
* type. Dispatching here, rather than making every call site
|
||||
* (home carousels, views/library, views/artist) pick a renderer itself,
|
||||
* is what keeps them all visually/behaviorally identical instead of
|
||||
* drifting - same reasoning this module's own header comment already
|
||||
* gives for being shared in the first place.
|
||||
*/
|
||||
export function tileHtml(item: ItemDto): string {
|
||||
if (item.type === 'MusicArtist') return artistTileHtml(item);
|
||||
if (item.type === 'MusicAlbum') return albumTileHtml(item);
|
||||
return posterTileHtml(item);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wires up every "Lancer une watch/listen party" button inside `container`, now
|
||||
* 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.
|
||||
|
|
@ -83,14 +134,15 @@ 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) {
|
||||
alert((err as Error).message);
|
||||
showToast((err as Error).message, { variant: 'error' });
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Lancer une watch party';
|
||||
btn.textContent = idleLabel;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
46
client/src/toast.css
Normal file
46
client/src/toast.css
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
/* ---- 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; }
|
||||
}
|
||||
66
client/src/toast.ts
Normal file
66
client/src/toast.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
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);
|
||||
}
|
||||
|
|
@ -9,6 +9,10 @@
|
|||
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;
|
||||
|
|
@ -50,6 +54,16 @@ export interface PlaybackOptionsDto {
|
|||
subtitleTracks: PlaybackTrackOption[];
|
||||
}
|
||||
|
||||
/** Emby's own native intro/credits detection - see server/src/types.ts's
|
||||
* own copy of this shape for where the field names come from. Every
|
||||
* field is independently nullable ("not detected", never an error) -
|
||||
* see views/player/Player.ts's updateSkipButton, the only consumer. */
|
||||
export interface PlaybackMarkersDto {
|
||||
introStartTicks: number | null;
|
||||
introEndTicks: number | null;
|
||||
creditsStartTicks: number | null;
|
||||
}
|
||||
|
||||
export type MediaType = 'video' | 'music';
|
||||
|
||||
export interface PublicMember {
|
||||
|
|
@ -157,6 +171,42 @@ 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 {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,9 @@
|
|||
/* .albumHeader's own two children - .seriesInfo (Series.css) and
|
||||
.playlistActions (Playlist.css) - are reused as-is, same precedent
|
||||
Album.css already set for .seriesInfo itself: every route's CSS ends
|
||||
up in the one bundle regardless of which view imports it, so there's
|
||||
no need to duplicate a rule this generic (a flex row of actions under
|
||||
a title) just because it was first written for a different page. */
|
||||
.albumHeader {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
|
|
|
|||
|
|
@ -2,10 +2,14 @@
|
|||
<div class="albumHeader">
|
||||
<img class="albumCover" id="albumCover" alt="" loading="lazy" onerror="this.classList.add('noImage')" />
|
||||
<div class="seriesInfo">
|
||||
<span class="heroKicker" id="albumKicker">Album</span>
|
||||
<h1 id="albumTitle"></h1>
|
||||
<p class="hint hide" id="albumArtist"></p>
|
||||
<div class="heroActions">
|
||||
<button id="launchBtn" class="heroPlayBtn"><span class="heroPlayIcon" id="launchBtnIcon"></span><span id="launchBtnLabel">Écouter cet album</span></button>
|
||||
<a class="linklike" id="embyLink" target="_blank" rel="noopener">Voir sur Emby</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<input id="searchInput" class="searchBar" type="search" placeholder="Rechercher un morceau..." />
|
||||
<p class="hint searchEmpty hide">Aucun résultat.</p>
|
||||
|
|
|
|||
|
|
@ -2,15 +2,23 @@ import './Album.css';
|
|||
import '../../styles/trackList.css';
|
||||
import html from './Album.html';
|
||||
import { getAlbumTracks, getItem } from '../../api/embyApi';
|
||||
import { addTrackToQueue, createParty } from '../../api/party';
|
||||
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';
|
||||
|
||||
const TICKS_PER_SECOND = 10000000; // Emby/.NET convention: 1 tick = 100ns, same as everywhere else this app touches durations/positions
|
||||
|
||||
// Same "small local constant" convention as musicBar/MusicBar.ts's own
|
||||
// icon strings - PLAY_ICON_SVG is duplicated from views/artist/Artist.ts
|
||||
// rather than shared, see that file's own comment on why.
|
||||
const PLAY_ICON_SVG = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 4.5v15l14-7.5Z" fill="currentColor"></path></svg>';
|
||||
const PLUS_ICON_SVG = '<svg viewBox="0 0 24 24" aria-hidden="true"><line x1="12" y1="5" x2="12" y2="19" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"></line><line x1="5" y1="12" x2="19" y2="12" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"></line></svg>';
|
||||
const CHECK_ICON_SVG = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 12.5 9 17.5 20 6.5" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"></path></svg>';
|
||||
|
||||
/** Route `/album/:id` - the last level of the music hierarchy: one album's
|
||||
* tracks, each startable as its own listen party (mirrors views/series's
|
||||
* episode list, but flat - an album has no further sub-level the way a
|
||||
|
|
@ -31,9 +39,9 @@ export async function renderAlbum(root: HTMLElement, params: RouteParams): Promi
|
|||
root.innerHTML = html;
|
||||
root.querySelector('#back')!.addEventListener('click', () => history.back());
|
||||
(root.querySelector('#albumCover') as HTMLImageElement).src = album.imageUrl;
|
||||
root.querySelector('#albumTitle')!.innerHTML = album.year
|
||||
? `${escapeHtml(album.name)} <span class="seriesYear">(${album.year})</span>`
|
||||
: escapeHtml(album.name);
|
||||
root.querySelector('#albumKicker')!.textContent = album.year ? `Album · ${album.year}` : 'Album';
|
||||
root.querySelector('#albumTitle')!.textContent = album.name;
|
||||
root.querySelector('#launchBtnIcon')!.innerHTML = PLAY_ICON_SVG;
|
||||
if (album.artistName) {
|
||||
const artistEl = root.querySelector('#albumArtist')!;
|
||||
artistEl.textContent = album.artistName;
|
||||
|
|
@ -42,27 +50,36 @@ export async function renderAlbum(root: HTMLElement, params: RouteParams): Promi
|
|||
(root.querySelector('#embyLink') as HTMLAnchorElement).href = album.embyUrl;
|
||||
|
||||
const listEl = root.querySelector('#trackList')!;
|
||||
const launchBtn = root.querySelector('#launchBtn') as HTMLButtonElement;
|
||||
const launchBtnLabel = root.querySelector('#launchBtnLabel')!;
|
||||
if (!tracks.length) {
|
||||
listEl.innerHTML = '<p class="hint">Aucun morceau dans cet album.</p>';
|
||||
root.querySelector('#searchInput')!.remove();
|
||||
launchBtn.disabled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Label reflects whether a listen party is already active *at render
|
||||
// time* - a harmless, occasionally-stale label (see refreshLabels()
|
||||
// below for when it's corrected). The actual behavior on click always
|
||||
// re-checks getActiveMusicPartyId() fresh, so clicking never does the
|
||||
// wrong thing even if the label itself hasn't caught up yet (e.g.
|
||||
// someone hit "Quitter" on the bar while this page was already open).
|
||||
const startLabel = 'Écouter ensemble';
|
||||
// aria-label reflects whether a listen party is already active *at
|
||||
// render time* - a harmless, occasionally-stale label (see
|
||||
// refreshLabels() below for when it's corrected). The actual behavior
|
||||
// on click always re-checks getActiveMusicPartyId() fresh, so clicking
|
||||
// never does the wrong thing even if the label hasn't caught up yet
|
||||
// (e.g. someone hit "Quitter" on the bar while this page was already
|
||||
// open). Icon-only buttons (play vs plus, mirroring the same two
|
||||
// states) - the label lives in aria-label/title for accessibility
|
||||
// rather than as visible text, now that there's one per track instead
|
||||
// of one per page (see docs/architecture.md's "Music UI rework").
|
||||
const startLabel = 'Lancer une listen party';
|
||||
const addLabel = 'Ajouter à la file';
|
||||
const startAlbumLabel = 'Écouter cet album';
|
||||
const addAlbumLabel = "Ajouter l'album à la file";
|
||||
|
||||
listEl.innerHTML = tracks.map((t) => `
|
||||
<div class="trackRow">
|
||||
<span class="trackIndex">${t.indexNumber != null ? t.indexNumber : ''}</span>
|
||||
<span class="trackName">${escapeHtml(t.name)}</span>
|
||||
<span class="trackDuration">${t.durationTicks ? formatDuration(t.durationTicks / TICKS_PER_SECOND) : ''}</span>
|
||||
<button class="startPartyBtn" data-item-id="${t.id}">${getActiveMusicPartyId() ? addLabel : startLabel}</button>
|
||||
<button class="startPartyBtn trackIconBtn" data-item-id="${t.id}" title="${getActiveMusicPartyId() ? addLabel : startLabel}" aria-label="${getActiveMusicPartyId() ? addLabel : startLabel}">${getActiveMusicPartyId() ? PLUS_ICON_SVG : PLAY_ICON_SVG}</button>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
|
|
@ -77,14 +94,22 @@ export async function renderAlbum(root: HTMLElement, params: RouteParams): Promi
|
|||
};
|
||||
}
|
||||
|
||||
// Re-labels every track button from current state, not just the one
|
||||
// just clicked - starting a party from track 3 means tracks 1, 2 and 4
|
||||
// can now also be queued instead of starting a party of their own.
|
||||
// Re-labels every track button (and the whole-album one) from current
|
||||
// state, not just the one just clicked - starting a party from track 3
|
||||
// means tracks 1, 2 and 4 can now also be queued instead of starting a
|
||||
// party of their own.
|
||||
function refreshLabels(): void {
|
||||
const active = getActiveMusicPartyId();
|
||||
listEl.querySelectorAll<HTMLButtonElement>('.startPartyBtn').forEach((b) => {
|
||||
if (!b.disabled) b.textContent = getActiveMusicPartyId() ? addLabel : startLabel;
|
||||
if (b.disabled) return;
|
||||
const label = active ? addLabel : startLabel;
|
||||
b.innerHTML = active ? PLUS_ICON_SVG : PLAY_ICON_SVG;
|
||||
b.title = label;
|
||||
b.setAttribute('aria-label', label);
|
||||
});
|
||||
if (!launchBtn.disabled) launchBtnLabel.textContent = active ? addAlbumLabel : startAlbumLabel;
|
||||
}
|
||||
refreshLabels(); // sets launchBtn's own initial label - the per-track buttons already get theirs from the template above
|
||||
|
||||
listEl.querySelectorAll<HTMLButtonElement>('.startPartyBtn').forEach((btn, i) => {
|
||||
const track = tracks[i] as ItemDto;
|
||||
|
|
@ -93,10 +118,8 @@ export async function renderAlbum(root: HTMLElement, params: RouteParams): Promi
|
|||
const activePartyId = getActiveMusicPartyId();
|
||||
try {
|
||||
if (activePartyId) {
|
||||
btn.textContent = 'Ajout...';
|
||||
await addTrackToQueue(activePartyId, trackInputFor(track));
|
||||
} else {
|
||||
btn.textContent = 'Démarrage...';
|
||||
// Attaches the persistent bottom bar directly and stays right
|
||||
// here - no navigate() - so adding a few more tracks from this
|
||||
// same album is just clicking their buttons too. See
|
||||
|
|
@ -105,15 +128,42 @@ export async function renderAlbum(root: HTMLElement, params: RouteParams): Promi
|
|||
const party = await createParty(track.id);
|
||||
joinMusicParty(party.partyId, party);
|
||||
}
|
||||
btn.textContent = 'Ajouté !';
|
||||
btn.innerHTML = CHECK_ICON_SVG;
|
||||
setTimeout(() => { btn.disabled = false; refreshLabels(); }, 1500);
|
||||
} catch (err) {
|
||||
alert((err as Error).message);
|
||||
showToast((err as Error).message, { variant: 'error' });
|
||||
btn.disabled = false;
|
||||
refreshLabels();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Whole-album equivalent of the per-track buttons above - asked for
|
||||
// directly, alongside the same action for a whole artist (see
|
||||
// Artist.ts). itemId is the album itself, not any one track - the
|
||||
// server expands it into every track inside, in order (see index.ts's
|
||||
// resolveMusicTracksForItem/createMusicPartyFromTracks), the same way
|
||||
// launching a playlist already expands into everything it contains.
|
||||
launchBtn.addEventListener('click', async () => {
|
||||
launchBtn.disabled = true;
|
||||
const activePartyId = getActiveMusicPartyId();
|
||||
try {
|
||||
if (activePartyId) {
|
||||
launchBtnLabel.textContent = 'Ajout...';
|
||||
await queueItem(activePartyId, albumId);
|
||||
} else {
|
||||
launchBtnLabel.textContent = 'Démarrage...';
|
||||
const party = await createParty(albumId);
|
||||
joinMusicParty(party.partyId, party);
|
||||
}
|
||||
launchBtnLabel.textContent = 'Ajouté !';
|
||||
setTimeout(() => { launchBtn.disabled = false; refreshLabels(); }, 1500);
|
||||
} catch (err) {
|
||||
showToast((err as Error).message, { variant: 'error' });
|
||||
launchBtn.disabled = false;
|
||||
refreshLabels();
|
||||
}
|
||||
});
|
||||
|
||||
wireSearchFilter(root.querySelector('#searchInput') as HTMLInputElement, root, '.trackRow');
|
||||
}
|
||||
|
|
|
|||
89
client/src/views/artist/Artist.css
Normal file
89
client/src/views/artist/Artist.css
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
/* ---- Artist hero - see Artist.ts. A circular photo, never a poster:
|
||||
the same reasoning tiles.ts's artistTileHtml already gives for the
|
||||
artist grid applies just as much to the one photo at the top of the
|
||||
artist's own page. ---- */
|
||||
.artistHero {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 20px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.artistHeroPhoto {
|
||||
width: 132px;
|
||||
height: 132px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
background: var(--panel);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.artistHeroPhoto.noImage { visibility: hidden; }
|
||||
|
||||
.artistHeroInfo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.artistHeroInfo h1 { margin: 0; font-size: 26px; line-height: 1.15; }
|
||||
|
||||
/* ---- Shared with views/album's own hero (Album.css reuses these three
|
||||
classes directly rather than redefining them - same "one rule,
|
||||
whichever file needs it first" precedent Album.css already set for
|
||||
.seriesInfo/.playlistActions). ---- */
|
||||
.heroKicker {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .05em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.heroActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
margin-top: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* A pill, not a plain rectangular button - the one visual actually
|
||||
borrowed from real music apps' big circular play button, while
|
||||
keeping the explicit text label this app already standardized on
|
||||
for every request/action control (see docs/architecture.md's
|
||||
player-rework section) - nothing here is icon-only. */
|
||||
.heroPlayBtn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 6px 18px 6px 6px;
|
||||
border-radius: 99px;
|
||||
background: var(--panel);
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.heroPlayBtn:hover { background: #333; }
|
||||
|
||||
.heroPlayIcon {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.heroPlayIcon svg { width: 14px; height: 14px; display: block; }
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.artistHero { flex-direction: column; align-items: center; text-align: center; }
|
||||
.artistHeroPhoto { width: 104px; height: 104px; }
|
||||
.artistHeroInfo { align-items: center; }
|
||||
}
|
||||
|
|
@ -1,5 +1,15 @@
|
|||
<button class="linklike" id="back">← Retour</button>
|
||||
<div class="pageHeader"><h1></h1></div>
|
||||
<div class="artistHero">
|
||||
<img class="artistHeroPhoto" id="artistPhoto" alt="" loading="lazy" onerror="this.classList.add('noImage')" />
|
||||
<div class="artistHeroInfo">
|
||||
<span class="heroKicker">Artiste</span>
|
||||
<h1></h1>
|
||||
<span class="hint" id="albumCount"></span>
|
||||
<div class="heroActions">
|
||||
<button id="launchBtn" class="heroPlayBtn"><span class="heroPlayIcon" id="launchBtnIcon"></span><span id="launchBtnLabel">Écouter toute la discographie</span></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<input id="searchInput" class="searchBar" type="search" placeholder="Rechercher un album..." />
|
||||
<p class="hint searchEmpty hide">Aucun résultat.</p>
|
||||
<div id="grid" class="grid"></div>
|
||||
|
|
|
|||
|
|
@ -1,16 +1,28 @@
|
|||
import './Artist.css';
|
||||
import html from './Artist.html';
|
||||
import { getArtistAlbums, getItem } from '../../api/embyApi';
|
||||
import { createParty, queueItem } from '../../api/party';
|
||||
import { getActiveMusicPartyId, joinMusicParty } from '../../musicBar/MusicBar';
|
||||
import type { RouteParams } from '../../router';
|
||||
import { tileHtml, wireSearchFilter } from '../../tiles';
|
||||
import { LOADING_HINT } from '../../loading';
|
||||
import { showToast } from '../../toast';
|
||||
|
||||
// Same icon, same "small local constant" convention as musicBar/MusicBar.ts's
|
||||
// own PLAY_ICON_SVG - not shared from there since neither file otherwise
|
||||
// depends on the other, and duplicating one SVG string is cheaper than a
|
||||
// shared icons module for exactly two current call sites (here and
|
||||
// views/album/Album.ts).
|
||||
const PLAY_ICON_SVG = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 4.5v15l14-7.5Z" fill="currentColor"></path></svg>';
|
||||
|
||||
/** Route `/artist/:id` - one artist's albums, reached from a music
|
||||
* library's top-level grid (see views/library) via a MusicArtist tile's
|
||||
* "Voir les albums" link (tiles.ts's DRILL_DOWN table). Structurally the
|
||||
* same "detail header + grid" shape as views/series, just one level up
|
||||
* the music hierarchy (Artist -> Album, not Series -> Season/Episode) and
|
||||
* with no per-item action beyond drilling further into an album (an
|
||||
* artist has nothing itself to start a party from). */
|
||||
* the music hierarchy (Artist -> Album, not Series -> Season/Episode).
|
||||
* #launchBtn is the one action available at this level itself (every
|
||||
* album's own tracks, in one go) - drilling into a specific album is
|
||||
* still the only way to queue less than the whole discography. */
|
||||
export async function renderArtist(root: HTMLElement, params: RouteParams): Promise<void> {
|
||||
const artistId = params.id as string;
|
||||
root.innerHTML = LOADING_HINT;
|
||||
|
|
@ -26,14 +38,58 @@ export async function renderArtist(root: HTMLElement, params: RouteParams): Prom
|
|||
|
||||
root.innerHTML = html;
|
||||
root.querySelector('h1')!.textContent = artist.name;
|
||||
(root.querySelector('#artistPhoto') as HTMLImageElement).src = artist.imageUrl;
|
||||
root.querySelector('#launchBtnIcon')!.innerHTML = PLAY_ICON_SVG;
|
||||
root.querySelector('#back')!.addEventListener('click', () => history.back());
|
||||
|
||||
const launchBtn = root.querySelector('#launchBtn') as HTMLButtonElement;
|
||||
const launchBtnLabel = root.querySelector('#launchBtnLabel')!;
|
||||
const grid = root.querySelector('#grid')!;
|
||||
root.querySelector('#albumCount')!.textContent = `${albums.length} album${albums.length > 1 ? 's' : ''}`;
|
||||
if (!albums.length) {
|
||||
grid.innerHTML = '<p class="hint">Aucun album trouvé pour cet artiste.</p>';
|
||||
root.querySelector('#searchInput')!.remove();
|
||||
launchBtn.disabled = true;
|
||||
return;
|
||||
}
|
||||
grid.innerHTML = albums.map(tileHtml).join('');
|
||||
wireSearchFilter(root.querySelector('#searchInput') as HTMLInputElement, root, '.tile');
|
||||
|
||||
// Label reflects whether a listen party is already active - same
|
||||
// "harmless, occasionally-stale until the next refresh" reasoning as
|
||||
// views/album's own equivalent buttons (the click handler below
|
||||
// always re-checks getActiveMusicPartyId() fresh regardless).
|
||||
const startLabel = 'Écouter toute la discographie';
|
||||
const addLabel = 'Ajouter la discographie à la file';
|
||||
function refreshLabel(): void {
|
||||
if (!launchBtn.disabled) launchBtnLabel.textContent = getActiveMusicPartyId() ? addLabel : startLabel;
|
||||
}
|
||||
refreshLabel();
|
||||
|
||||
// itemId is the artist itself - the server expands it into every
|
||||
// album's tracks, in order, the same mechanism a whole-album launch
|
||||
// uses one level down (see views/album/Album.ts, and index.ts's
|
||||
// resolveMusicTracksForItem/createMusicPartyFromTracks).
|
||||
launchBtn.addEventListener('click', async () => {
|
||||
launchBtn.disabled = true;
|
||||
const activePartyId = getActiveMusicPartyId();
|
||||
try {
|
||||
if (activePartyId) {
|
||||
launchBtnLabel.textContent = 'Ajout...';
|
||||
await queueItem(activePartyId, artistId);
|
||||
} else {
|
||||
launchBtnLabel.textContent = 'Démarrage...';
|
||||
// Same "attach the bar, stay put" pattern as views/album - see
|
||||
// its own comment.
|
||||
const party = await createParty(artistId);
|
||||
joinMusicParty(party.partyId, party);
|
||||
}
|
||||
launchBtnLabel.textContent = 'Ajouté !';
|
||||
setTimeout(() => { launchBtn.disabled = false; refreshLabel(); }, 1500);
|
||||
} catch (err) {
|
||||
showToast((err as Error).message, { variant: 'error' });
|
||||
launchBtn.disabled = false;
|
||||
refreshLabel();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -60,3 +60,18 @@
|
|||
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); }
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
</div>
|
||||
|
||||
<section class="section" id="partiesSection" hidden>
|
||||
<h2>Watch parties en cours</h2>
|
||||
<h2>Parties en cours</h2>
|
||||
<div id="partiesCarousel" class="carousel"></div>
|
||||
</section>
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ 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 {
|
||||
|
|
@ -17,7 +18,9 @@ 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.
|
||||
const lobby = connectLobby((parties) => renderParties(root, parties));
|
||||
// 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 elapsedTimer = window.setInterval(() => updateElapsedLabels(root), 1000);
|
||||
|
||||
loadRecentSections(root);
|
||||
|
|
@ -56,6 +59,7 @@ 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' : ''}
|
||||
|
|
@ -83,7 +87,7 @@ function renderParties(root: HTMLElement, parties: LobbyPartyDto[]): void {
|
|||
await joinParty(btn.dataset.partyId as string);
|
||||
navigate(`/party/${btn.dataset.partyId}`);
|
||||
} catch (err) {
|
||||
alert((err as Error).message);
|
||||
showToast((err as Error).message, { variant: 'error' });
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
|
|
|||
35
client/src/views/library/Library.css
Normal file
35
client/src/views/library/Library.css
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
/* ---- 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,6 @@
|
|||
<div class="pageHeader"><h1></h1></div>
|
||||
<input id="searchInput" class="searchBar" type="search" placeholder="Rechercher dans cette bibliothèque..." />
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
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 { RouteParams } from '../../router';
|
||||
import { renderTilesInChunks, wireSearchFilter, wireTileButtons } from '../../tiles';
|
||||
import type { CleanupFn, RouteParams } from '../../router';
|
||||
import { renderGroupedTilesInChunks, wireAlphaRail, wireSectionSearchSync } from '../../alphaIndex';
|
||||
import { wireSearchFilter, wireTileButtons } from '../../tiles';
|
||||
import { LOADING_HINT } from '../../loading';
|
||||
|
||||
/**
|
||||
|
|
@ -17,17 +19,30 @@ 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 async function renderLibrary(root: HTMLElement, params: RouteParams): Promise<void> {
|
||||
export function renderLibrary(root: HTMLElement, params: RouteParams): CleanupFn {
|
||||
root.innerHTML = LOADING_HINT;
|
||||
let cancelled = false;
|
||||
let cleanup: CleanupFn = () => {};
|
||||
|
||||
(async () => {
|
||||
let libraries;
|
||||
try {
|
||||
libraries = await getLibraries();
|
||||
} catch {
|
||||
root.innerHTML = '<p class="error">Impossible de charger les bibliothèques.</p>';
|
||||
if (!cancelled) 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) {
|
||||
|
|
@ -45,20 +60,32 @@ export async function renderLibrary(root: HTMLElement, params: RouteParams): Pro
|
|||
? 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;
|
||||
}
|
||||
|
||||
renderTilesInChunks(grid, items);
|
||||
renderGroupedTilesInChunks(grid, items);
|
||||
wireTileButtons(grid, async (itemId) => {
|
||||
const party = await createParty(itemId);
|
||||
navigate(`/party/${party.partyId}`);
|
||||
});
|
||||
wireSearchFilter(root.querySelector('#searchInput') as HTMLInputElement, root, '.tile');
|
||||
const searchInput = root.querySelector('#searchInput') as HTMLInputElement;
|
||||
wireSearchFilter(searchInput, root, '.tile');
|
||||
wireSectionSearchSync(searchInput, grid);
|
||||
cleanup = wireAlphaRail(root, grid, items);
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
cleanup();
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -196,6 +196,26 @@
|
|||
transition: padding .25s ease, background-color .25s ease, border-color .25s ease;
|
||||
}
|
||||
|
||||
/* Floating above #bottomBar (higher z-index, positioned clear of its
|
||||
tallest/expanded height) rather than living inside it - see
|
||||
Player.html's own comment on why: it must stay reachable through the
|
||||
bar's own auto-hide/.minimal collapse, not disappear along with it. */
|
||||
.skipMarkerBtn {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
bottom: 84px;
|
||||
z-index: 7;
|
||||
background: rgba(20, 18, 22, .85);
|
||||
color: white;
|
||||
border: 1px solid rgba(255, 255, 255, .25);
|
||||
padding: 10px 18px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.skipMarkerBtn:hover { background: rgba(20, 18, 22, .95); border-color: rgba(255, 255, 255, .4); }
|
||||
|
||||
/* Collapsed down to just the seek bar after a stretch of no activity
|
||||
(see Player.ts's scheduleControlsHide) - asked for directly: the
|
||||
full bar, always visible, sits right where a native <track> subtitle
|
||||
|
|
@ -346,6 +366,15 @@
|
|||
|
||||
.fsBtn:hover { background: rgba(255, 255, 255, .18); }
|
||||
|
||||
/* #pipBtn while active - same "clearly-on" treatment as .iconBtn's own
|
||||
[aria-pressed="true"] rule (#castBtn), just scoped to .fsBtn instead -
|
||||
the two aren't combined onto one shared class since .iconBtn and
|
||||
.fsBtn otherwise disagree on padding/border-radius/background. */
|
||||
.fsBtn[aria-pressed="true"] {
|
||||
color: #ff9800;
|
||||
background: rgba(255, 152, 0, .18);
|
||||
}
|
||||
|
||||
/* Replaces the video content while casting - the local <video> is
|
||||
paused/torn down (see Player.ts's startCasting/cast.ts), so leaving
|
||||
its last frame frozen with no explanation would just look stuck. */
|
||||
|
|
|
|||
|
|
@ -63,6 +63,17 @@
|
|||
notification rather than a fourth thing bolted onto a fixed layout. -->
|
||||
<div id="requestBanner" class="requestBanner hide"></div>
|
||||
|
||||
<!-- Emby's own native intro detection (see Player.ts's updateSkipButton/
|
||||
CatalogService.getPlaybackMarkers) - shown independently of #bottomBar
|
||||
below, and NOT subject to its own auto-hide/.minimal collapse, since
|
||||
this needs to stay reachable the moment the intro starts even if
|
||||
nobody's touched the player yet. Host-only, like every other seek -
|
||||
see updateSkipButton's own comment for why a follower can't get this
|
||||
button at all. Explicit label, not just an icon - see
|
||||
docs/architecture.md's player-rework section for why this app
|
||||
standardized on that for every request-shaped control. -->
|
||||
<button id="skipIntroBtn" class="skipMarkerBtn hide">Passer l'intro</button>
|
||||
|
||||
<!-- One bar, bottom-anchored, replacing what used to be three separate
|
||||
floating pieces (a centered banner/notice column, a request
|
||||
toolbar cornered bottom-right, an "Épisode suivant" link up in the
|
||||
|
|
@ -116,6 +127,17 @@
|
|||
<span id="ambientNotice" class="ambientNotice hide"></span>
|
||||
</div>
|
||||
<div class="bottomBarZone right">
|
||||
<!-- Hidden by default, shown only once Player.ts confirms
|
||||
document.pictureInPictureEnabled - not every browser supports
|
||||
this (see its own comment), and there's no live
|
||||
"availability changed" event to react to the way the cast
|
||||
button has, since this never changes mid-session. -->
|
||||
<button id="pipBtn" class="fsBtn hide" title="Picture-in-picture" aria-label="Picture-in-picture" aria-pressed="false">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
|
||||
<rect x="3" y="4" width="18" height="14" rx="2" fill="none" stroke="currentColor" stroke-width="2"></rect>
|
||||
<rect x="12" y="11" width="7" height="5" rx="1" fill="currentColor"></rect>
|
||||
</svg>
|
||||
</button>
|
||||
<button id="fullscreenBtn" class="fsBtn" title="Plein écran" aria-label="Plein écran"></button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,17 +1,19 @@
|
|||
import '../../styles/presence.css';
|
||||
import './Player.css';
|
||||
import html from './Player.html';
|
||||
import { auth, burnInStreamUrl, getPlaybackOptions, partyStreamUrl, subtitleUrl } from '../../api/embyApi';
|
||||
import { auth, burnInStreamUrl, getPlaybackMarkers, getPlaybackOptions, partyStreamUrl, subtitleUrl } from '../../api/embyApi';
|
||||
import { getParty, leaveParty, connectParty, issueCastToken, revokeCastToken } from '../../api/party';
|
||||
import type { PartyConnection } from '../../api/party';
|
||||
import { navigate } from '../../router';
|
||||
import type { CleanupFn, RouteParams } from '../../router';
|
||||
import { colorForName, escapeHtml, formatDuration, initialsFor } from '../../tiles';
|
||||
import { LOADING_HINT } from '../../loading';
|
||||
import type { BurnInChangedMsg, ItemChangedMsg, PartyPublicState, PauseRequestedMsg, PlaybackOptionsDto, ResumeRequestedMsg, SocketMember, StreamOptionsChangedMsg, TextSubtitleChangedMsg } from '../../types';
|
||||
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;
|
||||
|
|
@ -149,7 +151,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);
|
||||
alert(err.message);
|
||||
showToast(err.message, { variant: 'error' });
|
||||
navigate('/home');
|
||||
});
|
||||
|
||||
|
|
@ -196,6 +198,7 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
|
|||
const qualitySelect = root.querySelector('#qualitySelect') as HTMLSelectElement;
|
||||
const burnInHint = root.querySelector('#burnInHint')!;
|
||||
const castBtn = root.querySelector('#castBtn') as HTMLButtonElement;
|
||||
const pipBtn = root.querySelector('#pipBtn') as HTMLButtonElement;
|
||||
const fullscreenBtn = root.querySelector('#fullscreenBtn') as HTMLButtonElement;
|
||||
const bottomBar = root.querySelector('#bottomBar') as HTMLElement;
|
||||
const progressBar = root.querySelector('#progressBar') as HTMLElement;
|
||||
|
|
@ -203,6 +206,7 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
|
|||
const playPauseBtn = root.querySelector('#playPauseBtn') as HTMLButtonElement;
|
||||
const muteBtn = root.querySelector('#muteBtn') as HTMLButtonElement;
|
||||
const timeLabel = root.querySelector('#timeLabel')!;
|
||||
const skipIntroBtn = root.querySelector('#skipIntroBtn') as HTMLButtonElement;
|
||||
|
||||
qualitySelect.innerHTML = QUALITY_PRESETS.map((q) => `<option value="${q.value}">${q.label}</option>`).join('');
|
||||
|
||||
|
|
@ -225,6 +229,14 @@ 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;
|
||||
|
|
@ -262,6 +274,15 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
|
|||
// - see its own comment) can re-render the subtitle picker's host-only
|
||||
// gating for burn-in options without a redundant playback-options fetch.
|
||||
let lastPlaybackOptions: PlaybackOptionsDto | null = null;
|
||||
// Emby's own native intro/credits detection for currentItemId, if any -
|
||||
// see loadMarkers/updateSkipButton below and CatalogService
|
||||
// .getPlaybackMarkers. Null fields (or this whole thing being null,
|
||||
// while still loading) just mean no skip button, never an error.
|
||||
let currentMarkers: PlaybackMarkersDto | null = null;
|
||||
// One-shot per item - see updateSkipButton's own comment for why this
|
||||
// must NOT re-fire (force the bar open) on every tick for as long as
|
||||
// credits are running.
|
||||
let creditsControlsShown = false;
|
||||
// "The player" sendLocalState/applyRemoteState actually read and write -
|
||||
// the local <video> by default, swapped for a Chromecast's RemotePlayer
|
||||
// while casting (see toggleCast/cast.ts). Casting is available to any
|
||||
|
|
@ -402,6 +423,7 @@ 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
|
||||
|
|
@ -425,6 +447,43 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
|
|||
const current = activeDriver.currentTime;
|
||||
progressFill.style.width = duration > 0 ? `${Math.min(100, (current / duration) * 100)}%` : '0%';
|
||||
timeLabel.textContent = duration > 0 ? `${formatDuration(current)} / ${formatDuration(duration)}` : '';
|
||||
setMediaSessionPositionState(duration, current);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows/hides #skipIntroBtn from activeDriver's current position against
|
||||
* currentMarkers - called continuously (progressTicker, below), same as
|
||||
* updateProgressBar. Host-only: a follower's own seek never broadcasts
|
||||
* (see sendLocalState's own gate), so a local jump here would just get
|
||||
* overwritten by the next correction from the real host - same
|
||||
* reasoning as progressBar being read-only for a follower.
|
||||
*
|
||||
* Credits get no button of their own: #nextBtn already does exactly
|
||||
* "go to the next queued item" and is already shown whenever
|
||||
* hasNextQueued is true (see onItemChanged) - detection's only real
|
||||
* contribution there is making sure it's actually reachable the moment
|
||||
* credits start, by un-minimizing the bar once, rather than requiring
|
||||
* the host to have touched the player recently. The `creditsControlsShown`
|
||||
* guard is what keeps this a one-shot nudge instead of fighting anyone
|
||||
* who deliberately re-minimizes the bar during a long credits scroll.
|
||||
*/
|
||||
function updateSkipButton() {
|
||||
if (!isHost || !currentMarkers) {
|
||||
skipIntroBtn.classList.add('hide');
|
||||
return;
|
||||
}
|
||||
const current = activeDriver.currentTime;
|
||||
const { introStartTicks, introEndTicks, creditsStartTicks } = currentMarkers;
|
||||
|
||||
const inIntro = introStartTicks != null && introEndTicks != null
|
||||
&& current >= introStartTicks / TICKS_PER_SECOND && current < introEndTicks / TICKS_PER_SECOND;
|
||||
skipIntroBtn.classList.toggle('hide', !inIntro);
|
||||
|
||||
const inCredits = creditsStartTicks != null && current >= creditsStartTicks / TICKS_PER_SECOND;
|
||||
if (inCredits && hasNextQueued && !creditsControlsShown) {
|
||||
creditsControlsShown = true;
|
||||
showControls();
|
||||
}
|
||||
}
|
||||
|
||||
/** Shared by every discrete play/pause/seek event this player reacts
|
||||
|
|
@ -469,13 +528,23 @@ 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));
|
||||
activeDriver.currentTime = fraction * duration;
|
||||
onDriverStateChanged();
|
||||
seekTo(fraction * duration);
|
||||
}
|
||||
|
||||
// Host-only (checked here, not just via the readOnly class - that one
|
||||
|
|
@ -505,6 +574,12 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
|
|||
updateMuteBtn();
|
||||
});
|
||||
|
||||
skipIntroBtn.addEventListener('click', () => {
|
||||
if (currentMarkers?.introEndTicks == null) return;
|
||||
activeDriver.currentTime = currentMarkers.introEndTicks / TICKS_PER_SECOND;
|
||||
onDriverStateChanged();
|
||||
});
|
||||
|
||||
// Ticks the seek bar/time label between discrete events - currentTime
|
||||
// moves continuously while playing, with no event of its own for
|
||||
// "still playing, time passed" the way play/pause/seeked have. Runs
|
||||
|
|
@ -515,6 +590,7 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
|
|||
progressTicker = window.setInterval(() => {
|
||||
if (!currentItemId) return;
|
||||
updateProgressBar();
|
||||
updateSkipButton();
|
||||
}, 500);
|
||||
updatePlayPauseBtn();
|
||||
updateMuteBtn();
|
||||
|
|
@ -607,6 +683,7 @@ 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();
|
||||
|
|
@ -614,7 +691,10 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
|
|||
video.addEventListener('play', onDriverStateChanged);
|
||||
video.addEventListener('pause', onDriverStateChanged);
|
||||
video.addEventListener('seeked', onDriverStateChanged);
|
||||
video.addEventListener('ended', () => { if (hasNextQueued) conn?.next(); });
|
||||
// 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(); });
|
||||
if (!heartbeat) heartbeat = window.setInterval(reportHeartbeat, 3000);
|
||||
|
||||
// Burn-in (image) subtitle options are host-only (see
|
||||
|
|
@ -625,7 +705,7 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
|
|||
renderSubtitleOptions();
|
||||
applyStreamOptionsGating();
|
||||
|
||||
if (notify) alert("L'hôte précédent a quitté la party - tu es maintenant l'hôte.");
|
||||
if (notify) showToast("L'hôte précédent a quitté la party - tu es maintenant l'hôte.");
|
||||
}
|
||||
|
||||
async function loadTrackOptions(itemId: string) {
|
||||
|
|
@ -656,6 +736,24 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
|
|||
}
|
||||
}
|
||||
|
||||
/** Emby's own native intro/credits markers for `itemId`, if the library
|
||||
* it's from has detection enabled and the task has already reached it
|
||||
* - see CatalogService.getPlaybackMarkers. Reset first, synchronously,
|
||||
* not just on a fetch failure: without this, the OLD item's markers
|
||||
* (and skip button) would keep showing for however long this fetch
|
||||
* takes on the new one. */
|
||||
async function loadMarkers(itemId: string) {
|
||||
currentMarkers = null;
|
||||
skipIntroBtn.classList.add('hide');
|
||||
creditsControlsShown = false;
|
||||
try {
|
||||
currentMarkers = await getPlaybackMarkers(itemId);
|
||||
} catch (err) {
|
||||
console.error('[watchparty player] playback markers failed', err);
|
||||
// Non-fatal, same as loadTrackOptions above - playback works fine with no skip button.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* (Re)builds the subtitle <select> from the last-fetched playback
|
||||
* options, called both on a genuine item load and whenever burn-in/
|
||||
|
|
@ -925,10 +1023,50 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
|
|||
});
|
||||
updateFullscreenBtn();
|
||||
|
||||
/**
|
||||
* Picture-in-picture - asked for directly, mobile in particular: the
|
||||
* one way to keep watching in a small floating window while switching
|
||||
* to another app, since a phone has no second window/screen to put
|
||||
* this app in the way a desktop might. Feature-detected once at mount,
|
||||
* not live like #castBtn's own onCastAvailabilityChanged - unlike a
|
||||
* Chromecast appearing/disappearing on the network mid-session,
|
||||
* browser support for this is fixed for the lifetime of the page, so
|
||||
* there's nothing to react to after this first check. video itself
|
||||
* needs no new attribute for this to work - unlike disableremoteplayback
|
||||
* (added specifically to suppress Chrome's own auto cast icon), there's
|
||||
* no equivalent auto-injected native PiP control this app needs to
|
||||
* fight with.
|
||||
*/
|
||||
if (document.pictureInPictureEnabled && !video.disablePictureInPicture) {
|
||||
pipBtn.classList.remove('hide');
|
||||
pipBtn.addEventListener('click', () => {
|
||||
if (document.pictureInPictureElement) {
|
||||
document.exitPictureInPicture().catch(() => {});
|
||||
} else {
|
||||
video.requestPictureInPicture().catch((err) => {
|
||||
// Can genuinely fail even when supported (no user gesture in
|
||||
// some browsers' stricter reading of that, video not yet
|
||||
// ready) - same "try it, log it, don't crash anything else"
|
||||
// instinct as every other best-effort browser API this app
|
||||
// calls (video.play().catch, requestFullscreen().catch, ...).
|
||||
console.error('[watchparty player] picture-in-picture failed', err);
|
||||
});
|
||||
}
|
||||
});
|
||||
// Reflects state via the same [aria-pressed] treatment #castBtn
|
||||
// uses (see Player.css's .fsBtn[aria-pressed="true"]) - covers the
|
||||
// browser's own PiP window being closed directly (its native close
|
||||
// button, or the OS), not just this button's own click.
|
||||
video.addEventListener('enterpictureinpicture', () => pipBtn.setAttribute('aria-pressed', 'true'));
|
||||
video.addEventListener('leavepictureinpicture', () => pipBtn.setAttribute('aria-pressed', 'false'));
|
||||
}
|
||||
|
||||
async function onItemChanged(msg: ItemChangedMsg) {
|
||||
itemTitleEl.textContent = msg.itemName || '';
|
||||
setMediaSessionMetadata(msg.itemName || '', '', '', `/api/catalog/image/${msg.itemId}`);
|
||||
hasNextQueued = !!msg.hasNext;
|
||||
nextBtn.classList.toggle('hide', !(isHost && hasNextQueued));
|
||||
setMediaSessionNextAction(isHost && hasNextQueued ? goToNextItem : null);
|
||||
// A new item's own stream indices/subtitle pick have nothing to do
|
||||
// with the old one's - PartyService.advanceQueue already resets this
|
||||
// server-side too (see its own comment); this just keeps this client
|
||||
|
|
@ -940,6 +1078,7 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
|
|||
burnInAudioIndex = null;
|
||||
textSubtitleIndex = null;
|
||||
streamAudioIndex = null;
|
||||
loadMarkers(msg.itemId); // not awaited - never needed before playback can start, see loadVideo below
|
||||
await loadTrackOptions(msg.itemId);
|
||||
loadVideo(msg.itemId, 0, true);
|
||||
}
|
||||
|
|
@ -1075,22 +1214,42 @@ 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.
|
||||
requestPauseBtn.addEventListener('click', () => {
|
||||
function requestPause() {
|
||||
const now = Date.now();
|
||||
if (now < pauseRequestCooldownUntil || !conn) return;
|
||||
pauseRequestCooldownUntil = now + REQUEST_COOLDOWN_MS;
|
||||
conn.requestPause();
|
||||
});
|
||||
}
|
||||
|
||||
requestResumeBtn.addEventListener('click', () => {
|
||||
function requestResume() {
|
||||
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);
|
||||
|
|
@ -1102,8 +1261,22 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
|
|||
// image-subtitle option as disabled, as if they were a follower.
|
||||
if (party.youAreHost) becomeHost(false);
|
||||
|
||||
loadMarkers(party.itemId); // not awaited - see onItemChanged's own comment
|
||||
await loadTrackOptions(party.itemId);
|
||||
loadVideo(party.itemId, 0, false);
|
||||
// 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);
|
||||
|
||||
conn = connectParty(partyId, {
|
||||
onMembers: renderMembers,
|
||||
|
|
@ -1127,12 +1300,28 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
|
|||
if (takeOver) conn?.confirmDeviceTakeover();
|
||||
else navigate('/home');
|
||||
},
|
||||
onError: (message) => alert(message),
|
||||
onClose: () => {}
|
||||
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');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
nextBtn.addEventListener('click', () => { conn?.next(); });
|
||||
nextBtn.addEventListener('click', goToNextItem);
|
||||
(root.querySelector('#leaveBtn') as HTMLButtonElement).addEventListener('click', () => navigate('/home'));
|
||||
|
||||
init();
|
||||
|
|
@ -1142,11 +1331,13 @@ 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);
|
||||
|
|
|
|||
|
|
@ -25,8 +25,9 @@
|
|||
|
||||
.playlistActions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
gap: 12px 16px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
<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>
|
||||
|
|
|
|||
|
|
@ -2,11 +2,13 @@ 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
|
||||
|
|
@ -55,6 +57,15 @@ 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) : '';
|
||||
|
|
@ -93,7 +104,7 @@ export async function renderPlaylist(root: HTMLElement, params: RouteParams): Pr
|
|||
navigate(`/party/${party.partyId}`);
|
||||
}
|
||||
} catch (err) {
|
||||
alert((err as Error).message);
|
||||
showToast((err as Error).message, { variant: 'error' });
|
||||
btn.disabled = false;
|
||||
btn.textContent = idleLabel;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,20 @@
|
|||
|
||||
.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; }
|
||||
|
|
|
|||
|
|
@ -4,8 +4,16 @@
|
|||
<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>
|
||||
|
|
|
|||
|
|
@ -2,10 +2,12 @@ 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> {
|
||||
|
|
@ -34,11 +36,25 @@ 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>`)
|
||||
|
|
@ -49,13 +65,35 @@ 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');
|
||||
loadEpisodes(root, seriesId, tab.dataset.seasonId as string);
|
||||
selectedSeasonId = tab.dataset.seasonId as string;
|
||||
loadEpisodes(root, seriesId, selectedSeasonId);
|
||||
});
|
||||
});
|
||||
|
||||
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;
|
||||
|
|
@ -95,7 +133,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) {
|
||||
alert((err as Error).message);
|
||||
showToast((err as Error).message, { variant: 'error' });
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Lancer une watch party';
|
||||
}
|
||||
|
|
|
|||
81
client/src/views/staticParties/StaticParties.css
Normal file
81
client/src/views/staticParties/StaticParties.css
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
/* ---- "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; }
|
||||
}
|
||||
9
client/src/views/staticParties/StaticParties.html
Normal file
9
client/src/views/staticParties/StaticParties.html
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
<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>
|
||||
77
client/src/views/staticParties/StaticParties.ts
Normal file
77
client/src/views/staticParties/StaticParties.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
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('');
|
||||
}
|
||||
138
client/src/views/staticParty/StaticParty.css
Normal file
138
client/src/views/staticParty/StaticParty.css
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
/* ---- 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; }
|
||||
}
|
||||
39
client/src/views/staticParty/StaticParty.html
Normal file
39
client/src/views/staticParty/StaticParty.html
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
<button class="linklike" id="back">← 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>
|
||||
227
client/src/views/staticParty/StaticParty.ts
Normal file
227
client/src/views/staticParty/StaticParty.ts
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
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;
|
||||
});
|
||||
}
|
||||
31
client/src/views/staticParty/createStaticButton.ts
Normal file
31
client/src/views/staticParty/createStaticButton.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
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;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -8,3 +8,9 @@ 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
|
||||
|
|
|
|||
1498
docs/architecture.md
1498
docs/architecture.md
File diff suppressed because it is too large
Load diff
|
|
@ -46,6 +46,10 @@ 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"]
|
||||
|
|
|
|||
|
|
@ -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/musicQueueService.test.ts test/partyService.test.ts test/partyStreamService.test.ts test/playstateService.test.ts test/socketGateway.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/staticPartyService.test.ts test/transcodeService.test.ts"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
|
|
|
|||
|
|
@ -66,18 +66,28 @@ export class Logger {
|
|||
console.log(`[watchparty:transcode] ${new Date().toISOString()} ${message}`);
|
||||
}
|
||||
|
||||
/** The party watch-state sync (index.ts's periodic sweep, and
|
||||
* PartyService.advanceQueue/MusicQueueService.playNext's own item-
|
||||
* transition calls - see PlaystateService) - logs every outgoing
|
||||
* request (method + path, and the POST body for the UserData write)
|
||||
* and its outcome, not just failures: this is a best-effort
|
||||
* background sync with nothing else watching it, so `docker logs |
|
||||
* grep watchparty:playstate` needs to be enough on its own to see
|
||||
* exactly what was sent and whether Emby accepted it, without cross-
|
||||
* referencing `emby` above. Safe to log in full here (unlike `stream`
|
||||
* above) - the real embyToken travels as a header, never logged, and
|
||||
* PlaystateService's own tests confirm it never leaks into these lines. */
|
||||
/** Every per-member background write this app makes to Emby under
|
||||
* someone's own account - the party watch-state sync (index.ts's
|
||||
* periodic sweep, and PartyService.advanceQueue/MusicQueueService
|
||||
* .playNext's own item-transition calls - see PlaystateService), and
|
||||
* the parallel Sessions-API lifecycle reported alongside it (see
|
||||
* EmbySessionService). Logs every outgoing request (method + path,
|
||||
* and the POST body for the UserData write) and its outcome, not
|
||||
* just failures: this is a best-effort background sync with nothing
|
||||
* else watching it, so `docker logs | grep watchparty:playstate`
|
||||
* needs to be enough on its own to see exactly what was sent and
|
||||
* whether Emby accepted it, without cross-referencing `emby` above.
|
||||
* Safe to log in full here (unlike `stream` above) - the real
|
||||
* embyToken travels as a header, never logged, and both services'
|
||||
* own tests confirm it never leaks into these lines. */
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,3 +17,11 @@ 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';
|
||||
|
|
|
|||
|
|
@ -5,18 +5,24 @@ import http from 'http';
|
|||
import express, { type NextFunction, type Request, type Response } from 'express';
|
||||
import { Server as SocketIoServer } from 'socket.io';
|
||||
|
||||
import { EMBY_SERVER_URL, PORT } from './config';
|
||||
import { DATA_DIR, EMBY_SERVER_URL, PORT } from './config';
|
||||
import { Logger } from './Logger';
|
||||
import { AuthService, SESSION_COOKIE, SESSION_TTL_MS } from './services/AuthService';
|
||||
import { CatalogService, CatalogFetchError, imageUrlFor, splitPlaylistByMediaType } from './services/CatalogService';
|
||||
import { CatalogService, CatalogFetchError, imageUrlFor, seasonQueue, seriesQueueFrom, splitPlaylistByMediaType } from './services/CatalogService';
|
||||
import { PartyService } from './services/PartyService';
|
||||
import { MusicQueueService } from './services/MusicQueueService';
|
||||
import { TranscodeService } from './services/TranscodeService';
|
||||
import { PartyStreamService } from './services/PartyStreamService';
|
||||
import { PlaystateService } from './services/PlaystateService';
|
||||
import { EmbySessionService } from './services/EmbySessionService';
|
||||
import { CastTokenService } from './services/CastTokenService';
|
||||
import { StaticPartyService } from './services/StaticPartyService';
|
||||
import { StaticPartyStore } from './services/StaticPartyStore';
|
||||
import { SocketGateway } from './realtime/SocketGateway';
|
||||
import type { ClientToServerEvents, EmbySession, MediaType, QueueItem, ServerToClientEvents } from './types';
|
||||
import type { ClientToServerEvents, EmbyItem, EmbySession, ItemDto, MediaType, MusicTrackInput, QueueItem, ServerToClientEvents, StaticParty } from './types';
|
||||
|
||||
StaticPartyStore.configure(DATA_DIR);
|
||||
StaticPartyService.init();
|
||||
|
||||
const app = express();
|
||||
app.set('trust proxy', 1); // behind openresty/nginx - needed for req.secure to reflect the real scheme
|
||||
|
|
@ -212,6 +218,13 @@ app.get('/api/catalog/item/:itemId/playback-options', requireSession, asyncRoute
|
|||
res.json(await CatalogService.getPlaybackOptions(req.embySession!, param(req, 'itemId')));
|
||||
}));
|
||||
|
||||
// Emby's own native intro/credits detection, when the library it's from has
|
||||
// the feature enabled - powers the player's "Passer l'intro"/"Épisode
|
||||
// suivant" buttons. See CatalogService.getPlaybackMarkers.
|
||||
app.get('/api/catalog/item/:itemId/markers', requireSession, asyncRoute(async (req, res) => {
|
||||
res.json(await CatalogService.getPlaybackMarkers(req.embySession!, param(req, 'itemId')));
|
||||
}));
|
||||
|
||||
app.get('/api/catalog/libraries/:libraryId/items', requireSession, asyncRoute(async (req, res) => {
|
||||
res.json({ items: await CatalogService.getLibraryItems(req.embySession!, param(req, 'libraryId')) });
|
||||
}));
|
||||
|
|
@ -245,7 +258,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 watch party." });
|
||||
res.status(403).json({ error: "Tu n'es pas membre de cette party." });
|
||||
return;
|
||||
}
|
||||
await PartyStreamService.serveMasterPlaylist(req, res, party, session);
|
||||
|
|
@ -259,7 +272,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 watch party." });
|
||||
res.status(403).json({ error: "Tu n'es pas membre de cette party." });
|
||||
return;
|
||||
}
|
||||
await PartyStreamService.serveRawAsset(req, res, party, session);
|
||||
|
|
@ -289,7 +302,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 watch party." });
|
||||
res.status(403).json({ error: "Tu n'es pas membre de cette party." });
|
||||
return;
|
||||
}
|
||||
if (!party.burnInSubtitleIndex) {
|
||||
|
|
@ -305,7 +318,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 watch party." });
|
||||
res.status(403).json({ error: "Tu n'es pas membre de cette party." });
|
||||
return;
|
||||
}
|
||||
await TranscodeService.serveSegment(req, res, party, param(req, 'segment'));
|
||||
|
|
@ -337,6 +350,32 @@ app.post('/api/parties', requireSession, asyncRoute(async (req, res) => {
|
|||
return;
|
||||
}
|
||||
|
||||
// Neither is itself streamable (same reason a Playlist above isn't) -
|
||||
// launching one queues every track inside it instead: every album for
|
||||
// an artist, every track for an album, in Emby's own sort order (see
|
||||
// resolveMusicTracksForItem). Asked for directly, alongside the same
|
||||
// action on the party's own existing queue - see the "queue" route
|
||||
// below - both share this resolution.
|
||||
// A whole series - every season, in order, from S1E1 (see
|
||||
// seriesQueueFrom). Launched from the series page's own button or its
|
||||
// library tile; an episode's own button still starts from that episode
|
||||
// (the Episode branch below).
|
||||
if (item.Type === 'Series') {
|
||||
await createPartyFromSeries(res, session, item);
|
||||
return;
|
||||
}
|
||||
// Just one season - the series page's per-season button (see seasonQueue).
|
||||
if (item.Type === 'Season') {
|
||||
await createPartyFromSeason(res, session, item);
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.Type === 'MusicAlbum' || item.Type === 'MusicArtist') {
|
||||
const tracks = await resolveMusicTracksForItem(session, item);
|
||||
createMusicPartyFromTracks(res, session, tracks);
|
||||
return;
|
||||
}
|
||||
|
||||
const mediaType: MediaType = item.Type === 'Audio' ? 'music' : 'video';
|
||||
|
||||
// Starting a party from an episode queues the rest of the series from
|
||||
|
|
@ -347,13 +386,8 @@ 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 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 remaining = seriesQueueFrom(all, itemId);
|
||||
queue = (remaining.length ? remaining : [item]).map(toVideoQueueItem);
|
||||
}
|
||||
|
||||
const party = PartyService.create(
|
||||
|
|
@ -390,6 +424,17 @@ 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
|
||||
|
|
@ -424,41 +469,19 @@ async function createPartyFromPlaylist(res: Response, session: EmbySession, play
|
|||
const items = startIndex > 0 ? allItems.slice(startIndex) : allItems;
|
||||
|
||||
if (mediaType === 'music') {
|
||||
const first = items[0]!;
|
||||
const party = PartyService.create(
|
||||
session.userId, session.userName,
|
||||
first.Id, first.Name, imageUrlFor(first),
|
||||
null, mediaType
|
||||
);
|
||||
PartyService.setMemberEmbyToken(party, session.userId, session.embyToken);
|
||||
// Same mechanism POST /api/parties/:id/tracks already uses (see
|
||||
// below) - init seeds+auto-starts the first track, addTrack queues
|
||||
// the rest, exactly as if they'd been added one at a time.
|
||||
items.forEach((track, i) => {
|
||||
const trackInput = {
|
||||
const trackInputs: MusicTrackInput[] = items.map((track) => ({
|
||||
itemId: track.Id,
|
||||
itemName: track.Name,
|
||||
artistName: track.AlbumArtist || track.Artists?.[0] || null,
|
||||
albumName: track.Album || null,
|
||||
itemImageUrl: imageUrlFor(track),
|
||||
durationTicks: track.RunTimeTicks || null
|
||||
};
|
||||
if (i === 0) {
|
||||
MusicQueueService.init(party, trackInput, session.userId, session.userName);
|
||||
} else {
|
||||
MusicQueueService.addTrack(party, trackInput, session.userId, session.userName);
|
||||
}
|
||||
});
|
||||
SocketGateway.broadcastLobby(io);
|
||||
res.json(PartyService.toPublicState(party, session.userId));
|
||||
}));
|
||||
createMusicPartyFromTracks(res, session, trackInputs);
|
||||
return;
|
||||
}
|
||||
|
||||
const queue: QueueItem[] = items.map((e) => ({
|
||||
itemId: e.Id,
|
||||
itemName: e.Type === 'Episode' ? formatEpisodeName(e) : e.Name,
|
||||
itemImageUrl: `/api/catalog/image/${e.Id}`
|
||||
}));
|
||||
const queue: QueueItem[] = items.map(toVideoQueueItem);
|
||||
const party = PartyService.create(
|
||||
session.userId, session.userName,
|
||||
queue[0]!.itemId, queue[0]!.itemName, queue[0]!.itemImageUrl,
|
||||
|
|
@ -469,6 +492,112 @@ async function createPartyFromPlaylist(res: Response, session: EmbySession, play
|
|||
res.json(PartyService.toPublicState(party, session.userId));
|
||||
}
|
||||
|
||||
/** The "launch the whole series" half of POST /api/parties - a watch
|
||||
* party queued with every episode of every season (see seriesQueueFrom). */
|
||||
async function createPartyFromSeries(res: Response, session: EmbySession, series: EmbyItem): Promise<void> {
|
||||
const episodes = await CatalogService.getAllSeriesEpisodesRaw(session, series.Id);
|
||||
const queue = seriesQueueFrom(episodes).map(toVideoQueueItem);
|
||||
if (!queue.length) {
|
||||
res.status(400).json({ error: "Cette série n'a aucun épisode." });
|
||||
return;
|
||||
}
|
||||
createVideoPartyFromQueue(res, session, queue, `series="${series.Name}"`);
|
||||
}
|
||||
|
||||
/** The "launch just this season" half of POST /api/parties - only that
|
||||
* season's episodes, nothing after it (see seasonQueue). */
|
||||
async function createPartyFromSeason(res: Response, session: EmbySession, season: EmbyItem): Promise<void> {
|
||||
if (!season.SeriesId) {
|
||||
res.status(400).json({ error: 'Saison introuvable.' });
|
||||
return;
|
||||
}
|
||||
const episodes = await CatalogService.getAllSeriesEpisodesRaw(session, season.SeriesId);
|
||||
const queue = seasonQueue(episodes, season).map(toVideoQueueItem);
|
||||
if (!queue.length) {
|
||||
res.status(400).json({ error: "Cette saison n'a aucun épisode." });
|
||||
return;
|
||||
}
|
||||
createVideoPartyFromQueue(res, session, queue, `season="${season.Name}" series=${season.SeriesId}`);
|
||||
}
|
||||
|
||||
/** Creates a video watch party playing `queue` in order, hosted by
|
||||
* `session` - shared by the whole-series and single-season launches. */
|
||||
function createVideoPartyFromQueue(res: Response, session: EmbySession, queue: QueueItem[], logContext: string): void {
|
||||
const first = queue[0]!;
|
||||
const party = PartyService.create(session.userId, session.userName, first.itemId, first.itemName, first.itemImageUrl, queue, 'video');
|
||||
PartyService.setMemberEmbyToken(party, session.userId, session.embyToken);
|
||||
Logger.party(`queue launch partyId=${party.id} ${logContext} episodes=${queue.length}`);
|
||||
SocketGateway.broadcastLobby(io);
|
||||
res.json(PartyService.toPublicState(party, session.userId));
|
||||
}
|
||||
|
||||
/** A raw Emby item known/expected to be music-shaped (Audio, MusicAlbum,
|
||||
* or MusicArtist - the three POST /api/parties and the "queue" route
|
||||
* below ever call this for) resolved into a flat, ordered track list -
|
||||
* a single track for Audio, every track in Emby's own IndexNumber order
|
||||
* for an album, every album's tracks in Emby's own ProductionYear/
|
||||
* SortName order for an artist's whole discography. Two Emby round
|
||||
* trips for an artist's own albums+tracks would be one call each if
|
||||
* this app ever needed to page through a huge discography - not
|
||||
* attempted speculatively, since Emby has yet to show any sign of
|
||||
* needing it for a personal library's worth of artists. */
|
||||
async function resolveMusicTracksForItem(session: EmbySession, item: EmbyItem): Promise<MusicTrackInput[]> {
|
||||
if (item.Type === 'MusicAlbum') {
|
||||
const tracks = await CatalogService.getAlbumTracks(session, item.Id);
|
||||
return tracks.map(itemDtoToTrackInput);
|
||||
}
|
||||
if (item.Type === 'MusicArtist') {
|
||||
const albums = await CatalogService.getArtistAlbums(session, item.Id);
|
||||
const perAlbum = await Promise.all(albums.map((album) => CatalogService.getAlbumTracks(session, album.id)));
|
||||
return perAlbum.flat().map(itemDtoToTrackInput);
|
||||
}
|
||||
// A single Audio track - the only other type this is ever called for.
|
||||
return [{
|
||||
itemId: item.Id,
|
||||
itemName: item.Name,
|
||||
artistName: item.AlbumArtist || item.Artists?.[0] || null,
|
||||
albumName: item.Album || null,
|
||||
itemImageUrl: imageUrlFor(item),
|
||||
durationTicks: item.RunTimeTicks || null
|
||||
}];
|
||||
}
|
||||
|
||||
function itemDtoToTrackInput(item: ItemDto): MusicTrackInput {
|
||||
return {
|
||||
itemId: item.id,
|
||||
itemName: item.name,
|
||||
artistName: item.artistName,
|
||||
albumName: item.albumName,
|
||||
itemImageUrl: item.imageUrl,
|
||||
durationTicks: item.durationTicks
|
||||
};
|
||||
}
|
||||
|
||||
/** Shared by every "launch this as a listen party" path that can produce
|
||||
* more than one track up front (an album, an artist's discography, a
|
||||
* music playlist - see createPartyFromPlaylist above and the
|
||||
* MusicAlbum/MusicArtist branch of POST /api/parties below) - seeds
|
||||
* party.music with `tracks`, in order: the first auto-starts,
|
||||
* everything else queues behind it via MusicQueueService.addTracks
|
||||
* (one broadcast for however many tracks that is, not one per track). */
|
||||
function createMusicPartyFromTracks(res: Response, session: EmbySession, tracks: MusicTrackInput[]): void {
|
||||
if (!tracks.length) {
|
||||
res.status(400).json({ error: 'Aucun morceau à lire.' });
|
||||
return;
|
||||
}
|
||||
const [first, ...rest] = tracks;
|
||||
const party = PartyService.create(
|
||||
session.userId, session.userName,
|
||||
first!.itemId, first!.itemName, first!.itemImageUrl,
|
||||
null, 'music'
|
||||
);
|
||||
PartyService.setMemberEmbyToken(party, session.userId, session.embyToken);
|
||||
MusicQueueService.init(party, first!, session.userId, session.userName);
|
||||
MusicQueueService.addTracks(party, rest, session.userId, session.userName);
|
||||
SocketGateway.broadcastLobby(io);
|
||||
res.json(PartyService.toPublicState(party, session.userId));
|
||||
}
|
||||
|
||||
// Adding a track to a music party's shared queue is REST, not a socket
|
||||
// event (see SocketGateway's top comment): the album-browsing page that
|
||||
// this is actually called from has no live connection to the party it's
|
||||
|
|
@ -479,12 +608,12 @@ async function createPartyFromPlaylist(res: Response, session: EmbySession, play
|
|||
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: 'Watch party musique introuvable.' });
|
||||
res.status(404).json({ error: 'Listen party introuvable.' });
|
||||
return;
|
||||
}
|
||||
const session = req.embySession!;
|
||||
if (!party.members.has(session.userId)) {
|
||||
res.status(403).json({ error: "Tu n'es pas membre de cette watch party." });
|
||||
res.status(403).json({ error: "Tu n'es pas membre de cette party." });
|
||||
return;
|
||||
}
|
||||
const track = req.body || {};
|
||||
|
|
@ -497,6 +626,39 @@ app.post('/api/parties/:partyId/tracks', requireSession, (req, res) => {
|
|||
res.json(PartyService.toPublicState(party, session.userId));
|
||||
});
|
||||
|
||||
// Same "queue this item" resolution as the MusicAlbum/MusicArtist branch
|
||||
// of POST /api/parties above (a track queues itself; an album/artist
|
||||
// expands into every track inside it - see resolveMusicTracksForItem),
|
||||
// but adding into an ALREADY-LIVE party's queue instead of creating a
|
||||
// new one - views/album's and views/artist's own "add to queue" buttons.
|
||||
// Kept as its own route rather than overloading POST .../tracks above:
|
||||
// that one takes an already-built track object the caller already has
|
||||
// in hand (views/album's per-track buttons) - making it also guess
|
||||
// whether it received that or a bare id-to-resolve would be more
|
||||
// surprising than a second, clearly-named route.
|
||||
app.post('/api/parties/:partyId/queue', requireSession, asyncRoute(async (req, res) => {
|
||||
const party = PartyService.getById(param(req, 'partyId'));
|
||||
if (!party || party.mediaType !== 'music') {
|
||||
res.status(404).json({ error: 'Listen party introuvable.' });
|
||||
return;
|
||||
}
|
||||
const session = req.embySession!;
|
||||
if (!party.members.has(session.userId)) {
|
||||
res.status(403).json({ error: "Tu n'es pas membre de cette party." });
|
||||
return;
|
||||
}
|
||||
const itemId = req.body?.itemId;
|
||||
if (!itemId) {
|
||||
res.status(400).json({ error: 'itemId is required.' });
|
||||
return;
|
||||
}
|
||||
const item = await CatalogService.getRawItem(session, itemId);
|
||||
const tracks = await resolveMusicTracksForItem(session, item);
|
||||
MusicQueueService.addTracks(party, tracks, session.userId, session.userName);
|
||||
SocketGateway.broadcastLobby(io); // the queue/now-playing preview may have just changed
|
||||
res.json(PartyService.toPublicState(party, session.userId));
|
||||
}));
|
||||
|
||||
app.post('/api/parties/:partyId/join', requireSession, (req, res) => {
|
||||
const party = PartyService.getById(param(req, 'partyId'));
|
||||
if (!party) {
|
||||
|
|
@ -506,6 +668,12 @@ 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));
|
||||
});
|
||||
|
|
@ -513,10 +681,17 @@ 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 });
|
||||
});
|
||||
|
||||
|
|
@ -539,7 +714,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 watch party." });
|
||||
res.status(403).json({ error: "Tu n'es pas membre de cette party." });
|
||||
return;
|
||||
}
|
||||
res.json({ token: CastTokenService.issue(party.id, session) });
|
||||
|
|
@ -553,6 +728,229 @@ 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
|
||||
|
|
@ -606,17 +1004,70 @@ setInterval(() => PartyStreamService.reapIdleSessions(30 * 60 * 1000), 5 * 60 *
|
|||
// Marking an item Played (once the party moves past it) is handled
|
||||
// separately, at the source of that transition - see
|
||||
// PartyService.advanceQueue and MusicQueueService.playNext.
|
||||
//
|
||||
// Also where EmbySessionService's own start/progress/stop lifecycle is
|
||||
// driven from, rather than scattered across every queue-advance/track-
|
||||
// skip call site the way markPlayed needed to be - this loop already
|
||||
// visits every connected member on every tick, so it's already in
|
||||
// exactly the right place to notice "this member's current item just
|
||||
// changed" (member.embySession.itemId no longer matches) and react:
|
||||
// stop the old session if there was one, negotiate a fresh
|
||||
// MediaSourceId/PlaySessionId under their own account, and start the
|
||||
// new one. Same item as last tick just reports Progress with the
|
||||
// already-cached ids - no renegotiation needed for that, the common case.
|
||||
setInterval(() => {
|
||||
for (const party of PartyService.listActive()) {
|
||||
const state = PartyService.getCurrentPlaybackState(party);
|
||||
if (!state) continue; // nothing currently playing (e.g. an empty music queue)
|
||||
// A static watch party's group session: remember where the group is,
|
||||
// so the next session resumes there (no-op for any other party).
|
||||
if (party.staticLink) StaticPartyService.recordPosition(party.staticLink, state.itemId, state.positionTicks);
|
||||
for (const member of party.members.values()) {
|
||||
if (!member.connection || !member.embyToken) continue;
|
||||
PlaystateService.updatePosition(member.embyToken, member.userId, state.itemId, state.positionTicks);
|
||||
|
||||
if (member.embySession && member.embySession.itemId === state.itemId) {
|
||||
const s = member.embySession;
|
||||
EmbySessionService.progress(member.embyToken, s.itemId, s.mediaSourceId, s.playSessionId, state.positionTicks, state.isPaused);
|
||||
continue;
|
||||
}
|
||||
|
||||
// The item just changed for them (or this is the first tick since
|
||||
// they started watching/listening) - stop whatever they had open
|
||||
// before, if anything, then negotiate and start a fresh one.
|
||||
const previous = member.embySession;
|
||||
const embyToken = member.embyToken;
|
||||
member.embySession = null;
|
||||
if (previous) {
|
||||
EmbySessionService.stop(embyToken, previous.itemId, previous.mediaSourceId, previous.playSessionId, member.positionTicks);
|
||||
}
|
||||
// CatalogService's own methods all take a full EmbySession, for
|
||||
// consistency with every live user-facing route that already has
|
||||
// one from requireSession - this sweep only ever has a member's
|
||||
// bare stored token, same situation PlaystateService/
|
||||
// EmbySessionService are already in. serverId/createdUtc are safe
|
||||
// to fake: negotiatePlaybackSession (like every per-member Emby
|
||||
// write in this app) never reads either, only embyToken/userId.
|
||||
const fakeSession: EmbySession = { embyToken, userId: member.userId, userName: member.userName, serverId: '', createdUtc: 0 };
|
||||
CatalogService.negotiatePlaybackSession(fakeSession, state.itemId).then((negotiated) => {
|
||||
if (!negotiated) return;
|
||||
member.embySession = { itemId: state.itemId, mediaSourceId: negotiated.mediaSourceId, playSessionId: negotiated.playSessionId };
|
||||
EmbySessionService.start(embyToken, state.itemId, negotiated.mediaSourceId, negotiated.playSessionId, state.positionTicks, state.isPaused);
|
||||
});
|
||||
}
|
||||
}
|
||||
}, 20 * 1000);
|
||||
|
||||
// A pending (debounced) static-party save must reach disk before the
|
||||
// process exits - `docker stop` sends SIGTERM, Ctrl+C sends SIGINT.
|
||||
for (const signal of ['SIGTERM', 'SIGINT'] as const) {
|
||||
process.once(signal, () => {
|
||||
Logger.staticParty(`${signal} received - flushing before exit`);
|
||||
StaticPartyStore.flushSync();
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`[watchparty] listening on :${PORT} (Emby server: ${EMBY_SERVER_URL || 'NOT SET'})`);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
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';
|
||||
|
||||
/**
|
||||
|
|
@ -35,6 +36,9 @@ 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,
|
||||
|
|
@ -207,6 +211,17 @@ 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
|
||||
|
|
@ -283,6 +298,12 @@ class SocketGatewayClass {
|
|||
MusicQueueService.setPaused(party, !!msg?.isPaused);
|
||||
}));
|
||||
|
||||
// No host check either, same reasoning - see MusicQueueService.seek.
|
||||
socket.on('musicSeek', this.safe((msg: { positionSeconds: number }) => {
|
||||
if (!party || party.mediaType !== 'music' || !party.members.get(userId)) return;
|
||||
MusicQueueService.seek(party, Number(msg?.positionSeconds) || 0);
|
||||
}));
|
||||
|
||||
// Anyone (not just followers - harmless either way) can ask the host
|
||||
// to pause; the presence overlay (the video player) is what triggers
|
||||
// this. Broadcast to the whole party, not just the host, so everyone
|
||||
|
|
@ -404,8 +425,11 @@ class SocketGatewayClass {
|
|||
}
|
||||
|
||||
private lobbySnapshot(): LobbyPartyDto[] {
|
||||
return PartyService.listActive().map((party) => {
|
||||
// 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) => {
|
||||
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
|
||||
|
|
@ -423,7 +447,9 @@ 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
|
||||
positionUpdatedUtc: isVideo && host ? host.lastUpdatedUtc : null,
|
||||
staticPartyId: staticParty ? staticParty.id : null,
|
||||
staticPartyName: staticParty ? staticParty.name : null
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,14 +4,15 @@ import type { Request, Response } from 'express';
|
|||
import { EMBY_SERVER_URL } from '../config';
|
||||
import { EmbyClient } from '../emby/EmbyClient';
|
||||
import { Logger } from '../Logger';
|
||||
import type { EmbyItem, EmbySession, ItemDto, LibraryDto, MediaType, PlaybackOptionsDto, SeasonDto } from '../types';
|
||||
import type { EmbyItem, EmbySession, ItemDto, LibraryDto, MediaType, PlaybackMarkersDto, PlaybackOptionsDto, SeasonDto } from '../types';
|
||||
|
||||
// 'MusicVideo' is Emby's own item type for a musicvideos library's
|
||||
// contents (distinct from the generic 'Video', which is home videos).
|
||||
// 'Audio' is a music track. Series/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']);
|
||||
// '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']);
|
||||
|
||||
// Emby's /Views can return all sorts of things beyond actual media
|
||||
// libraries - boxset collections, live TV, photos, books, a mixed-content
|
||||
|
|
@ -68,6 +69,36 @@ 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';
|
||||
|
|
@ -125,9 +156,23 @@ 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. */
|
||||
* 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. */
|
||||
async getRawItem(session: EmbySession, itemId: string): Promise<EmbyItem> {
|
||||
return this.fetchJson<EmbyItem>(`/Users/${session.userId}/Items/${itemId}`, session, 'Titre introuvable.', { preserveEmbyStatus: true });
|
||||
return this.fetchJson<EmbyItem>(`/Users/${session.userId}/Items/${itemId}?Fields=AlbumId`, session, 'Titre introuvable.', { preserveEmbyStatus: true });
|
||||
}
|
||||
|
||||
async getItem(session: EmbySession, itemId: string): Promise<ItemDto> {
|
||||
|
|
@ -205,7 +250,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' });
|
||||
const params = new URLSearchParams({ ParentId: libraryId, UserId: session.userId, SortBy: 'SortName', Fields: '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));
|
||||
}
|
||||
|
|
@ -261,7 +306,7 @@ class CatalogServiceClass {
|
|||
ParentId: libraryId,
|
||||
Recursive: 'false', // top-level: Movies as movies, TV as Series tiles (not every episode)
|
||||
SortBy: 'SortName',
|
||||
Fields: 'ProductionYear,AlbumId',
|
||||
Fields: 'ProductionYear,AlbumId,SortName',
|
||||
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.');
|
||||
|
|
@ -286,6 +331,30 @@ class CatalogServiceClass {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Emby's own native intro/credits detection - see PlaybackMarkersDto's
|
||||
* own comment for what MarkerType actually looks like and where that
|
||||
* was confirmed. Read from the same Chapters field getRawItem/toItemDto
|
||||
* never request (they have no use for chapters at all) - a dedicated
|
||||
* fetch here rather than folding it into getItem, since a chapter list
|
||||
* is only ever needed by the player deciding whether to show a skip
|
||||
* button, nowhere a plain ItemDto is used.
|
||||
*/
|
||||
async getPlaybackMarkers(session: EmbySession, itemId: string): Promise<PlaybackMarkersDto> {
|
||||
const data = await this.fetchJson<{ Chapters?: Array<{ StartPositionTicks: number; MarkerType?: string }> }>(
|
||||
`/Users/${session.userId}/Items/${itemId}?Fields=Chapters`,
|
||||
session,
|
||||
'Impossible de récupérer les marqueurs de lecture.'
|
||||
);
|
||||
const chapters = data.Chapters || [];
|
||||
const startOf = (markerType: string) => chapters.find((c) => c.MarkerType === markerType)?.StartPositionTicks ?? null;
|
||||
return {
|
||||
introStartTicks: startOf('IntroStart'),
|
||||
introEndTicks: startOf('IntroEnd'),
|
||||
creditsStartTicks: startOf('CreditsStart')
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The negotiated MediaSource's id/container/streams - used by
|
||||
* TranscodeService to build its own ffmpeg source URL and translate
|
||||
|
|
@ -307,6 +376,33 @@ class CatalogServiceClass {
|
|||
return { id: source.Id, container: source.Container || 'mkv', streams: source.MediaStreams || [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* A fresh PlaybackInfo negotiation under `session`'s own account,
|
||||
* purely to get the MediaSourceId/PlaySessionId EmbySessionService
|
||||
* needs to open a real Emby Sessions-API entry for this member - see
|
||||
* its own class comment for why every party member needs their own,
|
||||
* rather than reusing the one PartyStreamService already negotiated
|
||||
* for the party's actual shared relay (that one was negotiated once,
|
||||
* under whichever member happened to trigger it first - never
|
||||
* guaranteed valid for every OTHER member's own account, since Emby
|
||||
* scopes PlaybackInfo per user). Null on any failure (an expired
|
||||
* token, Emby unreachable, no source at all) rather than throwing -
|
||||
* this is reported to an admin dashboard as a nice-to-have, never
|
||||
* something that should be allowed to disrupt real playback the way
|
||||
* getMediaSource's own hard failure is allowed to (that one feeds an
|
||||
* actual ffmpeg source).
|
||||
*/
|
||||
async negotiatePlaybackSession(session: EmbySession, itemId: string): Promise<{ mediaSourceId: string; playSessionId: string } | null> {
|
||||
try {
|
||||
const info = await this.fetchJson<PlaybackInfoResponse>(`/Items/${itemId}/PlaybackInfo?UserId=${session.userId}`, session, 'Impossible de négocier une session de lecture.');
|
||||
const source = (info.MediaSources || [])[0];
|
||||
if (!source?.Id || !info.PlaySessionId) return null;
|
||||
return { mediaSourceId: source.Id, playSessionId: info.PlaySessionId };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Direct proxying (images/video/audio) - these need the raw
|
||||
// Express req/res, unlike everything above which just returns JSON. ---
|
||||
|
||||
|
|
@ -452,6 +548,7 @@ class CatalogServiceClass {
|
|||
return {
|
||||
id: item.Id,
|
||||
name: item.Name,
|
||||
sortName: item.SortName || item.Name,
|
||||
overview: item.Overview,
|
||||
year: item.ProductionYear,
|
||||
type: item.Type,
|
||||
|
|
|
|||
104
server/src/services/EmbySessionService.ts
Normal file
104
server/src/services/EmbySessionService.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
'use strict';
|
||||
|
||||
import { EmbyClient } from '../emby/EmbyClient';
|
||||
import { Logger } from '../Logger';
|
||||
|
||||
/**
|
||||
* Reports a party member's own playback to Emby's real Sessions API
|
||||
* (`POST /Sessions/Playing`, `/Sessions/Playing/Progress`,
|
||||
* `/Sessions/Playing/Stopped`) - the piece of session lifecycle
|
||||
* docs/architecture.md's "Sync watch-party progress back to Emby"
|
||||
* section deliberately deferred at the time ("wiring clean start/stop
|
||||
* into every transition... is real complexity for a secondary payoff").
|
||||
* Asked for directly since: without this, this app's own streams never
|
||||
* showed up in Emby's admin "now playing" dashboard the way a real web/
|
||||
* mobile/TV client's would - only the resume-position/watched-history
|
||||
* UserData (PlaystateService) was ever visible anywhere, and only by
|
||||
* opening the item itself, not from the dashboard's live session list.
|
||||
*
|
||||
* One real Emby session per PARTY MEMBER, not one per party - a
|
||||
* faithful copy of how a real client behaves (every device/account gets
|
||||
* its own), and the actual point of the request ("let admin see who's
|
||||
* watching what") would be lost by collapsing every member onto one
|
||||
* shared entry. Every call needs the member's own `embyToken` (the same
|
||||
* source every other per-member Emby write in this app already uses -
|
||||
* see `PartyMember.embyToken`/`PlaystateService`) - Emby's Sessions API
|
||||
* is inherently per-account, there's no equivalent of "the party's" own
|
||||
* session to report against instead.
|
||||
*
|
||||
* `MediaSourceId`/`PlaySessionId` (see `CatalogService
|
||||
* .negotiatePlaybackSession`) are required in practice, not just
|
||||
* present in the schema - confirmed by reading the real server's own
|
||||
* `/openapi.json` rather than assumed, after this exact neighboring
|
||||
* area (`PlaystateService`'s own history) already got bitten twice by
|
||||
* an Emby endpoint accepting a request shape that looked complete but
|
||||
* wasn't. `index.ts`'s periodic sweep is what actually drives the
|
||||
* start/progress/stop transitions - see its own comment for why that
|
||||
* one place could cover this instead of scattering hooks across every
|
||||
* queue-advance/track-skip call site the way `PlaystateService
|
||||
* .markPlayed` needed to.
|
||||
*
|
||||
* Best-effort throughout, exactly like `PlaystateService`: any failure
|
||||
* is caught and logged here, never thrown - none of this may ever
|
||||
* disrupt real playback over an admin-dashboard nicety.
|
||||
*/
|
||||
class EmbySessionServiceClass {
|
||||
/** Called once per member when they start watching/listening to
|
||||
* `itemId` - a fresh join, or the party moving to a new item while
|
||||
* they're already a member (see index.ts's sweep, which detects that
|
||||
* by comparing against `PartyMember.embySession.itemId`). */
|
||||
async start(embyToken: string, itemId: string, mediaSourceId: string, playSessionId: string, positionTicks: number, isPaused: boolean): Promise<void> {
|
||||
await this.report('/Sessions/Playing', embyToken, {
|
||||
ItemId: itemId,
|
||||
MediaSourceId: mediaSourceId,
|
||||
PlaySessionId: playSessionId,
|
||||
PositionTicks: Math.round(positionTicks),
|
||||
IsPaused: isPaused,
|
||||
PlayMethod: 'Transcode',
|
||||
CanSeek: true
|
||||
});
|
||||
}
|
||||
|
||||
/** Called periodically (~20s, alongside `PlaystateService
|
||||
* .updatePosition` - see index.ts's sweep) for every member with a
|
||||
* live session already open for their current item. */
|
||||
async progress(embyToken: string, itemId: string, mediaSourceId: string, playSessionId: string, positionTicks: number, isPaused: boolean): Promise<void> {
|
||||
await this.report('/Sessions/Playing/Progress', embyToken, {
|
||||
ItemId: itemId,
|
||||
MediaSourceId: mediaSourceId,
|
||||
PlaySessionId: playSessionId,
|
||||
PositionTicks: Math.round(positionTicks),
|
||||
IsPaused: isPaused,
|
||||
PlayMethod: 'Transcode'
|
||||
});
|
||||
}
|
||||
|
||||
/** Called once per member when they stop watching/listening to
|
||||
* `itemId` - leaving the party (`PartyService.leave`), the item
|
||||
* changing (index.ts's sweep), or the party being destroyed for
|
||||
* inactivity (`PartyService.removeIdleParties`). */
|
||||
async stop(embyToken: string, itemId: string, mediaSourceId: string, playSessionId: string, positionTicks: number): Promise<void> {
|
||||
await this.report('/Sessions/Playing/Stopped', embyToken, {
|
||||
ItemId: itemId,
|
||||
MediaSourceId: mediaSourceId,
|
||||
PlaySessionId: playSessionId,
|
||||
PositionTicks: Math.round(positionTicks)
|
||||
});
|
||||
}
|
||||
|
||||
/** Every call logs its own outgoing request + outcome - see
|
||||
* Logger.playstate's own comment for why this needs to be enough on
|
||||
* its own, greppable, to answer "is this actually reaching Emby, and
|
||||
* with what" for this service too, not just PlaystateService. */
|
||||
private async report(path: string, embyToken: string, body: Record<string, unknown>): Promise<void> {
|
||||
Logger.playstate(`-> POST ${path} itemId=${body.ItemId}`);
|
||||
try {
|
||||
const res = await EmbyClient.fetch(path, embyToken, { method: 'POST', body });
|
||||
Logger.playstate(`<- ${res.status} ${res.ok ? 'ok' : 'FAILED'} ${path} itemId=${body.ItemId}`);
|
||||
} catch (err) {
|
||||
Logger.playstate(`<- threw ${path} itemId=${body.ItemId}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const EmbySessionService = new EmbySessionServiceClass();
|
||||
|
|
@ -52,7 +52,42 @@ class MusicQueueServiceClass {
|
|||
|
||||
addTrack(party: Party, track: MusicTrackInput, addedByUserId: string, addedByUserName: string): void {
|
||||
const music = party.music!;
|
||||
const entry: MusicTrack = {
|
||||
const entry = this.buildTrackEntry(track, addedByUserId, addedByUserName);
|
||||
music.queue.push(entry);
|
||||
party.lastActivityUtc = Date.now();
|
||||
Logger.party(`musicQueueAdd partyId=${party.id} user=${Logger.who(addedByUserId, addedByUserName)} track="${entry.itemName}" queueLength=${music.queue.length}`);
|
||||
if (!music.current) {
|
||||
this.playNext(party); // queue was empty - this track starts immediately
|
||||
}
|
||||
this.broadcast(party);
|
||||
}
|
||||
|
||||
/**
|
||||
* Same per-track behavior as addTrack (auto-starts if the queue was
|
||||
* empty when the first of these lands), but ONE broadcast at the end
|
||||
* instead of one per track - asked for directly, to queue a whole
|
||||
* album or an artist's entire discography in one action rather than
|
||||
* one track at a time. Without this, a hundred-track discography
|
||||
* would spam the party with a hundred intermediate "queueUpdated"
|
||||
* events for what's really a single user action - see index.ts's
|
||||
* createMusicPartyFromTracks/the "queue" route, the two callers.
|
||||
*/
|
||||
addTracks(party: Party, tracks: MusicTrackInput[], addedByUserId: string, addedByUserName: string): void {
|
||||
if (!tracks.length) return;
|
||||
const music = party.music!;
|
||||
for (const track of tracks) {
|
||||
music.queue.push(this.buildTrackEntry(track, addedByUserId, addedByUserName));
|
||||
if (!music.current) {
|
||||
this.playNext(party);
|
||||
}
|
||||
}
|
||||
party.lastActivityUtc = Date.now();
|
||||
Logger.party(`musicQueueAddBatch partyId=${party.id} user=${Logger.who(addedByUserId, addedByUserName)} count=${tracks.length} queueLength=${music.queue.length}`);
|
||||
this.broadcast(party);
|
||||
}
|
||||
|
||||
private buildTrackEntry(track: MusicTrackInput, addedByUserId: string, addedByUserName: string): MusicTrack {
|
||||
return {
|
||||
itemId: track.itemId,
|
||||
itemName: track.itemName,
|
||||
artistName: track.artistName || null,
|
||||
|
|
@ -64,13 +99,6 @@ class MusicQueueServiceClass {
|
|||
addedByUserId,
|
||||
addedByUserName
|
||||
};
|
||||
music.queue.push(entry);
|
||||
party.lastActivityUtc = Date.now();
|
||||
Logger.party(`musicQueueAdd partyId=${party.id} user=${Logger.who(addedByUserId, addedByUserName)} track="${entry.itemName}" queueLength=${music.queue.length}`);
|
||||
if (!music.current) {
|
||||
this.playNext(party); // queue was empty - this track starts immediately
|
||||
}
|
||||
this.broadcast(party);
|
||||
}
|
||||
|
||||
/** No host check - anyone can toggle this (see class comment). */
|
||||
|
|
@ -92,6 +120,41 @@ class MusicQueueServiceClass {
|
|||
this.broadcast(party);
|
||||
}
|
||||
|
||||
/**
|
||||
* No host check - anyone can do this (see class comment): a seek is
|
||||
* just a continuous version of pause/resume, reversible and not a
|
||||
* change to what's playing - not the majority-vote model reserved for
|
||||
* actually skipping to different content. Clamped into
|
||||
* [0, durationSeconds] when the duration is known (an unknown one -
|
||||
* never actually seen in practice, see MusicTrack - just trusts the
|
||||
* caller's own non-negative value, same as the video player's seek
|
||||
* has no server-side duration to clamp against either).
|
||||
*/
|
||||
seek(party: Party, positionSeconds: number): void {
|
||||
const current = party.music?.current;
|
||||
if (!current) return;
|
||||
const duration = current.track.durationSeconds;
|
||||
const clampedSeconds = Math.max(0, duration != null ? Math.min(positionSeconds, duration) : positionSeconds);
|
||||
const elapsedMs = clampedSeconds * 1000;
|
||||
if (current.isPaused) {
|
||||
current.pausedElapsedMs = elapsedMs;
|
||||
} else {
|
||||
// Must clear the existing timer first, not just overwrite it -
|
||||
// scheduleAdvance only ever sets advanceTimer, it never clears a
|
||||
// prior one itself (setPaused's own resume branch gets away with
|
||||
// skipping this only because pausing already cleared it - see its
|
||||
// own "if (isPaused)" branch). Skipping this here would leave the
|
||||
// OLD, now-wrong-duration timer running alongside the new one,
|
||||
// eventually firing playNext at the wrong time.
|
||||
this.clearTimer(party);
|
||||
current.startedUtc = Date.now() - elapsedMs;
|
||||
this.scheduleAdvance(party);
|
||||
}
|
||||
party.lastActivityUtc = Date.now();
|
||||
Logger.party(`musicSeek partyId=${party.id} -> ${clampedSeconds}s`);
|
||||
this.broadcast(party);
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers `userId`'s vote to skip the current track - a strict
|
||||
* majority of `connectedMemberCount` (computed by the caller, which has
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ import { TranscodeService } from './TranscodeService';
|
|||
import { PartyStreamService } from './PartyStreamService';
|
||||
import { PlaystateService } from './PlaystateService';
|
||||
import { CastTokenService } from './CastTokenService';
|
||||
import { EmbySessionService } from './EmbySessionService';
|
||||
import { StaticPartyService } from './StaticPartyService';
|
||||
import type { LeaveResult, MediaType, Party, PartyMember, PartyPublicState, QueueItem } from '../types';
|
||||
|
||||
/**
|
||||
|
|
@ -143,6 +145,30 @@ 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
|
||||
|
|
@ -157,6 +183,15 @@ class PartyServiceClass {
|
|||
party.lastActivityUtc = Date.now();
|
||||
MusicQueueService.removeVote(party, userId); // no-op for a video party (party.music is unset)
|
||||
CastTokenService.revokeForMember(party.id, userId); // no-op if they never cast - see its own comment for why this runs unconditionally, not just on an explicit "stop casting"
|
||||
// No-op if they never had a live Emby Sessions-API entry (never
|
||||
// connected, or the periodic sweep hadn't reached them yet) - see
|
||||
// EmbySessionService's own comment. Fire-and-forget, same as every
|
||||
// other per-member Emby report in this app - it already swallows its
|
||||
// own failures, and leaving the party must never wait on Emby.
|
||||
if (leavingMember?.embyToken && leavingMember.embySession) {
|
||||
const s = leavingMember.embySession;
|
||||
EmbySessionService.stop(leavingMember.embyToken, s.itemId, s.mediaSourceId, s.playSessionId, leavingMember.positionTicks);
|
||||
}
|
||||
|
||||
let newHostUserId: string | null = party.hostUserId;
|
||||
if (wasHost) {
|
||||
|
|
@ -175,6 +210,7 @@ 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)!;
|
||||
|
|
@ -196,7 +232,9 @@ class PartyServiceClass {
|
|||
if (party.queueIndex >= party.queue.length - 1) {
|
||||
return false;
|
||||
}
|
||||
const outgoingItemId = party.itemId; // whatever was just playing - see the markPlayed loop below
|
||||
// Whatever was just playing is "done" - before the party's own state
|
||||
// moves on. See completeCurrentItem's own comment.
|
||||
this.completeCurrentItem(party);
|
||||
party.queueIndex += 1;
|
||||
const next = party.queue[party.queueIndex]!;
|
||||
party.itemId = next.itemId;
|
||||
|
|
@ -207,18 +245,6 @@ 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,
|
||||
|
|
@ -243,6 +269,40 @@ 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());
|
||||
|
|
@ -256,6 +316,18 @@ class PartyServiceClass {
|
|||
MusicQueueService.destroy(party); // same leak this class's own destroy path guards against
|
||||
TranscodeService.endSession(party.id); // same leak - see the "leave" destroy path above
|
||||
PartyStreamService.endSession(party.id);
|
||||
if (party.staticLink) StaticPartyService.onPartyDestroyed(party.staticLink, party.id);
|
||||
// Same reasoning, for any member whose own "leave" (a clean
|
||||
// disconnect) never fired for whatever reason - a real gap would
|
||||
// otherwise leave a stale entry sitting in Emby's own dashboard
|
||||
// indefinitely, not just until the next sweep tick the way a
|
||||
// still-connected member's stale Progress report would.
|
||||
for (const member of party.members.values()) {
|
||||
if (member.embyToken && member.embySession) {
|
||||
const s = member.embySession;
|
||||
EmbySessionService.stop(member.embyToken, s.itemId, s.mediaSourceId, s.playSessionId, member.positionTicks);
|
||||
}
|
||||
}
|
||||
const idleMinutes = Math.round((Date.now() - party.lastActivityUtc) / 60000);
|
||||
Logger.party(`destroyed partyId=${party.id} (idle ${idleMinutes}min, ${party.members.size} member(s) still connected)`);
|
||||
}
|
||||
|
|
@ -311,7 +383,8 @@ class PartyServiceClass {
|
|||
embyToken: null,
|
||||
isPaused: true,
|
||||
positionTicks: 0,
|
||||
lastUpdatedUtc: Date.now()
|
||||
lastUpdatedUtc: Date.now(),
|
||||
embySession: null
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
319
server/src/services/StaticPartyService.ts
Normal file
319
server/src/services/StaticPartyService.ts
Normal file
|
|
@ -0,0 +1,319 @@
|
|||
'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();
|
||||
272
server/src/services/StaticPartyStore.ts
Normal file
272
server/src/services/StaticPartyStore.ts
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
'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();
|
||||
|
|
@ -29,8 +29,18 @@ export interface ClientToServerEvents {
|
|||
join: (msg: { partyId: string; deviceId: string; force?: boolean }) => void;
|
||||
playstate: (msg: { isPaused: boolean; positionTicks: number }) => void;
|
||||
next: () => void;
|
||||
/** Host-only (checked server-side exactly like "next") - the last
|
||||
* queued item played to its end, so there's no "next" to send. Lets
|
||||
* the server count that final item as completed (PartyService
|
||||
* .completeCurrentItem) without advancing anything. */
|
||||
itemEnded: () => void;
|
||||
voteSkip: () => void;
|
||||
musicPlaystate: (msg: { isPaused: boolean }) => void;
|
||||
/** No host check, same as "musicPlaystate" - see MusicQueueService's own
|
||||
* class comment on why a seek gets the same permissive treatment as
|
||||
* pause/resume (reversible, doesn't change what's playing) rather than
|
||||
* "voteSkip"'s majority-vote gate (which does). */
|
||||
musicSeek: (msg: { positionSeconds: number }) => void;
|
||||
requestPause: () => void;
|
||||
requestResume: () => void;
|
||||
/** Host-only (checked server-side exactly like "next") - picks which
|
||||
|
|
@ -152,11 +162,19 @@ 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;
|
||||
|
|
@ -169,6 +187,11 @@ 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,
|
||||
|
|
@ -178,6 +201,16 @@ 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;
|
||||
|
|
@ -219,6 +252,25 @@ export interface PlaybackOptionsDto {
|
|||
subtitleTracks: PlaybackTrackOption[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Emby's own native intro/credits detection (a library's own
|
||||
* EnableMarkerDetection option, run by its "Detect Episode Intros"
|
||||
* scheduled task - confirmed directly against a real server's
|
||||
* /openapi.json: MarkerType is one of Chapter/IntroStart/IntroEnd/
|
||||
* CreditsStart, mixed into the same Chapters array as the auto-generated
|
||||
* per-5-minutes ones). Every field is nullable, independently - the task
|
||||
* may not have reached this item yet, the library may not have the
|
||||
* feature enabled at all (it's per-library, not global), or a real
|
||||
* cold-open episode may genuinely have no IntroStart/IntroEnd. None of
|
||||
* that is an error - see CatalogService.getPlaybackMarkers - a caller
|
||||
* just checks each field for null before offering the matching skip
|
||||
* button. */
|
||||
export interface PlaybackMarkersDto {
|
||||
introStartTicks: number | null;
|
||||
introEndTicks: number | null;
|
||||
creditsStartTicks: number | null;
|
||||
}
|
||||
|
||||
// --- Party (video) -----------------------------------------------------------
|
||||
|
||||
/** One entry in a video party's fixed, pre-built queue (a movie/standalone
|
||||
|
|
@ -255,6 +307,22 @@ export interface PartyMember {
|
|||
lastUpdatedUtc: number;
|
||||
lastPauseRequestUtc?: number;
|
||||
lastResumeRequestUtc?: number;
|
||||
/** This member's own live Emby Sessions-API entry, if one's currently
|
||||
* open - see EmbySessionService. Null whenever they don't have one
|
||||
* (not connected/no token yet, or the periodic sweep hasn't started
|
||||
* one for their current item yet). `itemId` is what the sweep compares
|
||||
* against on each tick to notice an item change (stop the old
|
||||
* session, start a fresh one) - `mediaSourceId`/`playSessionId` are
|
||||
* cached here specifically so a Progress report can reuse them
|
||||
* without renegotiating PlaybackInfo on every single tick. */
|
||||
embySession: EmbyPlaybackSession | null;
|
||||
}
|
||||
|
||||
/** See PartyMember.embySession's own comment. */
|
||||
export interface EmbyPlaybackSession {
|
||||
itemId: string;
|
||||
mediaSourceId: string;
|
||||
playSessionId: string;
|
||||
}
|
||||
|
||||
/** The full internal party record - never sent to the client as-is, see
|
||||
|
|
@ -303,6 +371,25 @@ 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 {
|
||||
|
|
@ -359,6 +446,92 @@ 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 {
|
||||
|
|
|
|||
|
|
@ -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, splitPlaylistByMediaType } from '../src/services/CatalogService';
|
||||
import { CatalogService, imageUrlFor, seasonQueue, seriesQueueFrom, splitPlaylistByMediaType } from '../src/services/CatalogService';
|
||||
import { EmbyClient } from '../src/emby/EmbyClient';
|
||||
import type { EmbyItem, EmbySession } from '../src/types';
|
||||
|
||||
|
|
@ -97,6 +97,18 @@ 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: [{
|
||||
|
|
@ -117,6 +129,42 @@ describe('CatalogService', () => {
|
|||
assert.equal(options.subtitleTracks.find((t) => t.index === 4)?.isTextBased, true, 'ass is text too');
|
||||
});
|
||||
|
||||
it('getPlaybackMarkers picks out Intro/Credits markers by MarkerType, ignoring the auto-generated Chapter ones', async (t) => {
|
||||
t.mock.method(EmbyClient, 'fetch', async () => jsonResponse({
|
||||
Chapters: [
|
||||
{ StartPositionTicks: 0, Name: 'Chapitre 1', MarkerType: 'Chapter' },
|
||||
{ StartPositionTicks: 50000000, MarkerType: 'IntroStart' },
|
||||
{ StartPositionTicks: 900000000, MarkerType: 'IntroEnd' },
|
||||
{ StartPositionTicks: 3000000000, Name: 'Chapitre 2', MarkerType: 'Chapter' },
|
||||
{ StartPositionTicks: 20000000000, MarkerType: 'CreditsStart' }
|
||||
]
|
||||
}));
|
||||
|
||||
const markers = await CatalogService.getPlaybackMarkers(session, 'item1');
|
||||
|
||||
assert.equal(markers.introStartTicks, 50000000);
|
||||
assert.equal(markers.introEndTicks, 900000000);
|
||||
assert.equal(markers.creditsStartTicks, 20000000000);
|
||||
});
|
||||
|
||||
it('getPlaybackMarkers returns nulls, not an error, when the item has no detected markers at all', async (t) => {
|
||||
t.mock.method(EmbyClient, 'fetch', async () => jsonResponse({
|
||||
Chapters: [{ StartPositionTicks: 0, Name: 'Chapitre 1', MarkerType: 'Chapter' }]
|
||||
}));
|
||||
|
||||
const markers = await CatalogService.getPlaybackMarkers(session, 'item1');
|
||||
|
||||
assert.deepEqual(markers, { introStartTicks: null, introEndTicks: null, creditsStartTicks: null });
|
||||
});
|
||||
|
||||
it('getPlaybackMarkers returns nulls when the item has no Chapters field at all', async (t) => {
|
||||
t.mock.method(EmbyClient, 'fetch', async () => jsonResponse({}));
|
||||
|
||||
const markers = await CatalogService.getPlaybackMarkers(session, 'item1');
|
||||
|
||||
assert.deepEqual(markers, { introStartTicks: null, introEndTicks: null, creditsStartTicks: null });
|
||||
});
|
||||
|
||||
it('proxySubtitle asks Emby for this track as WebVTT, using the negotiated MediaSourceId', async (t) => {
|
||||
t.mock.method(EmbyClient, 'fetch', async () => jsonResponse({ MediaSources: [{ Id: 'src7' }] }));
|
||||
let capturedTarget = '';
|
||||
|
|
@ -169,6 +217,35 @@ describe('CatalogService', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('negotiatePlaybackSession returns the MediaSourceId/PlaySessionId a Sessions-API report needs', async (t) => {
|
||||
t.mock.method(EmbyClient, 'fetch', async () => jsonResponse({
|
||||
MediaSources: [{ Id: 'src1' }],
|
||||
PlaySessionId: 'play1'
|
||||
}));
|
||||
|
||||
const result = await CatalogService.negotiatePlaybackSession(session, 'item1');
|
||||
|
||||
assert.deepEqual(result, { mediaSourceId: 'src1', playSessionId: 'play1' });
|
||||
});
|
||||
|
||||
it('negotiatePlaybackSession returns null, not a throw, when Emby has no media source at all', async (t) => {
|
||||
t.mock.method(EmbyClient, 'fetch', async () => jsonResponse({ MediaSources: [], PlaySessionId: 'play1' }));
|
||||
|
||||
assert.equal(await CatalogService.negotiatePlaybackSession(session, 'item1'), null);
|
||||
});
|
||||
|
||||
it('negotiatePlaybackSession returns null when Emby omits PlaySessionId, even with a real source', async (t) => {
|
||||
t.mock.method(EmbyClient, 'fetch', async () => jsonResponse({ MediaSources: [{ Id: 'src1' }] }));
|
||||
|
||||
assert.equal(await CatalogService.negotiatePlaybackSession(session, 'item1'), null);
|
||||
});
|
||||
|
||||
it('negotiatePlaybackSession returns null, not a throw, when the negotiation itself fails', async (t) => {
|
||||
t.mock.method(EmbyClient, 'fetch', async () => new Response(null, { status: 500 }) as unknown as Response);
|
||||
|
||||
assert.equal(await CatalogService.negotiatePlaybackSession(session, 'item1'), null);
|
||||
});
|
||||
|
||||
it('getPlaylistItemsRaw asks Emby\'s own /Playlists endpoint, in order', async (t) => {
|
||||
let capturedPath = '';
|
||||
t.mock.method(EmbyClient, 'fetch', async (path: string) => {
|
||||
|
|
@ -237,3 +314,34 @@ 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']);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
91
server/test/embySessionService.test.ts
Normal file
91
server/test/embySessionService.test.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
// Exercises EmbySessionService directly against a mocked EmbyClient - no
|
||||
// real Emby server involved. Covers the request shape each of the three
|
||||
// Sessions-API calls sends and that a failure (a non-ok response, or a
|
||||
// thrown error) is swallowed rather than propagated - see the class's
|
||||
// own comment for why none of this may ever be allowed to disrupt real
|
||||
// playback over an admin-dashboard nicety.
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { EmbySessionService } from '../src/services/EmbySessionService';
|
||||
import { EmbyClient } from '../src/emby/EmbyClient';
|
||||
|
||||
function okResponse(): Response {
|
||||
return new Response(null, { status: 204 }) as unknown as Response;
|
||||
}
|
||||
|
||||
describe('EmbySessionService', () => {
|
||||
it('start() POSTs to /Sessions/Playing with the negotiated ids and the given position/pause state', async (t) => {
|
||||
let capturedPath = '';
|
||||
let capturedToken = '';
|
||||
let capturedBody: unknown = null;
|
||||
t.mock.method(EmbyClient, 'fetch', async (path: string, token: string, options: { body?: unknown }) => {
|
||||
capturedPath = path;
|
||||
capturedToken = token;
|
||||
capturedBody = options.body;
|
||||
return okResponse();
|
||||
});
|
||||
|
||||
await EmbySessionService.start('tok1', 'item1', 'src1', 'play1', 12_345, false);
|
||||
|
||||
assert.equal(capturedPath, '/Sessions/Playing');
|
||||
assert.equal(capturedToken, 'tok1');
|
||||
assert.deepEqual(capturedBody, {
|
||||
ItemId: 'item1',
|
||||
MediaSourceId: 'src1',
|
||||
PlaySessionId: 'play1',
|
||||
PositionTicks: 12_345,
|
||||
IsPaused: false,
|
||||
PlayMethod: 'Transcode',
|
||||
CanSeek: true
|
||||
});
|
||||
});
|
||||
|
||||
it('progress() POSTs to /Sessions/Playing/Progress', async (t) => {
|
||||
let capturedPath = '';
|
||||
t.mock.method(EmbyClient, 'fetch', async (path: string) => {
|
||||
capturedPath = path;
|
||||
return okResponse();
|
||||
});
|
||||
|
||||
await EmbySessionService.progress('tok1', 'item1', 'src1', 'play1', 999, true);
|
||||
|
||||
assert.equal(capturedPath, '/Sessions/Playing/Progress');
|
||||
});
|
||||
|
||||
it('stop() POSTs to /Sessions/Playing/Stopped', async (t) => {
|
||||
let capturedPath = '';
|
||||
let capturedBody: unknown = null;
|
||||
t.mock.method(EmbyClient, 'fetch', async (path: string, _token: string, options: { body?: unknown }) => {
|
||||
capturedPath = path;
|
||||
capturedBody = options.body;
|
||||
return okResponse();
|
||||
});
|
||||
|
||||
await EmbySessionService.stop('tok1', 'item1', 'src1', 'play1', 42);
|
||||
|
||||
assert.equal(capturedPath, '/Sessions/Playing/Stopped');
|
||||
assert.deepEqual(capturedBody, { ItemId: 'item1', MediaSourceId: 'src1', PlaySessionId: 'play1', PositionTicks: 42 });
|
||||
});
|
||||
|
||||
it('rounds a fractional PositionTicks rather than sending a non-integer', async (t) => {
|
||||
let capturedBody: { PositionTicks?: number } = {};
|
||||
t.mock.method(EmbyClient, 'fetch', async (_path: string, _token: string, options: { body?: unknown }) => {
|
||||
capturedBody = options.body as { PositionTicks?: number };
|
||||
return okResponse();
|
||||
});
|
||||
|
||||
await EmbySessionService.progress('tok1', 'item1', 'src1', 'play1', 12_345.6, false);
|
||||
|
||||
assert.equal(capturedBody.PositionTicks, 12_346);
|
||||
});
|
||||
|
||||
it('a non-ok response is logged, not thrown', async (t) => {
|
||||
t.mock.method(EmbyClient, 'fetch', async () => new Response(null, { status: 401 }) as unknown as Response);
|
||||
await assert.doesNotReject(() => EmbySessionService.start('tok1', 'item1', 'src1', 'play1', 0, false));
|
||||
});
|
||||
|
||||
it('a thrown network error is logged, not propagated', async (t) => {
|
||||
t.mock.method(EmbyClient, 'fetch', async () => { throw new Error('network down'); });
|
||||
await assert.doesNotReject(() => EmbySessionService.stop('tok1', 'item1', 'src1', 'play1', 0));
|
||||
});
|
||||
});
|
||||
|
|
@ -49,7 +49,8 @@ function fakeMember(userId: string, userName: string, embyToken: string): PartyM
|
|||
embyToken,
|
||||
isPaused: false,
|
||||
positionTicks: 0,
|
||||
lastUpdatedUtc: Date.now()
|
||||
lastUpdatedUtc: Date.now(),
|
||||
embySession: null
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -76,6 +77,60 @@ describe('MusicQueueService', () => {
|
|||
assert.equal(music(party).queue.length, 1);
|
||||
});
|
||||
|
||||
it('addTracks queues a whole batch (an album, a discography) in the given order, behind whatever is already playing', () => {
|
||||
const party = fakeParty('p2b');
|
||||
MusicQueueService.init(party, { itemId: 't1', itemName: 'Track One', itemImageUrl: '/img/t1', durationTicks: 10_000_000 }, 'u-alice', 'Alice');
|
||||
|
||||
MusicQueueService.addTracks(party, [
|
||||
{ itemId: 't2', itemName: 'Track Two', itemImageUrl: '/img/t2', durationTicks: 20_000_000 },
|
||||
{ itemId: 't3', itemName: 'Track Three', itemImageUrl: '/img/t3', durationTicks: 30_000_000 }
|
||||
], 'u-bob', 'Bob');
|
||||
|
||||
assert.equal(music(party).current?.track.itemId, 't1', 'still the track already playing');
|
||||
assert.deepEqual(music(party).queue.map((t) => t.itemId), ['t2', 't3']);
|
||||
});
|
||||
|
||||
it('addTracks auto-starts the first track (and only the first) when the queue was empty', () => {
|
||||
const party = fakeParty('p2c');
|
||||
MusicQueueService.init(party, null, 'u-alice', 'Alice'); // empty queue - nothing playing yet
|
||||
|
||||
MusicQueueService.addTracks(party, [
|
||||
{ itemId: 't1', itemName: 'Track One', itemImageUrl: '/img/t1', durationTicks: 10_000_000 },
|
||||
{ itemId: 't2', itemName: 'Track Two', itemImageUrl: '/img/t2', durationTicks: 20_000_000 }
|
||||
], 'u-alice', 'Alice');
|
||||
|
||||
assert.equal(music(party).current?.track.itemId, 't1', 'the batch\'s own first track starts playing');
|
||||
assert.deepEqual(music(party).queue.map((t) => t.itemId), ['t2']);
|
||||
});
|
||||
|
||||
it('addTracks broadcasts once for the whole batch, not once per track', () => {
|
||||
const party = fakeParty('p2d');
|
||||
MusicQueueService.init(party, { itemId: 't1', itemName: 'Track One', itemImageUrl: '/img/t1', durationTicks: 10_000_000 }, 'u-alice', 'Alice');
|
||||
let broadcasts = 0;
|
||||
// Same "attach a fresh fake io" pattern socketGateway.test.ts's own
|
||||
// attach() helper uses - MusicQueueService is a singleton, but every
|
||||
// test that cares about broadcasts re-attaches its own fake at the
|
||||
// start rather than resetting afterward, and every test that doesn't
|
||||
// care never looks at this.io's side effects at all.
|
||||
MusicQueueService.attachIo({ to: () => ({ emit: () => { broadcasts++; } }) } as unknown as Parameters<typeof MusicQueueService.attachIo>[0]);
|
||||
|
||||
MusicQueueService.addTracks(party, [
|
||||
{ itemId: 't2', itemName: 'Track Two', itemImageUrl: '/img/t2', durationTicks: 20_000_000 },
|
||||
{ itemId: 't3', itemName: 'Track Three', itemImageUrl: '/img/t3', durationTicks: 30_000_000 },
|
||||
{ itemId: 't4', itemName: 'Track Four', itemImageUrl: '/img/t4', durationTicks: 40_000_000 }
|
||||
], 'u-bob', 'Bob');
|
||||
|
||||
assert.equal(broadcasts, 1);
|
||||
});
|
||||
|
||||
it('addTracks is a harmless no-op for an empty batch', () => {
|
||||
const party = fakeParty('p2e');
|
||||
MusicQueueService.init(party, { itemId: 't1', itemName: 'Track One', itemImageUrl: '/img/t1', durationTicks: 10_000_000 }, 'u-alice', 'Alice');
|
||||
|
||||
assert.doesNotThrow(() => MusicQueueService.addTracks(party, [], 'u-alice', 'Alice'));
|
||||
assert.equal(music(party).queue.length, 0);
|
||||
});
|
||||
|
||||
it('voteSkip needs a strict majority of connected members, not any count', () => {
|
||||
const party = fakeParty('p3');
|
||||
MusicQueueService.init(party, { itemId: 't1', itemName: 'Track One', itemImageUrl: '/img/t1', durationTicks: 10_000_000 }, 'u-alice', 'Alice');
|
||||
|
|
@ -143,6 +198,67 @@ describe('MusicQueueService', () => {
|
|||
MusicQueueService.destroy(party); // avoid leaking this test's own long-duration timer
|
||||
});
|
||||
|
||||
it('seek while paused jumps the frozen elapsed time directly, without needing a resume', () => {
|
||||
const party = fakeParty('p4b');
|
||||
MusicQueueService.init(party, { itemId: 't1', itemName: 'Track One', itemImageUrl: '/img/t1', durationTicks: 600_000_000 /* 60s */ }, 'u-alice', 'Alice');
|
||||
MusicQueueService.setPaused(party, true);
|
||||
|
||||
MusicQueueService.seek(party, 30);
|
||||
|
||||
assert.equal(music(party).current?.isPaused, true, 'seeking must not itself resume playback');
|
||||
assert.equal(MusicQueueService.getElapsedSeconds(party), 30);
|
||||
});
|
||||
|
||||
it('seek clamps into [0, duration] rather than trusting an out-of-range value verbatim', () => {
|
||||
const party = fakeParty('p4c');
|
||||
MusicQueueService.init(party, { itemId: 't1', itemName: 'Track One', itemImageUrl: '/img/t1', durationTicks: 100_000_000 /* 10s */ }, 'u-alice', 'Alice');
|
||||
MusicQueueService.setPaused(party, true);
|
||||
|
||||
MusicQueueService.seek(party, 999);
|
||||
assert.equal(MusicQueueService.getElapsedSeconds(party), 10, 'clamped to the track\'s own duration');
|
||||
|
||||
MusicQueueService.seek(party, -5);
|
||||
assert.equal(MusicQueueService.getElapsedSeconds(party), 0, 'clamped to zero, not left negative');
|
||||
});
|
||||
|
||||
it('seek while playing re-arms the auto-advance timer against the new position, not the old one', async () => {
|
||||
const party = fakeParty('p4d');
|
||||
// Exactly a 2s track (20_000_000 ticks / 10_000_000 per second - no
|
||||
// rounding to worry about) - seeking to 1.7s leaves ~300ms remaining.
|
||||
// If the timer were left un-re-armed (still counting down from the
|
||||
// original full 2s mark instead), it would still be playing at the
|
||||
// 600ms mark below - comfortably short of the original ~2000ms, so
|
||||
// this genuinely distinguishes the two.
|
||||
MusicQueueService.init(party, { itemId: 't1', itemName: 'Track One', itemImageUrl: '/img/t1', durationTicks: 20_000_000 }, 'u-alice', 'Alice');
|
||||
|
||||
MusicQueueService.seek(party, 1.7);
|
||||
assert.equal(music(party).current?.track.itemId, 't1', 'still the same track right after seeking');
|
||||
|
||||
await sleep(600); // past the re-armed ~300ms, comfortably short of the original ~2000ms
|
||||
assert.equal(music(party).current, null, 'nothing queued behind it - should have auto-advanced off the re-armed timer');
|
||||
});
|
||||
|
||||
it('seek while playing clears the old timer before scheduling a new one - never double-scheduled', (t) => {
|
||||
const realClearTimeout = global.clearTimeout;
|
||||
const cleared = mock.fn(realClearTimeout);
|
||||
global.clearTimeout = cleared as typeof clearTimeout;
|
||||
t.after(() => { global.clearTimeout = realClearTimeout; });
|
||||
|
||||
const party = fakeParty('p4e');
|
||||
MusicQueueService.init(party, { itemId: 't1', itemName: 'Track One', itemImageUrl: '/img/t1', durationTicks: 600_000_000 /* 60s - stays pending */ }, 'u-alice', 'Alice');
|
||||
|
||||
MusicQueueService.seek(party, 10);
|
||||
|
||||
assert.equal(cleared.mock.callCount(), 1, 'the pre-seek timer must be cleared, not left running alongside the new one');
|
||||
MusicQueueService.destroy(party); // avoid leaking this test's own long-duration timer
|
||||
});
|
||||
|
||||
it('seek is a harmless no-op when nothing is currently playing', () => {
|
||||
const party = fakeParty('p4f');
|
||||
MusicQueueService.init(party, null, 'u-alice', 'Alice');
|
||||
assert.doesNotThrow(() => MusicQueueService.seek(party, 30));
|
||||
});
|
||||
|
||||
it('a short track auto-advances on its own via a real timer, with no client reporting "ended"', async () => {
|
||||
const party = fakeParty('p5');
|
||||
MusicQueueService.init(party, { itemId: 't1', itemName: 'Track One', itemImageUrl: '/img/t1', durationTicks: 10_000_000 /* 1s */ }, 'u-alice', 'Alice');
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { PartyService } from '../src/services/PartyService';
|
|||
import { MusicQueueService } from '../src/services/MusicQueueService';
|
||||
import { PlaystateService } from '../src/services/PlaystateService';
|
||||
import { CastTokenService } from '../src/services/CastTokenService';
|
||||
import { EmbySessionService } from '../src/services/EmbySessionService';
|
||||
import type { AppSocket } from '../src/types';
|
||||
|
||||
describe('PartyService', () => {
|
||||
|
|
@ -127,6 +128,68 @@ describe('PartyService', () => {
|
|||
assert.deepEqual(calls, [[party.id, 'u-bob']]);
|
||||
});
|
||||
|
||||
it('leave() stops whatever Emby Sessions-API entry the leaving member had open', (t) => {
|
||||
const calls: unknown[][] = [];
|
||||
t.mock.method(EmbySessionService, 'stop', async (...args: unknown[]) => { calls.push(args); });
|
||||
|
||||
const party = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video');
|
||||
const bob = PartyService.join(party, 'u-bob', 'Bob');
|
||||
bob.embyToken = 'tok-bob';
|
||||
bob.positionTicks = 555;
|
||||
bob.embySession = { itemId: 'm1', mediaSourceId: 'src1', playSessionId: 'play1' };
|
||||
|
||||
PartyService.leave(party, 'u-bob');
|
||||
|
||||
assert.deepEqual(calls, [['tok-bob', 'm1', 'src1', 'play1', 555]]);
|
||||
});
|
||||
|
||||
it('leave() does not call EmbySessionService.stop for a member with no open session', (t) => {
|
||||
let called = false;
|
||||
t.mock.method(EmbySessionService, 'stop', async () => { called = true; });
|
||||
|
||||
const party = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video');
|
||||
PartyService.join(party, 'u-bob', 'Bob'); // embyToken/embySession both still null - see newMember
|
||||
|
||||
PartyService.leave(party, 'u-bob');
|
||||
|
||||
assert.equal(called, false);
|
||||
});
|
||||
|
||||
it('isStaleDevice(): false when the deviceId matches the member\'s own', () => {
|
||||
const party = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video');
|
||||
const alice = party.members.get('u-alice')!;
|
||||
alice.deviceId = 'device-a';
|
||||
|
||||
assert.equal(PartyService.isStaleDevice(alice, 'device-a'), false);
|
||||
});
|
||||
|
||||
it('isStaleDevice(): true when the deviceId differs from the member\'s own - the actual takeover case', () => {
|
||||
const party = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video');
|
||||
const alice = party.members.get('u-alice')!;
|
||||
alice.deviceId = 'device-a'; // device B's takeover already moved this forward
|
||||
|
||||
assert.equal(PartyService.isStaleDevice(alice, 'device-b'), true);
|
||||
});
|
||||
|
||||
it('isStaleDevice(): false when the member has no recorded deviceId yet - nothing to conflict with, same reasoning as the socket "join" handler', () => {
|
||||
const party = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video');
|
||||
const alice = party.members.get('u-alice')!; // deviceId still null - no socket "join" has succeeded yet
|
||||
|
||||
assert.equal(PartyService.isStaleDevice(alice, 'device-a'), false);
|
||||
});
|
||||
|
||||
it('isStaleDevice(): false when the caller sent no deviceId at all - only ever blocks on positive evidence of a mismatch', () => {
|
||||
const party = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video');
|
||||
const alice = party.members.get('u-alice')!;
|
||||
alice.deviceId = 'device-a';
|
||||
|
||||
assert.equal(PartyService.isStaleDevice(alice, null), false);
|
||||
});
|
||||
|
||||
it('isStaleDevice(): false for an unknown member (already left some other way)', () => {
|
||||
assert.equal(PartyService.isStaleDevice(undefined, 'device-a'), false);
|
||||
});
|
||||
|
||||
it('join() is idempotent: re-joining an existing member returns it as-is instead of resetting it', () => {
|
||||
const party = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video');
|
||||
const bob = PartyService.join(party, 'u-bob', 'Bob');
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ function attach(io: ReturnType<typeof fakeIo>) {
|
|||
}
|
||||
|
||||
describe('SocketGateway', () => {
|
||||
it('voteSkip/musicPlaystate are no-ops on a video party', () => {
|
||||
it('voteSkip/musicPlaystate/musicSeek are no-ops on a video party', () => {
|
||||
const io = fakeIo();
|
||||
attach(io);
|
||||
const party = PartyService.create('u-host', 'Host', 'm1', 'Un Film', '/img/m1', null, 'video');
|
||||
|
|
@ -67,7 +67,8 @@ describe('SocketGateway', () => {
|
|||
const before = io.roomEmits.length;
|
||||
socket.trigger('voteSkip');
|
||||
socket.trigger('musicPlaystate', { isPaused: true });
|
||||
assert.equal(io.roomEmits.length, before, 'neither event should produce any broadcast on a video party');
|
||||
socket.trigger('musicSeek', { positionSeconds: 30 });
|
||||
assert.equal(io.roomEmits.length, before, 'none of these events should produce any broadcast on a video party');
|
||||
});
|
||||
|
||||
it('a non-host member can pause and resume a music party directly (no host check)', () => {
|
||||
|
|
@ -88,6 +89,23 @@ describe('SocketGateway', () => {
|
|||
assert.equal(party.music?.current?.isPaused, false);
|
||||
});
|
||||
|
||||
it('a non-host member can seek a music party directly (no host check)', () => {
|
||||
const io = fakeIo();
|
||||
attach(io);
|
||||
const party = PartyService.create('u-alice', 'Alice', 't1', 'Track One', '/img/t1', null, 'music');
|
||||
MusicQueueService.init(party, { itemId: 't1', itemName: 'Track One', itemImageUrl: '/img/t1', durationTicks: 600_000_000 }, 'u-alice', 'Alice');
|
||||
MusicQueueService.setPaused(party, true); // frozen elapsed time makes the resulting position deterministic below
|
||||
PartyService.join(party, 'u-bob', 'Bob');
|
||||
|
||||
const bobSocket = fakeSocket('u-bob');
|
||||
io.trigger('connection', bobSocket as unknown as AppSocket);
|
||||
bobSocket.trigger('join', { partyId: party.id });
|
||||
|
||||
assert.notEqual(party.hostUserId, 'u-bob');
|
||||
bobSocket.trigger('musicSeek', { positionSeconds: 30 });
|
||||
assert.equal(MusicQueueService.getElapsedSeconds(party), 30);
|
||||
});
|
||||
|
||||
it('voteSkip needs a majority of connected members and broadcasts queueUpdated on the party room', () => {
|
||||
const io = fakeIo();
|
||||
attach(io);
|
||||
|
|
|
|||
299
server/test/staticPartyService.test.ts
Normal file
299
server/test/staticPartyService.test.ts
Normal file
|
|
@ -0,0 +1,299 @@
|
|||
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);
|
||||
});
|
||||
});
|
||||
166
spec/static-watch-party.md
Normal file
166
spec/static-watch-party.md
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
# 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.
|
||||
Loading…
Reference in a new issue