Compare commits

...

65 commits

Author SHA1 Message Date
5ceaa2e050 Merge pull request 'Launch a whole series or a single season, as a watch or lounge party' (#30) from feat/launch-whole-series into master
All checks were successful
Publier l'image Docker / test (push) Successful in 23s
Publier l'image Docker / publish (push) Successful in 12s
2026-09-24 23:00:50 +02:00
1a10e5bf57 Launch a whole series or a single season, as a watch or lounge party
All checks were successful
Publier l'image Docker / test (pull_request) Successful in 24s
Publier l'image Docker / publish (pull_request) Has been skipped
- lounge parties can now be created from one season (source type
  "season", keeping seriesId/seasonNumber) as well as a whole series
- POST /api/parties accepts a Series id (every season from S1E1) and a
  Season id (that season only); series tiles get a launch button
- whole-series queues skip specials (season 0, sorted first by Emby) -
  pure seriesQueueFrom/seasonQueue in CatalogService, unit-tested, also
  used by the "from this episode" path
- series page: "Lancer toute la série" + "Créer une lounge party" in the
  header, "Lancer cette saison" + "Lounge party de cette saison" above
  the selected season's episodes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 20:22:57 +02:00
363cf44ebe Merge pull request 'Fix CI: flaky mtime test on master + PR checkout always failing' (#29) from fix/static-store-test-mtime-precision into master
All checks were successful
Publier l'image Docker / test (push) Successful in 24s
Publier l'image Docker / publish (push) Successful in 13s
Reviewed-on: #29
2026-09-24 19:42:48 +02:00
56f969ba78 Fix CI source checkout on pull requests
All checks were successful
Publier l'image Docker / test (pull_request) Successful in 24s
Publier l'image Docker / publish (pull_request) Has been skipped
On a pull_request event github.ref_name is the PR number (e.g. "29"),
not a fetchable ref, so the test job failed at "Récupérer les sources"
on every PR ("couldn't find remote ref 29"). Fetch the PR's head branch
(github.head_ref) instead, falling back to ref_name for pushes/tags.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 19:37:15 +02:00
b0d2100210 Fix flaky mtime assertion in the static party store test on Linux
Some checks failed
Publier l'image Docker / test (pull_request) Failing after 3s
Publier l'image Docker / publish (pull_request) Has been skipped
The "only rewrites the party that changed" test compared a file's
mtimeMs against the exact millisecond passed to utimesSync. Linux
reports mtimeMs as a float that can land a hair off it (...780.999 for
...781), failing CI on master. Compare against the mtime the filesystem
reports right after setting it instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 19:36:21 +02:00
db3cf68843 Merge pull request 'Lounge parties: own tab, one file per party, watch/listen/lounge naming' (#28) from feat/static-parties-tab-and-per-file-storage into master
Some checks failed
Publier l'image Docker / test (push) Failing after 20s
Publier l'image Docker / publish (push) Has been skipped
2026-09-24 19:31:54 +02:00
58d51f5f74 Name the three party kinds: watch, listen and lounge party
Some checks failed
Publier l'image Docker / test (pull_request) Failing after 4s
Publier l'image Docker / publish (pull_request) Has been skipped
- UI wording: "Watch party" = classic video, "Listen party" = classic
  audio, "Lounge party" = static party (tab, page, create buttons,
  toasts/confirms, server error messages); code names are unchanged
- home: "Parties en cours" with a Watch party / Listen party badge on
  each card
- music launch buttons say "Lancer une listen party"; a failed tile
  launch now restores its own label instead of always "watch party"

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 19:22:15 +02:00
1e437e4a91 Give static watch parties their own tab and one file per party
Some checks failed
Publier l'image Docker / test (pull_request) Failing after 4s
Publier l'image Docker / publish (pull_request) Has been skipped
- new "Parties permanentes" tab (/static, sidebar + mobile sheet),
  highlighted on a party's own page too; the home page no longer lists
  static parties, and its live "Watch parties en cours" list leaves
  their sessions out
- StaticPartyStore now writes data/static-parties/<id>.json - one file
  per party, debounced per party, so a write only touches the party that
  changed and a corrupt file only costs that party (moved aside to
  .corrupt, the rest still load); leftover .tmp files are cleaned up;
  deleting a party removes its file and cancels any pending save
- the first release's single static-parties.json is migrated once on
  load, then kept as .migrated

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 19:16:55 +02:00
2de375ac62 Merge pull request 'Add static (persistent) watch parties' (#27) from feat/static-watch-party into master
All checks were successful
Publier l'image Docker / test (push) Successful in 24s
Publier l'image Docker / publish (push) Successful in 17s
2026-09-24 19:07:38 +02:00
5182def5c9 Add static (persistent) watch parties
Some checks failed
Publier l'image Docker / test (pull_request) Failing after 4s
Publier l'image Docker / publish (pull_request) Has been skipped
A watch party built from a whole series or video playlist that remembers
the group's progress and each member's, so someone coming back or joining
late sees the items they missed and can catch up (solo, as a catch-up
watch party, or by marking items watched).

- StaticPartyService + StaticPartyStore: first on-disk state (JSON file
  under DATA_DIR, atomic debounced writes, flushed on SIGTERM)
- each session is an ordinary Party linked via party.staticLink; group
  position is saved by the 20s sweep and resumed on the next session
- PartyService.completeCurrentItem centralizes "item done" (Emby Played +
  static progress); new host-only "itemEnded" socket event covers the
  last queued item, which was never marked Played before
- missed = group-completed minus watched in-app minus Emby Played
- /static/:id view, create buttons on series/playlist pages, home section
- Docker volume for /data, spec/static-watch-party.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 18:54:47 +02:00
04a9116f68 Merge pull request 'Fix host stream freeze when a guest joins an in-progress party' (#26) from fix/guest-join-freezes-host-stream into master
All checks were successful
Publier l'image Docker / test (push) Successful in 24s
Publier l'image Docker / publish (push) Successful in 8s
2026-09-23 20:34:12 +02:00
7fbf22419d Fix host stream freeze when a guest joins an in-progress party
Some checks failed
Publier l'image Docker / test (pull_request) Failing after 4s
Publier l'image Docker / publish (pull_request) Has been skipped
A joining member's player always called loadVideo(itemId, 0, false),
hardcoding a resume position of 0. Since a party's Emby stream is one
shared transcode session (PartyStreamService, keyed by partyId alone),
a newcomer's hls.js requesting segments from position 0 against a
session that already progressed well past that forces Emby to
seek/restart the shared transcode - stalling playback for every
member, host included, right as the newcomer joins.

Seed the initial resume position from the host's last-reported
position (already available in party.members) instead, so the first
segment request lands near where the shared session already is.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 18:53:57 +02:00
076cd7a624 Merge pull request 'Fix the music bar mobile now-playing screen not fitting landscape' (#25) from fix/music-bar-landscape-mobile into master
All checks were successful
Publier l'image Docker / test (push) Successful in 24s
Publier l'image Docker / publish (push) Successful in 7s
2026-09-18 15:54:02 +02:00
83592f1ee9 Fix the music bar mobile now-playing screen not fitting landscape
Some checks failed
Publier l'image Docker / test (pull_request) Failing after 4s
Publier l'image Docker / publish (pull_request) Has been skipped
The album art was sized off min(220px, 60vw) - the viewport dimension
a phone in landscape has plenty of - and stayed pinned near that cap
regardless of how little vertical room was actually left once
everything stacked vertically (art, text, transport, side), running
well past a typical landscape height and needing to scroll to reach
the controls.

Added a dedicated media query for max-width 720px and orientation
landscape rather than chasing every fixed value in the portrait rules:
the art now sits beside the title/artist instead of above them
(flex-direction: row), freeing most of the vertical space the
portrait layout spends stacking them, and its size is capped by vh
instead of vw - the dimension landscape actually constrains. A few
margins shrink alongside it in the same block for the same reason.
Deliberately left the transport buttons fixed-size - a touch target
answers to a finger, not the screen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 11:43:36 +02:00
310dcd422e Merge pull request 'Fix a track started directly never falling back to its album's artwork' (#24) from fix/track-image-albumid-fallback into master
All checks were successful
Publier l'image Docker / test (push) Successful in 24s
Publier l'image Docker / publish (push) Successful in 17s
2026-09-18 11:36:34 +02:00
6bec09ae20 Merge remote-tracking branch 'origin/master' into fix/track-image-albumid-fallback
Some checks failed
Publier l'image Docker / test (pull_request) Failing after 4s
Publier l'image Docker / publish (pull_request) Has been skipped
# Conflicts:
#	README.md
#	docs/architecture.md
2026-09-18 11:36:09 +02:00
b31377c1e9 Merge pull request 'Fix device takeover leaving both devices on the session with no working controls' (#23) from fix/device-takeover-stale-connection into master
All checks were successful
Publier l'image Docker / test (push) Successful in 25s
Publier l'image Docker / publish (push) Successful in 13s
2026-09-18 11:33:24 +02:00
5b2b832be7 Fix a track started directly never falling back to its albums artwork
Some checks failed
Publier l'image Docker / test (pull_request) Failing after 4s
Publier l'image Docker / publish (pull_request) Has been skipped
imageUrlFors own Audio -> AlbumId fallback was silently never
triggering for a track played directly (not via its album/artist),
because the raw item that decision runs against - CatalogService
getRawItem - never requested AlbumId from Emby. Its default field set
for a single-item GET omits it entirely, so item.AlbumId read
undefined regardless of whether the tracks actual album had one -
falling through to the tracks own id every time, which essentially
never has a Primary image of its own. Every other path that resolves a
tracks image already asks for AlbumId correctly (getAlbumTracks/
getPlaylistItemsRaw), which is why launching a whole album/artist/
playlist was never affected. Fixed the one gap: getRawItem now
requests Fields=AlbumId too.

Flagged directly as probably not the whole images-are-hard-to-find
story - a real Emby library can also legitimately have no Primary
image at all for a given artist/album, which is not a bug this app can
fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 10:26:33 +02:00
3ae1fce806 Fix device takeover leaving both devices on the session with no working controls
Some checks failed
Publier l'image Docker / test (pull_request) Failing after 4s
Publier l'image Docker / publish (pull_request) Has been skipped
Two gaps around an already-correct takeover mechanism, not the
mechanism itself. First: Player.ts/MusicBar.ts both wired onClose to a
no-op, so the moment a takeover force-disconnected the old device its
video/hls.js instance (or the persistent music bar) just kept running
forever on a dead connection - both devices looked fully active, since
nothing ever told the old one to stop or navigate away. Second: the
obvious fix (call the same leave path Quitter uses) ran into a worse
bug - POST /leave removes a member by userId alone with no staleness
guard, so the old devices own leave call would have ended the NEW
devices membership too. Fixed both: onClose now tears down local
playback and leaves whenever this routes own explicit teardown has not
already run, and the leave route (plus the client, which now sends its
own deviceId) gained the same staleness guard the socket handlers
already had, via a new PartyService.isStaleDevice.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 10:23:22 +02:00
071184e3c6 Merge pull request 'Link video/music playback to the OS's own media controls (Windows SMTC, macOS Now Playing, ...)' (#22) from feature/media-session-integration into master
All checks were successful
Publier l'image Docker / test (push) Successful in 27s
Publier l'image Docker / publish (push) Successful in 8s
2026-09-17 23:43:32 +02:00
6582ad855b Link video/music playback to the OS own media controls (Windows SMTC, macOS Now Playing, ...)
Some checks failed
Publier l'image Docker / test (pull_request) Failing after 4s
Publier l'image Docker / publish (pull_request) Has been skipped
New mediaSession.ts wraps the standard Media Session API - shared by
Player.ts and MusicBar.ts, which can never both be active at once so
there is no risk of them fighting over this one global. Metadata/
position/playback-state hook into each players own existing single
source of truth (updatePlayPauseBtn/updateProgressBar, setPlayIcon/
updatePositionDisplay) instead of adding parallel call sites. Play/
pause/seek are wired once and read isHost/conn fresh on every
OS-triggered call, so they keep working through a live host handoff;
a non-host play/pause routes through the same requestPause/
requestResume the request toolbar already uses, and seek is gated on
isHost the same way the in-page seek bar already is. Next is handled
separately since a registered nexttrack handler is what makes the OS
show that control at all - its presence tracks whatever the in-page
equivalent already tracks (isHost && hasNextQueued for video, a vote
skip for music). Cleared entirely on leaving a party/page, or just to
idle (metadata/state only) when a listen partys queue runs empty but
the party itself is still open.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 23:41:39 +02:00
9cb25f4dc0 Merge pull request 'Add a mobile banner nudging an install of the PWA' (#21) from feature/pwa-install-prompt into master
All checks were successful
Publier l'image Docker / test (push) Successful in 25s
Publier l'image Docker / publish (push) Successful in 7s
2026-09-17 21:22:35 +02:00
d79b49f34d Merge remote-tracking branch 'origin/master' into feature/pwa-install-prompt
Some checks failed
Publier l'image Docker / test (pull_request) Failing after 4s
Publier l'image Docker / publish (pull_request) Has been skipped
# Conflicts:
#	README.md
#	docs/architecture.md
2026-09-17 21:22:16 +02:00
e9e288c93c Merge pull request 'Fix album track rows collapsing to 3 lines on mobile' (#20) from fix/album-track-row-mobile-width into master
All checks were successful
Publier l'image Docker / test (push) Successful in 26s
Publier l'image Docker / publish (push) Successful in 9s
2026-09-16 23:40:26 +02:00
7f0dbc28e3 Add a mobile banner nudging an install of the PWA
Some checks failed
Publier l'image Docker / test (pull_request) Failing after 4s
Publier l'image Docker / publish (pull_request) Has been skipped
The PWA already existed but nothing told a mobile visitor it could be
installed - Chrome own default mini-infobar is easy to miss, and
Safari has no browser-driven prompt at all. New pwaInstall.ts (self-
mounting like toast.ts) captures beforeinstallprompt as early as
possible (a top-level side effect of importing the module from
Shell.ts, live before boot() even starts its async login check, since
that event never replays for a listener that attached too late) and
shows a banner with a real Installer button on Chrome/Edge/Android, or
manual Share-then-home-screen instructions on iOS Safari where no such
event exists at all. Mobile-only, suppressed once already running
standalone, and a close click remembers not to ask again.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 23:29:27 +02:00
c12e34e1e7 Fix album track rows collapsing to 3 lines on mobile
Some checks failed
Publier l'image Docker / test (pull_request) Failing after 4s
Publier l'image Docker / publish (pull_request) Has been skipped
trackList.css forced .trackRow down to 2 columns under 600px and
pinned duration + the icon button both to column 2 - auto-placement
then stacked them into their own rows under the track name instead of
staying on one line. The fixed overhead (index/gaps/padding/an
auto-sized duration and button) comes to roughly 155-165px, and even a
320px-wide phone leaves comfortably more than that for the flexible
name column - .trackName already ellipsizes, so the breakpoint was
solving a problem that did not exist. Removed outright.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 23:18:12 +02:00
021f272a7d Merge pull request 'Fix the music bar progress bar not spanning full width on desktop' (#19) from fix/music-bar-progress-full-width into master
All checks were successful
Publier l'image Docker / test (push) Successful in 24s
Publier l'image Docker / publish (push) Successful in 8s
Reviewed-on: #19
2026-09-16 22:58:14 +02:00
5ceceb262d Fix the music bar progress bar not spanning full width on desktop
Some checks failed
Publier l'image Docker / test (pull_request) Failing after 4s
Publier l'image Docker / publish (pull_request) Has been skipped
trackProgress is musicBarPanel own first child inside a 3-column grid
(track/transport/side), with no grid-column of its own - auto-placement
dropped it into column 1 of row 1 like any other item, squeezing it to
that columns width and wrapping musicBarPanelSide onto a second row
underneath instead of spanning the full bar with everything else in
one row below, as its own comment already said it should. Fixed with
grid-column: 1 / -1, which pushes every sibling after it into row 2
across the full width with no changes needed to any of them.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 22:55:51 +02:00
fa9d87b130 Merge pull request 'Fix library tile buttons rendering above the sticky header while scrolling' (#18) from fix/library-header-zindex into master
All checks were successful
Publier l'image Docker / test (push) Successful in 25s
Publier l'image Docker / publish (push) Successful in 8s
2026-09-16 22:41:28 +02:00
f923fa24e7 Merge remote-tracking branch 'origin/master' into fix/library-header-zindex
Some checks failed
Publier l'image Docker / test (pull_request) Failing after 4s
Publier l'image Docker / publish (pull_request) Has been skipped
# Conflicts:
#	README.md
#	docs/architecture.md
2026-09-16 22:41:13 +02:00
658ad1d01d Merge pull request 'Fix the music bar queue popup: broken layout on desktop, unclosable on mobile' (#17) from fix/music-bar-queue-popup into master
All checks were successful
Publier l'image Docker / test (push) Successful in 26s
Publier l'image Docker / publish (push) Successful in 8s
2026-09-16 22:39:09 +02:00
d23fcc028b Fix library tile buttons rendering above the sticky header while scrolling
Some checks failed
Publier l'image Docker / test (pull_request) Failing after 4s
Publier l'image Docker / publish (pull_request) Has been skipped
.libraryHeader and the alphabet index's .letterHeading copied their
z-index: 2 from tiles.css's own pre-existing sticky .searchBar - which
never showed the problem in practice, since a single slim input has
little room to actually overlap a scrolling tile. A tile's own
.tileOverlay action buttons are also z-index: 2 though, and an equal
z-index ties toward whichever renders later in the DOM - a tile, always
after the header. Wrapping the header into a much taller block gave a
scrolling tile far more room to pass underneath before fully clearing
it, which is what actually surfaced the tie. Raised both to z-index: 3,
matching shell/Shell.css's own mobileTopBar.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 22:37:11 +02:00
fc2c4e551f Fix the music bar queue popup: broken layout on desktop, unclosable on mobile
Some checks failed
Publier l'image Docker / test (pull_request) Failing after 4s
Publier l'image Docker / publish (pull_request) Has been skipped
.musicBarQueue never actually had a position set on desktop despite its
own comment saying it should float above the bar - toggling it visible
just pushed the real controls out of the way instead, growing the
fixed bottom bar upward. Mobile had position: relative with a high
z-index to at least show above the full-screen now-playing panel, but
that left it sized/placed by normal flow, overlapping the very toggle
button used to open it with no other way to close it.

Now: musicBar gets position: relative, desktop musicBarQueue floats
via position: absolute; bottom: 100% without touching the bar's own
layout, and mobile gets a proper position: fixed; inset: 0 overlay.
Also added a dedicated close button plus a click-outside-closes
listener, so closing it never again depends on where the toggle button
happens to render underneath.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 22:32:32 +02:00
292fec9c19 Merge pull request 'Fix the alphabet index bucketing by display name instead of Emby SortName' (#16) from fix/alphabet-index-sort-order into master
All checks were successful
Publier l'image Docker / test (push) Successful in 25s
Publier l'image Docker / publish (push) Successful in 14s
Reviewed-on: #16
2026-09-16 21:44:44 +02:00
6b6a57a5f7 Fix the alphabet index bucketing by display name instead of Emby SortName
Some checks failed
Publier l'image Docker / test (pull_request) Failing after 4s
Publier l'image Docker / publish (pull_request) Has been skipped
Emby orders (and this app already asked it to) by SortName, which
strips leading articles - an item like The Bunker sorts among the Bs
there, but the index was bucketing by the raw display name, throwing a
spurious T heading into the middle of that run. getLibraryItems/
getArtists now also fetch SortName, ItemDto carries it through, and the
index buckets by that instead - the same key Emby actually used to
order the array, not a guess that usually matches it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 21:36:37 +02:00
b34e1ce8ff Merge pull request 'Add an alphabet index to the library page' (#15) from feature/library-alphabet-index into master
All checks were successful
Publier l'image Docker / test (push) Successful in 24s
Publier l'image Docker / publish (push) Successful in 6s
2026-09-16 21:24:32 +02:00
3ba0de341e Merge pull request 'Make the library page header (title + search bar) fully sticky' (#14) from feature/library-sticky-header into master
Some checks failed
Publier l'image Docker / publish (push) Blocked by required conditions
Publier l'image Docker / test (push) Has been cancelled
2026-09-16 21:24:14 +02:00
0bd95b03b9 Add an alphabet index to the library page: sticky letter headings + a right-edge A-Z rail
Groups an already-alphabetical library into per-letter sections (accents
folded, so an item is bucketed under its base letter) with a sticky
heading before each - still one chunked-rendered grid, not a big
synchronous write, so a 1000+-item library stays smooth. The right-edge
rail jumps to a letter on tap or drag (pointer-capture, same shape as
the mobile nav sheet's own drag handle), dims letters with nothing in
them, and tracks the header/music-bar's real height instead of a
guessed offset so it never sits under either.

Stacked on feature/library-sticky-header (Library.css's .libraryHeader,
not yet merged) rather than master, per CLAUDE.md's sequential-PRs rule.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 20:31:03 +02:00
3fda483688 Make the library page header (title + search bar) fully sticky
Some checks failed
Publier l'image Docker / test (pull_request) Failing after 4s
Publier l'image Docker / publish (pull_request) Has been skipped
The search bar was already sticky on its own; the title above it
still scrolled away. Wraps both in one sticky .libraryHeader block,
scoped to views/library only, and stacks it below the mobile shell's
own sticky top bar instead of racing it for the same spot.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 19:20:01 +02:00
0a9b3fb498 Merge pull request 'Replace every blocking alert() with a light toast, fix a mobile grid bug' (#12) from feature/toast-notifications into master
All checks were successful
Publier l'image Docker / test (push) Successful in 24s
Publier l'image Docker / publish (push) Successful in 7s
2026-09-16 18:33:01 +02:00
acb45c8a1a Merge remote-tracking branch 'origin/master' into feature/toast-notifications
Some checks failed
Publier l'image Docker / test (pull_request) Failing after 4s
Publier l'image Docker / publish (pull_request) Has been skipped
# Conflicts:
#	README.md
#	docs/architecture.md
2026-09-16 18:32:22 +02:00
d10a35e541 Merge pull request 'Make the mobile nav sheet swipe-to-close instead of fighting pull-to-refresh' (#13) from fix/mobile-nav-swipe-close into master
All checks were successful
Publier l'image Docker / test (push) Successful in 24s
Publier l'image Docker / publish (push) Successful in 7s
Reviewed-on: #13
2026-09-16 18:29:19 +02:00
afae977b49 Make the mobile nav sheet swipe-to-close instead of fighting pull-to-refresh
Some checks failed
Publier l'image Docker / test (pull_request) Failing after 4s
Publier l'image Docker / publish (pull_request) Has been skipped
Reported directly: the only way to close the bottom sheet was tapping
the scrim, and the instinctive swipe-it-down gesture fell straight
through to the browser's own pull-to-refresh instead - nothing was
telling the browser this app's own code owned that gesture.

touch-action: none on the scrim and the (now real, not just visual)
grabber handle stops the browser's default touch handling from
kicking in there at all; overscroll-behavior: contain on the sheet
itself stops a drag that hits its own scroll boundary (the library
grid can run taller than the sheet's 75vh cap) from chaining to the
page underneath. The grabber's small 36x4px pill now sits inside a
much bigger invisible touch target that actually drags the sheet
(pointerdown/move/up, 1:1 tracking, snap back under 90px or close past
it) - scoped to the grabber alone, not the whole sheet, so the
library grid's own taps/scroll keep working untouched.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 17:58:56 +02:00
ff664f2c2b Replace every blocking alert() with a light toast, fix a mobile grid bug
Some checks failed
Publier l'image Docker / test (pull_request) Failing after 4s
Publier l'image Docker / publish (pull_request) Has been skipped
Every message this app ever showed the client - the host-handoff
notice, every "couldn't start/join a party" error - went through a
blocking alert(), freezing the tab for what's usually just a heads-up.
New toast.ts is a self-mounting singleton (no per-party state, so it
doesn't need Shell.ts wiring like musicBar/MusicBar does) styled after
Player.css's own .requestBanner, with an 'error' variant that's still
just a plain left accent, not a red banner - even a failed click
doesn't need this app to suddenly shout. All ten alert() call sites
across Player/MusicBar/tiles/Home/Series/Playlist/Artist/Album now go
through it.

Also fixes a real reported bug while in the area: the library/artist
grid's minmax(160px, 1fr) floor left no room for a second column once
a real phone's padding was subtracted, unlike everything else mobile
in this app (the sidebar, the music bar) which already got a
narrow-screen treatment. Switched to a fluid clamp(100px, 28vw, 160px)
floor instead of a fixed breakpoint, confirmed at seven real widths via
an iframe (its own independent viewport for vw units, sidestepping
this sandbox's own unreliable viewport-resize emulation).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 17:34:10 +02:00
2772cc70d6 Merge pull request 'Rework the music UI: circles/squares instead of movie posters, a real now-playing screen' (#11) from feature/music-ui into master
All checks were successful
Publier l'image Docker / test (push) Successful in 24s
Publier l'image Docker / publish (push) Successful in 7s
2026-09-16 15:59:34 +02:00
617a3d0d49 Merge remote-tracking branch 'origin/master' into feature/music-ui
Some checks failed
Publier l'image Docker / test (pull_request) Failing after 4s
Publier l'image Docker / publish (pull_request) Has been skipped
# Conflicts:
#	README.md
#	docs/architecture.md
2026-09-16 15:58:43 +02:00
e9b6a2541c Merge pull request 'Replace the mobile sidebar with a bottom sheet, grouped by data shape' (#10) from feature/mobile-nav into master
All checks were successful
Publier l'image Docker / test (push) Successful in 24s
Publier l'image Docker / publish (push) Successful in 7s
2026-09-16 15:55:57 +02:00
2f10a46664 Merge remote-tracking branch 'origin/master' into feature/mobile-nav
Some checks failed
Publier l'image Docker / test (pull_request) Failing after 4s
Publier l'image Docker / publish (pull_request) Has been skipped
# Conflicts:
#	README.md
#	docs/architecture.md
2026-09-16 15:55:14 +02:00
b5a9a0bc7d Merge pull request 'Make the app installable as a PWA' (#9) from feature/pwa into master
All checks were successful
Publier l'image Docker / test (push) Successful in 24s
Publier l'image Docker / publish (push) Successful in 7s
2026-09-16 15:52:12 +02:00
c8655fc7c3 Merge remote-tracking branch 'origin/master' into feature/pwa
Some checks failed
Publier l'image Docker / test (pull_request) Failing after 3s
Publier l'image Docker / publish (pull_request) Has been skipped
# Conflicts:
#	README.md
2026-09-16 15:51:34 +02:00
e65ba271fd Merge pull request 'Add picture-in-picture to the video player' (#8) from feature/picture-in-picture into master
All checks were successful
Publier l'image Docker / test (push) Successful in 23s
Publier l'image Docker / publish (push) Successful in 7s
2026-09-16 15:50:05 +02:00
098f447e57 Rework the music UI: circles/squares instead of movie posters, a real now-playing screen
Some checks failed
Publier l'image Docker / test (pull_request) Failing after 4s
Publier l'image Docker / publish (pull_request) Has been skipped
The music library reused the movie-poster tile (2:3, hover-revealed
actions) for artists and albums too, since it was already there for
video - functional, but never reads as "this is music" the way real
apps do. Explored as mockups first, then implemented without touching
any of MusicBar's existing sync/cast/HLS logic:

- tiles.ts's tileHtml() now dispatches on Emby type: artists get a
  circle with no hover overlay, albums a square - same "whole tile is
  the one link" idea, since there's only ever one action either way.
- Artist/album pages gain a real hero header (circular/square art, a
  pill-shaped play button) instead of a plain title + text button.
  Album's per-track buttons shrink from a wide text button repeated on
  every row to a small icon that swaps play/plus with the same two
  states as before.
- MusicBar gains a real mobile "now playing" full-screen view (tap the
  collapsed mini bar to expand it) instead of only ever being a thin
  strip - the screen every music app has that this one was missing.
  The existing thin top-edge progress bar didn't change shape at all,
  on either breakpoint.

Nothing here invents a feature without a real data source behind it -
no popular tracks, no previous-track, no favorites, none of that
exists server-side.

Caught and fixed one real bug via computed-style verification, not
just reading the CSS back: the new circular play button was silently
losing to .iconBtn's own background/radius at equal specificity,
outcome depending on esbuild's bundle order rather than either rule's
own declared intent.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 15:44:14 +02:00
40a897cf1b Replace the mobile sidebar with a bottom sheet, grouped by data shape
Some checks failed
Publier l'image Docker / test (pull_request) Failing after 4s
Publier l'image Docker / publish (pull_request) Has been skipped
The old mobile treatment just reshaped the sidebar into a horizontally
scrolling top bar - easy to miss that there's more to scroll to, and
"Se deconnecter" ended up buried at the far end of it. Explored as
mockups first (compared against a bottom tab bar, a hamburger drawer,
and a polished version of the existing bar) before writing this.

The sheet mirrors the real data shape instead of a flat list: Accueil
and Listes de lecture (when a playlists-type library exists) are fixed
rows bookending a Bibliotheques group, which is the part that actually
varies with whatever the connected Emby server returns - zero new
server work, since getLibraries() already sends collectionType per
library, just unused on the client until now.

The music bar's stacking under the new nav trigger is now structural
(a shared position:fixed flex column) instead of a hardcoded offset,
since the bar's own height isn't constant - its row can wrap on a
narrow screen with a long track/artist name.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 14:56:46 +02:00
75a9fef18a Make the app installable as a PWA
Some checks failed
Publier l'image Docker / test (pull_request) Failing after 4s
Publier l'image Docker / publish (pull_request) Has been skipped
Manifest + hand-authored icons (any + maskable) + iOS meta tags, so it
can be added to a phone's home screen and opened without browser
chrome. No service worker - explored as a possible route to automatic
Picture-in-Picture on backgrounding, but confirmed (by actually calling
requestPictureInPicture() from a real visibilitychange listener) that
install state doesn't change that API's fresh-gesture requirement, so
this is a standalone feature rather than a fix for that limitation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 14:07:28 +02:00
dfd109fc50 Merge remote-tracking branch 'origin/master' into feature/picture-in-picture
Some checks failed
Publier l'image Docker / test (pull_request) Failing after 4s
Publier l'image Docker / publish (pull_request) Has been skipped
# Conflicts:
#	README.md
#	docs/architecture.md
2026-09-16 10:16:16 +02:00
d6a65ce8b7 Add picture-in-picture to the video player
Some checks failed
Publier l'image Docker / test (pull_request) Failing after 4s
Publier l'image Docker / publish (pull_request) Has been skipped
Asked for directly, mobile in particular: a phone has no second window
to put this app in the way a desktop might, so the standard browser
Picture-in-Picture API - popping the video out into a small, OS-managed
floating window - is the one way to keep watching while switching to
another app. Standard requestPictureInPicture()/exitPictureInPicture(),
not anything WebKit-prefixed - confirmed directly in a real Chromium
build that pictureInPictureEnabled/requestPictureInPicture/
exitPictureInPicture are all present, which is what actually matters
for Android Chrome, the mobile browser this was asked for.

New #pipBtn, right next to #fullscreenBtn - both are "how is this
displayed" toggles, so they share the same .fsBtn sizing, and a new
.fsBtn[aria-pressed="true"] rule gives it the same "clearly on" orange
treatment #castBtn already has (its own rule rather than combined onto
.iconBtn, which disagrees with .fsBtn on padding/border-radius/
background). Feature-detected once at mount, not live the way
#castBtn's own availability listener is - browser support for this
doesn't appear/disappear mid-session the way a Chromecast on the
network does.

State (aria-pressed) syncs via the video's own enterpictureinpicture/
leavepictureinpicture events rather than the button's click handler
alone, so it also reflects the browser's native PiP window being
closed directly. No cleanup needed on navigation: browsers already
close an active PiP window once its source video leaves the document,
and every route change here already destroys and replaces <video> -
the deliberate opposite of this app's own choice for casting, which
keeps a Chromecast session alive after navigating away since it has an
existence independent of the local video element.

Verified: full typecheck/build (client only - no server involved).
Confirmed directly in a real browser, not just type-checked:
pictureInPictureEnabled is true, and running the exact show/hide +
click-handling logic against a real (sourceless) video correctly shows
the button and catches the resulting InvalidStateError from
requestPictureInPicture() without it becoming an uncaught exception.
Not verified: an actual floating PiP window on a real mobile browser
playing a real Emby stream - no device/login reachable from this
sandbox.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 10:12:59 +02:00
b922e4236f Merge pull request 'Queue a whole album or an artist's entire discography in one action' (#7) from feature/queue-album-and-discography into master
All checks were successful
Publier l'image Docker / test (push) Successful in 24s
Publier l'image Docker / publish (push) Successful in 12s
Reviewed-on: #7
2026-09-16 07:54:13 +02:00
62afa88bb7 Queue a whole album or an artist's entire discography in one action
Some checks failed
Publier l'image Docker / test (pull_request) Failing after 4s
Publier l'image Docker / publish (pull_request) Has been skipped
Asked for directly: views/album's and views/artist's per-track/
per-album buttons had no whole-album or whole-discography equivalent -
queuing an album meant clicking every track one at a time, and an
artist page had no listen-party action at all beyond drilling into a
specific album.

Resolved server-side, not via a client-side loop - the same choice
already made for playlists, and for the same reason: an artist's
entire discography can genuinely be dozens of albums and hundreds of
tracks, and a client loop of individual add-track calls would mean
that many sequential round trips (slow, and non-atomic) instead of
one. New resolveMusicTracksForItem (index.ts) takes a raw Emby item
already known to be Audio/MusicAlbum/MusicArtist and resolves it to a
flat, ordered track list - a single track, every track in an album (in
Emby's own IndexNumber order), or every album's tracks for an artist's
whole discography (fetched in parallel, in Emby's own chronological
album order). POST /api/parties gained a MusicAlbum/MusicArtist branch
right alongside the existing Playlist one, using this plus a new
shared createMusicPartyFromTracks - which also let
createPartyFromPlaylist's own music branch drop its separate per-track
loop in favor of the same two calls.

MusicQueueService.addTracks is the batch sibling of the existing
addTrack (both now share a buildTrackEntry helper) - same
auto-starts-if-the-queue-was-empty behavior, but one queueUpdated
broadcast for the whole batch rather than one per track, so a
hundred-track discography doesn't spam the party with a hundred
intermediate broadcasts for what's really one user action.

Adding to an already-live party's queue needed its own new route, POST
/api/parties/:partyId/queue - given just an itemId, resolves it the
same way and calls addTracks. Kept separate from the existing
POST .../tracks, which takes an already-built track object a caller
already has in hand (views/album's own per-track buttons) - overloading
that route to guess whether it received a full track or a bare id
would have been more surprising than a second, clearly-named one.

The buttons themselves mirror views/playlist's own "Lancer cette
playlist" - same label-reflects-current-state-and-re-checks-on-click
discipline views/album's per-track buttons already had, same
attach-the-bar-and-stay-put behavior when starting fresh. views/album
gained one page-level button (reusing .playlistActions from
Playlist.css, same "one CSS bundle regardless of which view's file a
rule started in" precedent Album.css already set for .seriesInfo).
views/artist gained its first listen-party action ever, sitting in
.pageHeader itself (its own justify-content: space-between already
puts a second child on the right of the title with no new CSS needed).

Verified: full typecheck/test/build on both projects (147/147 server
tests - new coverage for addTracks: batch order preserved, auto-start
only fires for the batch's own first track when the queue was empty,
exactly one broadcast regardless of batch size, harmless no-op for an
empty batch). resolveMusicTracksForItem/createMusicPartyFromTracks
themselves are index.ts route glue, not independently unit-tested -
consistent with this project's existing boundary that route-level
orchestration isn't, only the services it calls into are. The two new
buttons' layout was checked visually against the real compiled CSS (a
static render, real theme tokens), not eyeballed. Not verified: an
actual large discography queuing correctly end to end against a real
server - no Emby login reachable from this sandbox.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 07:46:38 +02:00
b41437e23e Merge pull request 'Sticky catalog search bar, and let anyone seek within a music track' (#5) from feature/sticky-search-and-music-seek into master
All checks were successful
Publier l'image Docker / test (push) Successful in 20s
Publier l'image Docker / publish (push) Successful in 12s
2026-09-16 07:21:15 +02:00
7bf8197bfe Merge remote-tracking branch 'origin/master' into feature/sticky-search-and-music-seek
Some checks failed
Publier l'image Docker / test (pull_request) Failing after 4s
Publier l'image Docker / publish (pull_request) Has been skipped
# Conflicts:
#	README.md
#	docs/architecture.md
2026-09-16 07:20:57 +02:00
f680f72b1b Merge pull request 'Add skip intro / next episode at credits, via Emby's own detection' (#4) from feature/intro-credits-skip into master
All checks were successful
Publier l'image Docker / test (push) Successful in 19s
Publier l'image Docker / publish (push) Successful in 12s
2026-09-16 07:19:00 +02:00
bc878c8d1c Merge remote-tracking branch 'origin/master' into feature/intro-credits-skip
Some checks failed
Publier l'image Docker / test (pull_request) Failing after 4s
Publier l'image Docker / publish (pull_request) Has been skipped
# Conflicts:
#	README.md
#	docs/architecture.md
2026-09-16 07:18:43 +02:00
ff2b9e46f2 Merge pull request 'Report watch-party streams to Emby's own Sessions API (admin dashboard)' (#6) from feature/emby-sessions-dashboard into master
All checks were successful
Publier l'image Docker / test (push) Successful in 21s
Publier l'image Docker / publish (push) Successful in 17s
Reviewed-on: #6
2026-09-16 07:14:11 +02:00
fafee359b9 Sticky catalog search bar, and let anyone seek within a music track
Some checks failed
Publier l'image Docker / test (pull_request) Failing after 4s
Publier l'image Docker / publish (pull_request) Has been skipped
Two small, unrelated requests handled together.

The search bar (tiles.ts's shared .searchBar, used by views/library/
artist/album) scrolled away with the page - asked to stay reachable
while scrolling a long grid. position: sticky; top: 0 on the input
itself; base.css's own generic input rule already gives it an opaque
background, which is what keeps tiles scrolling underneath from
showing through once it's stuck - nothing else needed changing.

Seeking within the current music track was explicit, deliberate scope
this session's own "participative playlist" work had cut - the
progress bar was marked read-only specifically because MusicQueueService
had no seek concept, just play/pause/advance. The permission model was
already decided by that class's own documented reasoning for why
*anyone* can pause/resume (reversible, doesn't change what's playing) -
a seek is exactly that same shape, continuous instead of binary, so it
gets the same treatment, not voteSkip's majority gate.

MusicQueueService.seek mirrors setPaused's own elapsed-time math
(clamped into [0, durationSeconds]), with one real difference: setPaused's
resume branch gets away with calling scheduleAdvance directly only
because pausing already cleared the timer first - scheduleAdvance
itself never clears a prior one. Seeking while still playing has no
such guarantee, so skipping that clear first would leave the OLD timer
(counting down against the old position) running alongside the new
one, eventually firing playNext at the wrong moment. New musicSeek
socket event, no host check (same as musicPlaystate). Client-side,
MusicBar's progress track gained the same pointerdown/move/up
drag-to-seek handlers the video player's seek bar already has - no host
branch to port this time, since a listen party has none - plus a
local, optimistic activeDriver.currentTime write before broadcasting,
for the same instant-feedback reason the video player's own
seekFromPointerEvent does that.

Verified: full typecheck/test/build on both projects (128/128 server
tests - new coverage for seek's clamping, its paused-vs-playing
branches, and a real-timer test proving the old advance timer is
cleared rather than left running alongside a re-armed one - the exact
bug the naive "just call scheduleAdvance again" version would have
shipped). No server needed for the sticky search bar - checked by
reading .mainContent's own layout directly (no nested scroll container
between it and the document, confirming position: sticky resolves
against the page scroll as intended).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 23:27:23 +02:00
57bb297bff Add skip intro / next episode at credits, via Emby's own detection
Some checks failed
Publier l'image Docker / test (pull_request) Failing after 4s
Publier l'image Docker / publish (pull_request) Has been skipped
Asked for directly. Checked the real server before building anything:
no plugin provides this (Cinema Intros is a different feature - pre-show
trailers, not per-episode skip points), and Emby's own Chapters field
turned out to be auto-generated placeholders every 5 minutes, not real
markers.

What the server does have natively, confirmed from its own
/openapi.json rather than assumed: a per-library EnableMarkerDetection
option backed by a built-in "Detect Episode Intros" scheduled task -
audio-fingerprint detection, the same idea as Jellyfin's separate Intro
Skipper plugin, already part of Emby itself. Enabling it (done directly
on the server for the two TV libraries) adds real entries to the same
Chapters array, distinguished by MarkerType: IntroStart/IntroEnd/
CreditsStart.

This made the feature almost entirely "read what Emby already
computed" - no fingerprinting, no new persistence, no background jobs
of this app's own. CatalogService.getPlaybackMarkers fetches
Fields=Chapters and picks out just those three marker types (nulls
when detection hasn't reached this item or isn't enabled for its
library - never an error), behind a new
GET /api/catalog/item/:id/markers route mirroring getPlaybackOptions.

The player gets one new floating button, #skipIntroBtn - an explicit
label, not an icon, matching this app's own convention for every
request-shaped control. It's a sibling of #bottomBar, not inside it,
so it stays reachable through the bar's own auto-hide/.minimal collapse.
Clicking it seeks to introEndTicks through the exact same
activeDriver/onDriverStateChanged path any other seek uses, so it's
automatically host-only and automatically cast-aware with no new
special-casing for either. End credits got no button of their own -
#nextBtn already does exactly "go to the next queued item" and was
already shown whenever hasNextQueued is true; the only gap closed here
is visibility, by un-minimizing the bar once (a one-shot flag, not a
fight against someone deliberately re-minimizing it) the moment
playback crosses creditsStartTicks.

Verified: full typecheck/test/build on both projects (125/125 server
tests, including new coverage for getPlaybackMarkers - real MarkerType
values mixed with auto-generated Chapter ones, and both "no Chapters"
and "no markers among the Chapters" resolving to nulls rather than
throwing). The button's on-screen position was checked directly
(getBoundingClientRect against the real compiled CSS) at two viewport
sizes - clear of #bottomBar with a 10px gap, not overlapping. Not
verified: the detection task had only just been enabled on the real
server and hadn't finished analyzing any episode yet, so the actual
end-to-end "a detected marker makes the button appear at the right
second" behavior needs a real check once it's had time to run.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 23:16:38 +02:00
81 changed files with 6939 additions and 301 deletions

View file

@ -55,13 +55,16 @@ jobs:
# node:20-alpine n'a pas git préinstallé - même approche de clone # 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 # manuel que le job publish (pas d'actions/checkout, action JS qui a
# ses propres soucis dans les conteneurs Alpine minimalistes ici). # 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 - name: Récupérer les sources
run: | run: |
set -eu set -eu
apk add --no-cache git apk add --no-cache git
git init -q git init -q
git remote add origin "${{ github.server_url }}/${{ github.repository }}.git" 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 git checkout -q FETCH_HEAD
- name: Serveur - installer les dépendances - name: Serveur - installer les dépendances

3
.gitignore vendored
View file

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

View file

@ -159,7 +159,7 @@ server/ Node.js + Express + Socket.IO backend: proxies ALL Emby
tsc to server/dist. tsc to server/dist.
src/index.ts Express app + route wiring only - thin, src/index.ts Express app + route wiring only - thin,
delegates to the services below 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 src/types.ts shared interfaces (Party, ItemDto, the
Socket.IO event maps, ...) Socket.IO event maps, ...)
src/Logger.ts party/stream lifecycle logging src/Logger.ts party/stream lifecycle logging
@ -245,9 +245,14 @@ build stage, so nothing needs building beforehand:
```bash ```bash
docker build -f server/Dockerfile -t watchparty . 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 Then put it behind your existing reverse proxy like everything else
(`theater.media.kyuno.fr`, `git.dev.kyuno.fr`, ...), e.g. (`theater.media.kyuno.fr`, `git.dev.kyuno.fr`, ...), e.g.
`watchparty.kyuno.fr` → `http://<container>:3000`. `watchparty.kyuno.fr` → `http://<container>:3000`.
@ -328,11 +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 as its own always-on-top bar, duplicating the cast icon and
overlapping the new bottom bar in fullscreen) in favor of fully overlapping the new bottom bar in fullscreen) in favor of fully
custom play/pause, a seek bar, and mute — see docs/architecture.md 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 - [x] Report each party member's own playback to Emby's real Sessions
API (`/Sessions/Playing`, `/Progress`, `/Stopped`), so the admin API (`/Sessions/Playing`, `/Progress`, `/Stopped`), so the admin
dashboard sees watch-party streams the same way it sees a native dashboard sees watch-party streams the same way it sees a native
client's — one session per member, under their own account — see client's — one session per member, under their own account — see
docs/architecture.md 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 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 - [ ] Try casting to a real Chromecast (only the SDK's own bootstrap is
confirmed here — no real device/session) confirmed here — no real device/session)

View file

@ -11,7 +11,7 @@
// npm run watch -> rebuilds on save, for local dev (see README's // npm run watch -> rebuilds on save, for local dev (see README's
// Running locally section) // Running locally section)
import * as esbuild from 'esbuild'; import * as esbuild from 'esbuild';
import { copyFileSync, mkdirSync } from 'fs'; import { copyFileSync, cpSync, mkdirSync } from 'fs';
import path from 'path'; import path from 'path';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
@ -20,6 +20,11 @@ const outdir = path.join(here, 'dist');
mkdirSync(outdir, { recursive: true }); mkdirSync(outdir, { recursive: true });
copyFileSync(path.join(here, 'src', 'index.html'), path.join(outdir, 'index.html')); 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 = { const buildOptions = {
entryPoints: [path.join(here, 'src', 'app.ts')], entryPoints: [path.join(here, 'src', 'app.ts')],

81
client/src/alphaIndex.css Normal file
View 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
View 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);
};
}

View file

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

View file

@ -39,8 +39,14 @@ export function joinParty(partyId: string): Promise<PartyPublicState> {
return apiFetch(`/api/parties/${encodeURIComponent(partyId)}/join`, { method: 'POST' }); 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 }> { 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> { 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) }); 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 * 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 * 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; sendPlaystate(isPaused: boolean, positionTicks: number): void;
/** Host-only: advance the party's queue (e.g. to the next episode). */ /** Host-only: advance the party's queue (e.g. to the next episode). */
next(): void; 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 - /** Asks the host (and tells everyone else) "please pause". Video only -
* a music party has no host to ask, see voteSkip/setMusicPaused. */ * a music party has no host to ask, see voteSkip/setMusicPaused. */
requestPause(): void; requestPause(): void;
@ -140,6 +164,10 @@ export interface PartyConnection {
voteSkip(): void; voteSkip(): void;
/** Music only: no host check server-side, unlike sendPlaystate above. */ /** Music only: no host check server-side, unlike sendPlaystate above. */
setMusicPaused(isPaused: boolean): void; 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 /** Re-sends "join" with force:true after the user confirms they want to
* disconnect their other device and take over here - call this from * disconnect their other device and take over here - call this from
* onDeviceConflict's handler once the user says yes. */ * onDeviceConflict's handler once the user says yes. */
@ -205,6 +233,9 @@ export function connectParty(partyId: string, callbacks: PartyConnectionCallback
next() { next() {
socket.emit('next'); socket.emit('next');
}, },
itemEnded() {
socket.emit('itemEnded');
},
requestPause() { requestPause() {
socket.emit('requestPause'); socket.emit('requestPause');
}, },
@ -217,6 +248,9 @@ export function connectParty(partyId: string, callbacks: PartyConnectionCallback
setMusicPaused(isPaused) { setMusicPaused(isPaused) {
socket.emit('musicPlaystate', { isPaused }); socket.emit('musicPlaystate', { isPaused });
}, },
seekMusic(positionSeconds) {
socket.emit('musicSeek', { positionSeconds });
},
confirmDeviceTakeover() { confirmDeviceTakeover() {
socket.emit('join', { partyId, deviceId, force: true }); socket.emit('join', { partyId, deviceId, force: true });
}, },

View 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 }) });
}

View file

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

View file

@ -4,6 +4,24 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Watch Party</title> <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" /> <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/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> <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
View 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.
}
}

View file

@ -6,13 +6,14 @@
able to browse/add tracks while it plays). Sits above .mainContent, at 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 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 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 { .musicBar {
position: fixed; position: relative; /* containing block for .musicBarQueue's own position: absolute - see its comment */
left: 0;
right: 0;
bottom: 0;
z-index: 15;
background: var(--bg-header); background: var(--bg-header);
border-top: 1px solid rgba(255, 255, 255, .08); border-top: 1px solid rgba(255, 255, 255, .08);
box-shadow: 0 -4px 16px rgba(0, 0, 0, .3); 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 - /* A thin strip along the bar's own top edge, not inside .musicBarRow -
the same "compact player" convention Spotify/YouTube Music use for the same "compact player" convention Spotify/YouTube Music use for
their mini-players. Read-only (see MusicBar.ts's own comment) - no their mini-players. Click/drag to seek (see MusicBar.ts) - the
click/drag handling, just a visual position indicator. */ 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 { .musicBarProgressTrack {
grid-column: 1 / -1;
position: relative;
height: 3px; height: 3px;
background: rgba(255, 255, 255, .12); 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 { .musicBarProgressFill {
height: 100%; height: 100%;
width: 0%; width: 0%;
background: var(--accent); 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; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
padding: 8px 16px; min-width: 0;
flex-wrap: wrap; }
.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 /* 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; } .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 /* #castBtn while actively casting - same "clearly-on" treatment as
views/player/Player.css's own .iconBtn[aria-pressed="true"] (ID-scoped views/player/Player.css's own .iconBtn[aria-pressed="true"] (ID-scoped
here rather than a shared .iconBtn rule since #playPauseBtn's own 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 /* Pops up ABOVE the bar (same pattern as Player.css's .settingsPanel above
its gear button) - collapsed by default, see MusicBar.ts's toggleQueueBtn its gear button - position: absolute against .musicBar's own
handler. Spans the full bar width (no max-width) rather than a fixed position: relative, bottom: 100% instead of that one's top: 100%/
popup box - a party's queue can run long, and a narrow column wastes right: 0, since this floats up from a bottom bar rather than down from
the width a desktop viewport already has going spare. a top-corner icon) - collapsed by default, see MusicBar.ts's
display:flex/flex-direction:column here (rather than on .pageHeader/ toggleQueueBtn handler. `position: absolute` (not left in normal flow)
#queueList individually) is what lets #queueList be the only child is what stops opening this from silently growing .bottomStack's own
that scrolls below - see #queueList's own comment. */ 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 { .musicBarQueue {
position: absolute;
left: 0;
right: 0;
bottom: 100%;
max-height: 50vh; max-height: 50vh;
margin: 0 16px; margin: 0 16px;
padding: 14px 14px 0; padding: 14px 14px 0;
@ -112,6 +292,23 @@
.musicBarQueue .pageHeader { flex-shrink: 0; } .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" /* The only scrolling region inside .musicBarQueue - "File d'attente"
above it stays put instead of scrolling away with the tracks. */ above it stays put instead of scrolling away with the tracks. */
#queueList { #queueList {
@ -119,9 +316,111 @@
} }
@media (max-width: 720px) { @media (max-width: 720px) {
.musicBarRow { justify-content: center; } .mobileMiniBar { display: flex; }
.musicBarInfo { margin-right: 0; flex-basis: 100%; order: -1; text-align: center; }
.musicBarQueue { margin: 0; border-radius: 0; } /* 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 /* One queue entry - a plain flex row (image, then a shrinking/truncating

View file

@ -1,26 +1,68 @@
<div id="musicBarQueue" class="musicBarQueue hide"> <div id="musicBarQueue" class="musicBarQueue hide">
<div class="pageHeader"> <div class="pageHeader">
<h2>File d'attente</h2> <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>
<div id="queueList" class="trackList"><p class="hint">Rien en attente.</p></div> <div id="queueList" class="trackList"><p class="hint">Rien en attente.</p></div>
</div> </div>
<!-- Read-only - see MusicBar.ts's own comment on why this doesn't seek <!-- Mobile-only collapsed preview (see MusicBar.css's 720px breakpoint) -
(no host to arbitrate a jump, and MusicQueueService has no "seek" #musicBarPanel below is already always visible on desktop, so this
concept server-side today - just play/pause/advance). --> only ever renders there. Tapping anywhere on it except the play
<div id="trackProgress" class="musicBarProgressTrack"><div id="trackProgressFill" class="musicBarProgressFill"></div></div> 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"> <!-- Always in place on desktop (the bar itself, unconditionally visible
<div id="presenceOverlay" class="presenceOverlay musicBarPresence"></div> 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')" /> <img id="trackImage" class="musicBarImage" alt="" onerror="this.classList.add('noImage')" />
<div class="musicBarInfo"> <div class="musicBarInfo">
<strong id="trackName">-</strong> <strong id="trackName">-</strong>
<span class="hint" id="trackArtist"></span> <span class="hint" id="trackArtist"></span>
</div> </div>
<span id="trackPosition" class="hint musicBarPosition">0:00 / 0:00</span> <span id="trackPosition" class="hint musicBarPosition">0:00 / 0:00</span>
<button class="iconBtn" id="playPauseBtn" title="Lecture / pause" aria-label="Lecture / pause">&#9654;</button> </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"> <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"> <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> <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> <circle cx="4" cy="20" r="1.5" fill="currentColor"></circle>
</svg> </svg>
</button> </button>
<button class="linklike" id="voteSkipBtn">Passer (vote)</button> <button class="iconBtn" id="leaveBtn" title="Quitter" aria-label="Quitter">
<button class="linklike" id="toggleQueueBtn">File d'attente</button> <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 class="linklike" id="leaveBtn">Quitter</button> </button>
</div>
</div> </div>
<audio id="barAudio"></audio> <audio id="barAudio"></audio>

View file

@ -6,9 +6,11 @@ import { partyStreamUrl } from '../api/embyApi';
import { connectParty, issueCastToken, leaveParty, revokeCastToken } from '../api/party'; import { connectParty, issueCastToken, leaveParty, revokeCastToken } from '../api/party';
import type { PartyConnection } from '../api/party'; import type { PartyConnection } from '../api/party';
import { colorForName, escapeHtml, formatDuration, initialsFor } from '../tiles'; import { colorForName, escapeHtml, formatDuration, initialsFor } from '../tiles';
import { showToast } from '../toast';
import type { MusicCurrentPublic, MusicPublicState, PartyPublicState, SocketMember } from '../types'; import type { MusicCurrentPublic, MusicPublicState, PartyPublicState, SocketMember } from '../types';
import { initCast, localDriver, onCastAvailabilityChanged, onCastSessionEnded, startCast, stopCast } from '../cast'; import { initCast, localDriver, onCastAvailabilityChanged, onCastSessionEnded, startCast, stopCast } from '../cast';
import type { PlaybackDriver } 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 * Persistent, app-lifetime "listen party" bar - mounted ONCE by Shell.ts
@ -34,6 +36,12 @@ import type { PlaybackDriver } from '../cast';
* for music parties. * 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 // 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) - // well above hls.js's defaults (10s manifest/level/frag load timeouts) -
// every track here also goes through CatalogService.proxyStream into a // 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 audio = barEl.querySelector('#barAudio') as HTMLAudioElement;
const playPauseBtn = barEl.querySelector('#playPauseBtn') as HTMLButtonElement; const playPauseBtn = barEl.querySelector('#playPauseBtn') as HTMLButtonElement;
const miniPlayPauseBtn = barEl.querySelector('#miniPlayPauseBtn') as HTMLButtonElement;
const castBtn = barEl.querySelector('#castBtn') as HTMLButtonElement; const castBtn = barEl.querySelector('#castBtn') as HTMLButtonElement;
const voteSkipBtn = barEl.querySelector('#voteSkipBtn') as HTMLButtonElement; const voteSkipBtn = barEl.querySelector('#voteSkipBtn') as HTMLButtonElement;
const toggleQueueBtn = barEl.querySelector('#toggleQueueBtn')!; const toggleQueueBtn = barEl.querySelector('#toggleQueueBtn')!;
const queuePanel = barEl.querySelector('#musicBarQueue')!; const queuePanel = barEl.querySelector('#musicBarQueue')!;
const queueCloseBtn = barEl.querySelector('#queueCloseBtn')!;
const leaveBtn = barEl.querySelector('#leaveBtn')!; 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); activeDriver = localDriver(audio);
audio.addEventListener('play', () => { if (!suppressLocalEvents && conn) conn.setMusicPaused(false); }); audio.addEventListener('play', () => { if (!suppressLocalEvents && conn) conn.setMusicPaused(false); });
audio.addEventListener('pause', () => { if (!suppressLocalEvents && conn) conn.setMusicPaused(true); }); audio.addEventListener('pause', () => { if (!suppressLocalEvents && conn) conn.setMusicPaused(true); });
playPauseBtn.addEventListener('click', () => conn?.setMusicPaused(!activeDriver!.paused)); playPauseBtn.addEventListener('click', () => conn?.setMusicPaused(!activeDriver!.paused));
miniPlayPauseBtn.addEventListener('click', () => conn?.setMusicPaused(!activeDriver!.paused));
voteSkipBtn.addEventListener('click', () => conn?.voteSkip()); voteSkipBtn.addEventListener('click', () => conn?.voteSkip());
toggleQueueBtn.addEventListener('click', () => queuePanel.classList.toggle('hide')); 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()); 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 // 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 // gated on anything party-related, same as Player.ts's own castBtn
// (see its comment: casting is available to any member, and this bar // (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 // server updates, same "derive it client-side from a snapshot" approach
// as Home's party-elapsed labels and the old MusicPlayer.ts - runs // as Home's party-elapsed labels and the old MusicPlayer.ts - runs
// continuously rather than only while a party is active (cheap: a // continuously rather than only while a party is active (cheap: a
// no-op while !currentItemId). // no-op while !currentItemId). Also called directly right after a local
positionTicker = window.setInterval(() => { // 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; if (!currentItemId) return;
const currentTime = activeDriver!.currentTime; const currentTime = activeDriver!.currentTime;
const position = barEl!.querySelector('#trackPosition')!; const position = barEl!.querySelector('#trackPosition')!;
position.textContent = `${formatDuration(currentTime)} / ${formatDuration(currentDurationSeconds)}`; position.textContent = `${formatDuration(currentTime)} / ${formatDuration(currentDurationSeconds)}`;
const fill = barEl!.querySelector('#trackProgressFill') as HTMLElement; const percent = currentDurationSeconds > 0 ? `${Math.min(100, (currentTime / currentDurationSeconds) * 100)}%` : '0%';
fill.style.width = currentDurationSeconds > 0 ? `${Math.min(100, (currentTime / currentDurationSeconds) * 100)}%` : '0%'; (barEl!.querySelector('#trackProgressFill') as HTMLElement).style.width = percent;
}, 500); // 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(); if (takeOver) conn?.confirmDeviceTakeover();
else leaveMusicParty(); else leaveMusicParty();
}, },
onError: (message) => alert(message), onError: (message) => showToast(message, { variant: 'error' }),
onClose: () => {} // 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; currentItemId = null;
lastMusicState = null; lastMusicState = null;
latestMembers = []; latestMembers = [];
clearMediaSession();
conn?.leave(); conn?.leave();
conn = null; conn = null;
barEl!.classList.add('hide'); barEl!.classList.add('hide');
barEl!.querySelector('#musicBarQueue')!.classList.add('hide'); barEl!.querySelector('#musicBarQueue')!.classList.add('hide');
barEl!.querySelector('#musicBarPanel')!.classList.remove('open');
leaveParty(partyId).catch(() => {}); leaveParty(partyId).catch(() => {});
} }
@ -238,11 +356,37 @@ async function castTrack(title: string): Promise<void> {
* pause listeners already do (see initMusicBar), since casting here has * pause listeners already do (see initMusicBar), since casting here has
* no host gate to check first (see activeDriver's own comment). */ * no host gate to check first (see activeDriver's own comment). */
function onCastStateChanged(): void { function onCastStateChanged(): void {
const playPauseBtn = barEl!.querySelector('#playPauseBtn') as HTMLButtonElement; setPlayIcon(!activeDriver!.paused);
playPauseBtn.textContent = activeDriver!.paused ? '▶' : '⏸';
if (!suppressLocalEvents) conn?.setMusicPaused(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 * Starts casting - available to any listener, not just... there is no
* "just", a listen party has no host (see activeDriver's own comment). * "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. * (isCasting) delegated to castTrack/loadTrackLocal below.
*/ */
function loadTrack(track: MusicCurrentPublic, elapsedSeconds: number, isPaused: boolean): void { 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; currentItemId = track.itemId;
currentDurationSeconds = track.durationSeconds || 0; currentDurationSeconds = track.durationSeconds || 0;
trackImage.src = track.itemImageUrl || ''; setTrackMeta(track.itemImageUrl || '', track.itemName || '', [track.artistName, track.albumName].filter(Boolean).join(' - '));
trackImage.classList.remove('noImage'); // Artist/album kept separate here, unlike setTrackMeta's own combined
trackName.textContent = track.itemName || ''; // display line above - the OS's own now-playing UI has real fields for
trackArtist.textContent = [track.artistName, track.albumName].filter(Boolean).join(' - '); // both, no need to pre-join them into one string for it.
setMediaSessionMetadata(track.itemName || '', track.artistName || '', track.albumName || '', track.itemImageUrl || '');
if (isCasting) { if (isCasting) {
castTrack([track.itemName, track.artistName].filter(Boolean).join(' - ')).catch((err) => { castTrack([track.itemName, track.artistName].filter(Boolean).join(' - ')).catch((err) => {
@ -403,37 +544,43 @@ function loadTrackLocal(track: MusicCurrentPublic, elapsedSeconds: number, isPau
*/ */
function updateVoteSkipLabel(): void { function updateVoteSkipLabel(): void {
const voteSkipBtn = barEl!.querySelector('#voteSkipBtn') as HTMLButtonElement; const voteSkipBtn = barEl!.querySelector('#voteSkipBtn') as HTMLButtonElement;
const voteBadge = barEl!.querySelector('#voteBadge') as HTMLElement;
const current = lastMusicState?.current; const current = lastMusicState?.current;
voteSkipBtn.disabled = !current; voteSkipBtn.disabled = !current;
if (!current) { if (!current) {
voteSkipBtn.textContent = 'Passer (vote)'; voteBadge.classList.add('hide');
return; return;
} }
const connectedCount = latestMembers.filter((m) => m.connected).length || 1; const connectedCount = latestMembers.filter((m) => m.connected).length || 1;
const needed = Math.floor(connectedCount / 2) + 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 { function applyMusicState(music: MusicPublicState): void {
lastMusicState = music; lastMusicState = music;
const playPauseBtn = barEl!.querySelector('#playPauseBtn') as HTMLButtonElement; const playPauseBtn = barEl!.querySelector('#playPauseBtn') as HTMLButtonElement;
const miniPlayPauseBtn = barEl!.querySelector('#miniPlayPauseBtn') as HTMLButtonElement;
const castBtn = barEl!.querySelector('#castBtn') as HTMLButtonElement; const castBtn = barEl!.querySelector('#castBtn') as HTMLButtonElement;
const queueList = barEl!.querySelector('#queueList')!; 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; const audio = barEl!.querySelector('#barAudio') as HTMLAudioElement;
// Queue/vote-button/play-icon updates happen first, unconditionally, // Queue/vote-button/play-icon updates happen first, unconditionally,
// deliberately BEFORE the track-loading branch below (see loadTrack's // deliberately BEFORE the track-loading branch below (see loadTrack's
// own comment on why - same ordering as Player.ts/the old MusicPlayer.ts). // 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; playPauseBtn.disabled = !music.current;
miniPlayPauseBtn.disabled = !music.current;
// Nothing for startCasting to actually cast with an empty queue - same // Nothing for startCasting to actually cast with an empty queue - same
// "disable, don't just silently no-op" treatment as playPauseBtn above. // "disable, don't just silently no-op" treatment as playPauseBtn above.
castBtn.disabled = !music.current && !isCasting; castBtn.disabled = !music.current && !isCasting;
updateVoteSkipLabel(); 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 // Its own .queueRow, NOT views/album's shared .trackRow (see
// styles/trackList.css) - that one is a CSS grid whose 4 columns // styles/trackList.css) - that one is a CSS grid whose 4 columns
@ -456,13 +603,13 @@ function applyMusicState(music: MusicPublicState): void {
if (!music.current) { if (!music.current) {
currentItemId = null; currentItemId = null;
trackName.textContent = 'Rien ne joue pour le moment.'; setTrackMeta('', 'Rien ne joue pour le moment.', '');
trackArtist.textContent = ''; setMediaSessionIdle();
trackImage.src = '';
if (hls) { hls.destroy(); hls = null; } if (hls) { hls.destroy(); hls = null; }
activeDriver!.pause(); activeDriver!.pause();
audio.removeAttribute('src'); // harmless no-op while casting - the local element is already srcless then, see startCasting 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('#trackProgressFill') as HTMLElement).style.width = '0%';
(barEl!.querySelector('#miniProgressFill') as HTMLElement).style.width = '0%';
} else if (music.current.itemId !== currentItemId) { } else if (music.current.itemId !== currentItemId) {
loadTrack(music.current, music.current.elapsedSeconds, music.current.isPaused); loadTrack(music.current, music.current.elapsedSeconds, music.current.isPaused);
} else { } else {

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

View 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
View 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
View 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">&times;</button>`
: `<span class="pwaInstallText">Installe Watch Party sur ton écran d'accueil pour y accéder plus vite.</span>
<button type="button" class="pwaInstallBtn">Installer</button>
<button type="button" class="pwaInstallClose" aria-label="Fermer">&times;</button>`;
bannerEl.querySelector('.pwaInstallClose')!.addEventListener('click', dismiss);
bannerEl.querySelector('.pwaInstallBtn')?.addEventListener('click', () => { install().catch(() => {}); });
document.body.insertBefore(bannerEl, document.body.firstChild);
}
/**
* Called once by Shell.ts after a successful login - covers both the
* case where beforeinstallprompt already fired before Shell existed to
* react to it (deferredPrompt sitting ready, banner never actually
* shown yet) and iOS, which has no event at all to trigger this from.
* Also re-checked on resize: crossing the 720px breakpoint live (an
* unfolding or rotated device) shouldn't need a reload to notice.
*/
export function initPwaInstallPrompt(): void {
renderIfEligible();
window.addEventListener('resize', renderIfEligible);
}

View file

@ -62,20 +62,190 @@
padding: 24px clamp(16px, 3vw, 48px) 80px; 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) { @media (max-width: 720px) {
.appShell { flex-direction: column; } .appShell { flex-direction: column; }
.sidebar { .sidebar { display: none; }
width: 100%; .mobileTopBar { display: flex; align-items: center; }
height: auto; .mobileNavTrigger { display: flex; }
flex-direction: row; .mobileNavScrim { display: block; }
align-items: center; .mobileNavSheet { display: block; }
padding: 10px 12px;
overflow-x: auto;
overflow-y: hidden;
}
.sidebarBrand { padding: 0 12px 0 0; }
.sidebarNav { flex-direction: row; flex: none; }
.navSectionLabel { display: none; }
.sidebarLogout { margin: 0 0 0 12px; }
} }

View file

@ -3,11 +3,49 @@
<div class="sidebarBrand">Watch Party</div> <div class="sidebarBrand">Watch Party</div>
<nav class="sidebarNav" id="sidebarNav"> <nav class="sidebarNav" id="sidebarNav">
<a href="#/home" class="navItem">Accueil</a> <a href="#/home" class="navItem">Accueil</a>
<a href="#/static" class="navItem">Lounge parties</a>
<div class="navSectionLabel">Bibliothèques</div> <div class="navSectionLabel">Bibliothèques</div>
<p class="hint" id="libStatus"><span class="spinner"></span>Chargement...</p> <p class="hint" id="libStatus"><span class="spinner"></span>Chargement...</p>
</nav> </nav>
<button class="linklike sidebarLogout" id="logout"></button> <button class="linklike sidebarLogout logoutBtn" id="logout"></button>
</aside> </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> <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> <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> </div>

View file

@ -5,47 +5,169 @@ import './Shell.css';
import html from './Shell.html'; import html from './Shell.html';
import { auth, getLibraries, logout } from '../api/embyApi'; import { auth, getLibraries, logout } from '../api/embyApi';
import { initMusicBar } from '../musicBar/MusicBar'; 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> { export async function renderShell(appEl: HTMLElement): Promise<HTMLElement> {
appEl.innerHTML = html; 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 // Mounted once, outside #mainContent, so a listen party survives
// navigating the router - see musicBar/MusicBar.ts's header comment. // navigating the router - see musicBar/MusicBar.ts's header comment.
initMusicBar(appEl.querySelector('#musicBar') as HTMLElement); 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(); await logout();
// Simplest reliable way back to a bare login screen: a real reload, // Simplest reliable way back to a bare login screen: a real reload,
// rather than hand-tearing-down every shell/router listener in place. // rather than hand-tearing-down every shell/router listener in place.
location.hash = ''; location.hash = '';
location.reload(); location.reload();
}); });
});
const nav = appEl.querySelector('#sidebarNav')!; const nav = appEl.querySelector('#sidebarNav')!;
const status = appEl.querySelector('#libStatus')!; const status = appEl.querySelector('#libStatus')!;
const mobileLibGrid = appEl.querySelector('#mobileLibGrid')!;
const mobilePlaylistsSlot = appEl.querySelector('#mobilePlaylistsSlot')!;
try { try {
const libraries = await getLibraries(); const libraries = await getLibraries();
status.remove(); status.remove();
libraries.forEach((lib) => { libraries.forEach((lib) => {
const a = document.createElement('a'); nav.appendChild(createLibraryLink(lib, 'navItem', false));
a.href = `#/library/${lib.id}`; // 'playlists' gets pulled out of the "Bibliothèques" group into
a.className = 'navItem'; // its own fixed row - see docs/architecture.md. Everything else
a.textContent = lib.name; // (movies/tvshows/music/musicvideos, whatever mix this server
nav.appendChild(a); // 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 { } catch {
status.textContent = 'Bibliothèques indisponibles.'; status.textContent = 'Bibliothèques indisponibles.';
} }
const highlight = () => { const highlight = () => {
nav.querySelectorAll('.navItem').forEach((a) => { appEl.querySelectorAll('.navItem').forEach((a) => {
const isHome = a.getAttribute('href') === '#/home'; const href = a.getAttribute('href');
a.classList.toggle('active', a.getAttribute('href') === location.hash || (isHome && !location.hash)); 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(); highlight();
window.addEventListener('hashchange', 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; return appEl.querySelector('#mainContent') as HTMLElement;
} }

View file

@ -14,6 +14,7 @@
--accent: #52b54b; /* Emby's theme-primary-color (hsl(116, 41.7%, 50.2%)) */ --accent: #52b54b; /* Emby's theme-primary-color (hsl(116, 41.7%, 50.2%)) */
--accent-bright: #6ccf65; /* Emby's --theme-accent-text-color-darkbg */ --accent-bright: #6ccf65; /* Emby's --theme-accent-text-color-darkbg */
--danger: #ff6b6b; --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; } * { box-sizing: border-box; }

View file

@ -20,9 +20,25 @@
.trackIndex { color: var(--muted); font-size: 13px; text-align: center; } .trackIndex { color: var(--muted); font-size: 13px; text-align: center; }
.trackName { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .trackName { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.trackDuration { color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums; } .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) { /* A compact circle, not the wide text button this used to be (views/album's
.trackRow { grid-template-columns: 24px 1fr; } own "Écouter ensemble"/"Ajouter à la file") - with a whole album's worth
.trackRow .trackDuration, .trackRow button { grid-column: 2; justify-self: start; } 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; }

View file

@ -4,17 +4,34 @@
/* base.css's global `input {}` rule already gives this the right look /* base.css's global `input {}` rule already gives this the right look
(padding/border/background) - this just bounds its width and spaces it (padding/border/background) - this just bounds its width and spaces it
from the grid below, shared by views/library, views/artist, views/album - 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 { .searchBar {
max-width: 320px; max-width: 320px;
margin-bottom: 16px; margin-bottom: 16px;
position: sticky;
top: 0;
z-index: 2;
} }
.searchEmpty { margin-top: 8px; } .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 { .grid {
display: 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; gap: 20px 16px;
} }
@ -127,3 +144,33 @@
} }
.tileBtn { font-size: 11px; padding: 6px 4px; } .tileBtn { font-size: 11px; padding: 6px 4px; }
} }
/* Artist/album tiles (tiles.ts's artistTileHtml/albumTileHtml) - an <a>
directly, not a <div> with a separate absolutely-positioned
.tileLink/.tileOverlay on top of it: there's only ever the one
action (open the artist/album page), so the whole tile just being
the link is simpler than reusing machinery built for a second,
hover-revealed set of actions these two never have. */
.tile.tileArtist,
.tile.tileAlbum {
text-decoration: none;
color: var(--text);
}
.tile.tileArtist img,
.tile.tileAlbum img {
aspect-ratio: 1 / 1;
}
.tile.tileArtist img {
border-radius: 50%;
}
.tile.tileArtist .tileLabel {
text-align: center;
}
.tile.tileArtist:hover .tileLabel,
.tile.tileAlbum:hover .tileLabel {
text-decoration: underline;
}

View file

@ -2,6 +2,7 @@
// grid, and the artist page, so they all stay visually/behaviorally // grid, and the artist page, so they all stay visually/behaviorally
// identical instead of drifting. // identical instead of drifting.
import './tiles.css'; import './tiles.css';
import { showToast } from './toast';
import type { ItemDto } from './types'; import type { ItemDto } from './types';
export function escapeHtml(str: string): string { 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' } 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 drill = item.type ? DRILL_DOWN[item.type] : undefined;
const drillHref = drill ? `#/${drill.route}/${item.id}` : null; 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 ` return `
<div class="tile"> <div class="tile">
${drillHref ? `<a href="${drillHref}" class="tileLink" aria-label="${escapeHtml(item.name)}"></a>` : ''} ${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 * and for any tile added to it later. `onStart(itemId)` does the actual
* work (create the party + navigate) - kept as a caller-supplied callback * 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. * 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'); const btn = (e.target as HTMLElement).closest<HTMLButtonElement>('.startPartyBtn');
if (!btn || btn.disabled) return; if (!btn || btn.disabled) return;
e.preventDefault(); e.preventDefault();
const idleLabel = btn.textContent; // "watch party" or "listen party" - put back as-is on failure
btn.disabled = true; btn.disabled = true;
btn.textContent = 'Démarrage...'; btn.textContent = 'Démarrage...';
try { try {
await onStart(btn.dataset.itemId as string); await onStart(btn.dataset.itemId as string);
} catch (err) { } catch (err) {
alert((err as Error).message); showToast((err as Error).message, { variant: 'error' });
btn.disabled = false; btn.disabled = false;
btn.textContent = 'Lancer une watch party'; btn.textContent = idleLabel;
} }
}); });
} }

46
client/src/toast.css Normal file
View 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
View 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);
}

View file

@ -9,6 +9,10 @@
export interface ItemDto { export interface ItemDto {
id: string; id: string;
name: 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; overview?: string;
year?: number; year?: number;
type?: string; type?: string;
@ -50,6 +54,16 @@ export interface PlaybackOptionsDto {
subtitleTracks: PlaybackTrackOption[]; 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 type MediaType = 'video' | 'music';
export interface PublicMember { export interface PublicMember {
@ -157,6 +171,42 @@ export interface LobbyPartyDto {
positionTicks: number | null; positionTicks: number | null;
isPaused: boolean | null; isPaused: boolean | null;
positionUpdatedUtc: number | 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 { export interface ItemChangedMsg {

View file

@ -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 { .albumHeader {
display: flex; display: flex;
gap: 20px; gap: 20px;

View file

@ -2,10 +2,14 @@
<div class="albumHeader"> <div class="albumHeader">
<img class="albumCover" id="albumCover" alt="" loading="lazy" onerror="this.classList.add('noImage')" /> <img class="albumCover" id="albumCover" alt="" loading="lazy" onerror="this.classList.add('noImage')" />
<div class="seriesInfo"> <div class="seriesInfo">
<span class="heroKicker" id="albumKicker">Album</span>
<h1 id="albumTitle"></h1> <h1 id="albumTitle"></h1>
<p class="hint hide" id="albumArtist"></p> <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> <a class="linklike" id="embyLink" target="_blank" rel="noopener">Voir sur Emby</a>
</div> </div>
</div>
</div> </div>
<input id="searchInput" class="searchBar" type="search" placeholder="Rechercher un morceau..." /> <input id="searchInput" class="searchBar" type="search" placeholder="Rechercher un morceau..." />
<p class="hint searchEmpty hide">Aucun résultat.</p> <p class="hint searchEmpty hide">Aucun résultat.</p>

View file

@ -2,15 +2,23 @@ import './Album.css';
import '../../styles/trackList.css'; import '../../styles/trackList.css';
import html from './Album.html'; import html from './Album.html';
import { getAlbumTracks, getItem } from '../../api/embyApi'; 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 { getActiveMusicPartyId, joinMusicParty } from '../../musicBar/MusicBar';
import { escapeHtml, formatDuration, wireSearchFilter } from '../../tiles'; import { escapeHtml, formatDuration, wireSearchFilter } from '../../tiles';
import { LOADING_HINT } from '../../loading'; import { LOADING_HINT } from '../../loading';
import { showToast } from '../../toast';
import type { ItemDto, MusicTrackInput } from '../../types'; import type { ItemDto, MusicTrackInput } from '../../types';
import type { RouteParams } from '../../router'; 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 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 /** 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 * 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 * 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.innerHTML = html;
root.querySelector('#back')!.addEventListener('click', () => history.back()); root.querySelector('#back')!.addEventListener('click', () => history.back());
(root.querySelector('#albumCover') as HTMLImageElement).src = album.imageUrl; (root.querySelector('#albumCover') as HTMLImageElement).src = album.imageUrl;
root.querySelector('#albumTitle')!.innerHTML = album.year root.querySelector('#albumKicker')!.textContent = album.year ? `Album · ${album.year}` : 'Album';
? `${escapeHtml(album.name)} <span class="seriesYear">(${album.year})</span>` root.querySelector('#albumTitle')!.textContent = album.name;
: escapeHtml(album.name); root.querySelector('#launchBtnIcon')!.innerHTML = PLAY_ICON_SVG;
if (album.artistName) { if (album.artistName) {
const artistEl = root.querySelector('#albumArtist')!; const artistEl = root.querySelector('#albumArtist')!;
artistEl.textContent = album.artistName; 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; (root.querySelector('#embyLink') as HTMLAnchorElement).href = album.embyUrl;
const listEl = root.querySelector('#trackList')!; const listEl = root.querySelector('#trackList')!;
const launchBtn = root.querySelector('#launchBtn') as HTMLButtonElement;
const launchBtnLabel = root.querySelector('#launchBtnLabel')!;
if (!tracks.length) { if (!tracks.length) {
listEl.innerHTML = '<p class="hint">Aucun morceau dans cet album.</p>'; listEl.innerHTML = '<p class="hint">Aucun morceau dans cet album.</p>';
root.querySelector('#searchInput')!.remove(); root.querySelector('#searchInput')!.remove();
launchBtn.disabled = true;
return; return;
} }
// Label reflects whether a listen party is already active *at render // aria-label reflects whether a listen party is already active *at
// time* - a harmless, occasionally-stale label (see refreshLabels() // render time* - a harmless, occasionally-stale label (see
// below for when it's corrected). The actual behavior on click always // refreshLabels() below for when it's corrected). The actual behavior
// re-checks getActiveMusicPartyId() fresh, so clicking never does the // on click always re-checks getActiveMusicPartyId() fresh, so clicking
// wrong thing even if the label itself hasn't caught up yet (e.g. // never does the wrong thing even if the label hasn't caught up yet
// someone hit "Quitter" on the bar while this page was already open). // (e.g. someone hit "Quitter" on the bar while this page was already
const startLabel = 'Écouter ensemble'; // 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 addLabel = 'Ajouter à la file';
const startAlbumLabel = 'Écouter cet album';
const addAlbumLabel = "Ajouter l'album à la file";
listEl.innerHTML = tracks.map((t) => ` listEl.innerHTML = tracks.map((t) => `
<div class="trackRow"> <div class="trackRow">
<span class="trackIndex">${t.indexNumber != null ? t.indexNumber : ''}</span> <span class="trackIndex">${t.indexNumber != null ? t.indexNumber : ''}</span>
<span class="trackName">${escapeHtml(t.name)}</span> <span class="trackName">${escapeHtml(t.name)}</span>
<span class="trackDuration">${t.durationTicks ? formatDuration(t.durationTicks / TICKS_PER_SECOND) : ''}</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> </div>
`).join(''); `).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 // Re-labels every track button (and the whole-album one) from current
// just clicked - starting a party from track 3 means tracks 1, 2 and 4 // state, not just the one just clicked - starting a party from track 3
// can now also be queued instead of starting a party of their own. // means tracks 1, 2 and 4 can now also be queued instead of starting a
// party of their own.
function refreshLabels(): void { function refreshLabels(): void {
const active = getActiveMusicPartyId();
listEl.querySelectorAll<HTMLButtonElement>('.startPartyBtn').forEach((b) => { 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) => { listEl.querySelectorAll<HTMLButtonElement>('.startPartyBtn').forEach((btn, i) => {
const track = tracks[i] as ItemDto; const track = tracks[i] as ItemDto;
@ -93,10 +118,8 @@ export async function renderAlbum(root: HTMLElement, params: RouteParams): Promi
const activePartyId = getActiveMusicPartyId(); const activePartyId = getActiveMusicPartyId();
try { try {
if (activePartyId) { if (activePartyId) {
btn.textContent = 'Ajout...';
await addTrackToQueue(activePartyId, trackInputFor(track)); await addTrackToQueue(activePartyId, trackInputFor(track));
} else { } else {
btn.textContent = 'Démarrage...';
// Attaches the persistent bottom bar directly and stays right // Attaches the persistent bottom bar directly and stays right
// here - no navigate() - so adding a few more tracks from this // here - no navigate() - so adding a few more tracks from this
// same album is just clicking their buttons too. See // 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); const party = await createParty(track.id);
joinMusicParty(party.partyId, party); joinMusicParty(party.partyId, party);
} }
btn.textContent = 'Ajouté !'; btn.innerHTML = CHECK_ICON_SVG;
setTimeout(() => { btn.disabled = false; refreshLabels(); }, 1500); setTimeout(() => { btn.disabled = false; refreshLabels(); }, 1500);
} catch (err) { } catch (err) {
alert((err as Error).message); showToast((err as Error).message, { variant: 'error' });
btn.disabled = false; btn.disabled = false;
refreshLabels(); 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'); wireSearchFilter(root.querySelector('#searchInput') as HTMLInputElement, root, '.trackRow');
} }

View 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; }
}

View file

@ -1,5 +1,15 @@
<button class="linklike" id="back">&larr; Retour</button> <button class="linklike" id="back">&larr; 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..." /> <input id="searchInput" class="searchBar" type="search" placeholder="Rechercher un album..." />
<p class="hint searchEmpty hide">Aucun résultat.</p> <p class="hint searchEmpty hide">Aucun résultat.</p>
<div id="grid" class="grid"></div> <div id="grid" class="grid"></div>

View file

@ -1,16 +1,28 @@
import './Artist.css';
import html from './Artist.html'; import html from './Artist.html';
import { getArtistAlbums, getItem } from '../../api/embyApi'; import { getArtistAlbums, getItem } from '../../api/embyApi';
import { createParty, queueItem } from '../../api/party';
import { getActiveMusicPartyId, joinMusicParty } from '../../musicBar/MusicBar';
import type { RouteParams } from '../../router'; import type { RouteParams } from '../../router';
import { tileHtml, wireSearchFilter } from '../../tiles'; import { tileHtml, wireSearchFilter } from '../../tiles';
import { LOADING_HINT } from '../../loading'; 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 /** Route `/artist/:id` - one artist's albums, reached from a music
* library's top-level grid (see views/library) via a MusicArtist tile's * 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 * "Voir les albums" link (tiles.ts's DRILL_DOWN table). Structurally the
* same "detail header + grid" shape as views/series, just one level up * same "detail header + grid" shape as views/series, just one level up
* the music hierarchy (Artist -> Album, not Series -> Season/Episode) and * the music hierarchy (Artist -> Album, not Series -> Season/Episode).
* with no per-item action beyond drilling further into an album (an * #launchBtn is the one action available at this level itself (every
* artist has nothing itself to start a party from). */ * 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> { export async function renderArtist(root: HTMLElement, params: RouteParams): Promise<void> {
const artistId = params.id as string; const artistId = params.id as string;
root.innerHTML = LOADING_HINT; root.innerHTML = LOADING_HINT;
@ -26,14 +38,58 @@ export async function renderArtist(root: HTMLElement, params: RouteParams): Prom
root.innerHTML = html; root.innerHTML = html;
root.querySelector('h1')!.textContent = artist.name; 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()); root.querySelector('#back')!.addEventListener('click', () => history.back());
const launchBtn = root.querySelector('#launchBtn') as HTMLButtonElement;
const launchBtnLabel = root.querySelector('#launchBtnLabel')!;
const grid = root.querySelector('#grid')!; const grid = root.querySelector('#grid')!;
root.querySelector('#albumCount')!.textContent = `${albums.length} album${albums.length > 1 ? 's' : ''}`;
if (!albums.length) { if (!albums.length) {
grid.innerHTML = '<p class="hint">Aucun album trouvé pour cet artiste.</p>'; grid.innerHTML = '<p class="hint">Aucun album trouvé pour cet artiste.</p>';
root.querySelector('#searchInput')!.remove(); root.querySelector('#searchInput')!.remove();
launchBtn.disabled = true;
return; return;
} }
grid.innerHTML = albums.map(tileHtml).join(''); grid.innerHTML = albums.map(tileHtml).join('');
wireSearchFilter(root.querySelector('#searchInput') as HTMLInputElement, root, '.tile'); wireSearchFilter(root.querySelector('#searchInput') as HTMLInputElement, root, '.tile');
// Label reflects whether a listen party is already active - same
// "harmless, occasionally-stale until the next refresh" reasoning as
// views/album's own equivalent buttons (the click handler below
// always re-checks getActiveMusicPartyId() fresh regardless).
const startLabel = 'Écouter toute la discographie';
const addLabel = 'Ajouter la discographie à la file';
function refreshLabel(): void {
if (!launchBtn.disabled) launchBtnLabel.textContent = getActiveMusicPartyId() ? addLabel : startLabel;
}
refreshLabel();
// itemId is the artist itself - the server expands it into every
// album's tracks, in order, the same mechanism a whole-album launch
// uses one level down (see views/album/Album.ts, and index.ts's
// resolveMusicTracksForItem/createMusicPartyFromTracks).
launchBtn.addEventListener('click', async () => {
launchBtn.disabled = true;
const activePartyId = getActiveMusicPartyId();
try {
if (activePartyId) {
launchBtnLabel.textContent = 'Ajout...';
await queueItem(activePartyId, artistId);
} else {
launchBtnLabel.textContent = 'Démarrage...';
// Same "attach the bar, stay put" pattern as views/album - see
// its own comment.
const party = await createParty(artistId);
joinMusicParty(party.partyId, party);
}
launchBtnLabel.textContent = 'Ajouté !';
setTimeout(() => { launchBtn.disabled = false; refreshLabel(); }, 1500);
} catch (err) {
showToast((err as Error).message, { variant: 'error' });
launchBtn.disabled = false;
refreshLabel();
}
});
} }

View file

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

View file

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

View file

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

View 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);
}
}

View file

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

View file

@ -1,9 +1,11 @@
import './Library.css';
import html from './Library.html'; import html from './Library.html';
import { getArtists, getLibraries, getLibraryItems } from '../../api/embyApi'; import { getArtists, getLibraries, getLibraryItems } from '../../api/embyApi';
import { createParty } from '../../api/party'; import { createParty } from '../../api/party';
import { navigate } from '../../router'; import { navigate } from '../../router';
import type { RouteParams } from '../../router'; import type { CleanupFn, RouteParams } from '../../router';
import { renderTilesInChunks, wireSearchFilter, wireTileButtons } from '../../tiles'; import { renderGroupedTilesInChunks, wireAlphaRail, wireSectionSearchSync } from '../../alphaIndex';
import { wireSearchFilter, wireTileButtons } from '../../tiles';
import { LOADING_HINT } from '../../loading'; import { LOADING_HINT } from '../../loading';
/** /**
@ -17,17 +19,30 @@ import { LOADING_HINT } from '../../loading';
* endpoint, not a generic library listing - see * endpoint, not a generic library listing - see
* GET /api/catalog/libraries/:id/artists) - Album -> Tracks is one * GET /api/catalog/libraries/:id/artists) - Album -> Tracks is one
* level further in (views/artist, then views/album). * 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; root.innerHTML = LOADING_HINT;
let cancelled = false;
let cleanup: CleanupFn = () => {};
(async () => {
let libraries; let libraries;
try { try {
libraries = await getLibraries(); libraries = await getLibraries();
} catch { } 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; return;
} }
if (cancelled) return;
const library = libraries.find((l) => l.id === params.id); const library = libraries.find((l) => l.id === params.id);
if (!library) { if (!library) {
@ -45,20 +60,32 @@ export async function renderLibrary(root: HTMLElement, params: RouteParams): Pro
? await getArtists(library.id) ? await getArtists(library.id)
: await getLibraryItems(library.id); : await getLibraryItems(library.id);
} catch { } catch {
if (!cancelled) {
grid.innerHTML = '<p class="error">Impossible de charger cette bibliothèque.</p>'; grid.innerHTML = '<p class="error">Impossible de charger cette bibliothèque.</p>';
root.querySelector('#searchInput')!.remove(); root.querySelector('#searchInput')!.remove();
}
return; return;
} }
if (cancelled) return;
if (!items.length) { if (!items.length) {
grid.innerHTML = '<p class="hint">Bibliothèque vide.</p>'; grid.innerHTML = '<p class="hint">Bibliothèque vide.</p>';
root.querySelector('#searchInput')!.remove(); root.querySelector('#searchInput')!.remove();
return; return;
} }
renderTilesInChunks(grid, items); renderGroupedTilesInChunks(grid, items);
wireTileButtons(grid, async (itemId) => { wireTileButtons(grid, async (itemId) => {
const party = await createParty(itemId); const party = await createParty(itemId);
navigate(`/party/${party.partyId}`); 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();
};
} }

View file

@ -196,6 +196,26 @@
transition: padding .25s ease, background-color .25s ease, border-color .25s ease; 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 /* Collapsed down to just the seek bar after a stretch of no activity
(see Player.ts's scheduleControlsHide) - asked for directly: the (see Player.ts's scheduleControlsHide) - asked for directly: the
full bar, always visible, sits right where a native <track> subtitle full bar, always visible, sits right where a native <track> subtitle
@ -346,6 +366,15 @@
.fsBtn:hover { background: rgba(255, 255, 255, .18); } .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 /* Replaces the video content while casting - the local <video> is
paused/torn down (see Player.ts's startCasting/cast.ts), so leaving paused/torn down (see Player.ts's startCasting/cast.ts), so leaving
its last frame frozen with no explanation would just look stuck. */ its last frame frozen with no explanation would just look stuck. */

View file

@ -63,6 +63,17 @@
notification rather than a fourth thing bolted onto a fixed layout. --> notification rather than a fourth thing bolted onto a fixed layout. -->
<div id="requestBanner" class="requestBanner hide"></div> <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 <!-- One bar, bottom-anchored, replacing what used to be three separate
floating pieces (a centered banner/notice column, a request floating pieces (a centered banner/notice column, a request
toolbar cornered bottom-right, an "Épisode suivant" link up in the toolbar cornered bottom-right, an "Épisode suivant" link up in the
@ -116,6 +127,17 @@
<span id="ambientNotice" class="ambientNotice hide"></span> <span id="ambientNotice" class="ambientNotice hide"></span>
</div> </div>
<div class="bottomBarZone right"> <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> <button id="fullscreenBtn" class="fsBtn" title="Plein écran" aria-label="Plein écran"></button>
</div> </div>
</div> </div>

View file

@ -1,17 +1,19 @@
import '../../styles/presence.css'; import '../../styles/presence.css';
import './Player.css'; import './Player.css';
import html from './Player.html'; 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 { getParty, leaveParty, connectParty, issueCastToken, revokeCastToken } from '../../api/party';
import type { PartyConnection } from '../../api/party'; import type { PartyConnection } from '../../api/party';
import { navigate } from '../../router'; import { navigate } from '../../router';
import type { CleanupFn, RouteParams } from '../../router'; import type { CleanupFn, RouteParams } from '../../router';
import { colorForName, escapeHtml, formatDuration, initialsFor } from '../../tiles'; import { colorForName, escapeHtml, formatDuration, initialsFor } from '../../tiles';
import { LOADING_HINT } from '../../loading'; 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 { joinMusicParty, leaveMusicParty } from '../../musicBar/MusicBar';
import { initCast, localDriver, onCastAvailabilityChanged, onCastSessionEnded, startCast, stopCast } from '../../cast'; import { initCast, localDriver, onCastAvailabilityChanged, onCastSessionEnded, startCast, stopCast } from '../../cast';
import type { PlaybackDriver } 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 TICKS_PER_SECOND = 10000000; // Emby/.NET convention: 1 tick = 100ns
const DRIFT_TOLERANCE_SECONDS = 1.5; 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 // rendering bug in renderVideoPlayer itself, not just a genuine "party
// not found", so the stack trace matters here. // not found", so the stack trace matters here.
console.error('[watchparty player] failed to open party', err); console.error('[watchparty player] failed to open party', err);
alert(err.message); showToast(err.message, { variant: 'error' });
navigate('/home'); navigate('/home');
}); });
@ -196,6 +198,7 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
const qualitySelect = root.querySelector('#qualitySelect') as HTMLSelectElement; const qualitySelect = root.querySelector('#qualitySelect') as HTMLSelectElement;
const burnInHint = root.querySelector('#burnInHint')!; const burnInHint = root.querySelector('#burnInHint')!;
const castBtn = root.querySelector('#castBtn') as HTMLButtonElement; const castBtn = root.querySelector('#castBtn') as HTMLButtonElement;
const pipBtn = root.querySelector('#pipBtn') as HTMLButtonElement;
const fullscreenBtn = root.querySelector('#fullscreenBtn') as HTMLButtonElement; const fullscreenBtn = root.querySelector('#fullscreenBtn') as HTMLButtonElement;
const bottomBar = root.querySelector('#bottomBar') as HTMLElement; const bottomBar = root.querySelector('#bottomBar') as HTMLElement;
const progressBar = root.querySelector('#progressBar') 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 playPauseBtn = root.querySelector('#playPauseBtn') as HTMLButtonElement;
const muteBtn = root.querySelector('#muteBtn') as HTMLButtonElement; const muteBtn = root.querySelector('#muteBtn') as HTMLButtonElement;
const timeLabel = root.querySelector('#timeLabel')!; 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(''); 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 isHost = false;
let hasNextQueued = 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 conn: PartyConnection | null = null;
let hls: HlsInstance | null = null; let hls: HlsInstance | null = null;
let currentItemId: string | 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 // - see its own comment) can re-render the subtitle picker's host-only
// gating for burn-in options without a redundant playback-options fetch. // gating for burn-in options without a redundant playback-options fetch.
let lastPlaybackOptions: PlaybackOptionsDto | null = null; 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 player" sendLocalState/applyRemoteState actually read and write -
// the local <video> by default, swapped for a Chromecast's RemotePlayer // the local <video> by default, swapped for a Chromecast's RemotePlayer
// while casting (see toggleCast/cast.ts). Casting is available to any // 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. */ * onDriverStateChanged below. */
function updatePlayPauseBtn() { function updatePlayPauseBtn() {
playPauseBtn.innerHTML = activeDriver.paused ? PLAY_BTN_ICON_SVG : PAUSE_BTN_ICON_SVG; playPauseBtn.innerHTML = activeDriver.paused ? PLAY_BTN_ICON_SVG : PAUSE_BTN_ICON_SVG;
setMediaSessionPlaybackState(!activeDriver.paused);
} }
/** #muteBtn - deliberately reads/writes `video.muted` directly, never /** #muteBtn - deliberately reads/writes `video.muted` directly, never
@ -425,6 +447,43 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
const current = activeDriver.currentTime; const current = activeDriver.currentTime;
progressFill.style.width = duration > 0 ? `${Math.min(100, (current / duration) * 100)}%` : '0%'; progressFill.style.width = duration > 0 ? `${Math.min(100, (current / duration) * 100)}%` : '0%';
timeLabel.textContent = duration > 0 ? `${formatDuration(current)} / ${formatDuration(duration)}` : ''; 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 /** 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; 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) { function seekFromPointerEvent(e: PointerEvent) {
const duration = activeDriver.duration; const duration = activeDriver.duration;
if (!duration) return; if (!duration) return;
const rect = progressBar.getBoundingClientRect(); const rect = progressBar.getBoundingClientRect();
const fraction = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width)); const fraction = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width));
activeDriver.currentTime = fraction * duration; seekTo(fraction * duration);
onDriverStateChanged();
} }
// Host-only (checked here, not just via the readOnly class - that one // 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(); 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 // Ticks the seek bar/time label between discrete events - currentTime
// moves continuously while playing, with no event of its own for // moves continuously while playing, with no event of its own for
// "still playing, time passed" the way play/pause/seeked have. Runs // "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(() => { progressTicker = window.setInterval(() => {
if (!currentItemId) return; if (!currentItemId) return;
updateProgressBar(); updateProgressBar();
updateSkipButton();
}, 500); }, 500);
updatePlayPauseBtn(); updatePlayPauseBtn();
updateMuteBtn(); updateMuteBtn();
@ -607,6 +683,7 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
settingsWrap.classList.remove('hide'); settingsWrap.classList.remove('hide');
requestToolbar.classList.add('hide'); // the host acts directly - no requests to make once promoted mid-session requestToolbar.classList.add('hide'); // the host acts directly - no requests to make once promoted mid-session
nextBtn.classList.toggle('hide', !hasNextQueued); nextBtn.classList.toggle('hide', !hasNextQueued);
setMediaSessionNextAction(hasNextQueued ? goToNextItem : null);
playPauseBtn.classList.remove('hide'); playPauseBtn.classList.remove('hide');
progressBar.classList.remove('readOnly'); progressBar.classList.remove('readOnly');
updateAmbientNotice(); updateAmbientNotice();
@ -614,7 +691,10 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
video.addEventListener('play', onDriverStateChanged); video.addEventListener('play', onDriverStateChanged);
video.addEventListener('pause', onDriverStateChanged); video.addEventListener('pause', onDriverStateChanged);
video.addEventListener('seeked', 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); if (!heartbeat) heartbeat = window.setInterval(reportHeartbeat, 3000);
// Burn-in (image) subtitle options are host-only (see // Burn-in (image) subtitle options are host-only (see
@ -625,7 +705,7 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
renderSubtitleOptions(); renderSubtitleOptions();
applyStreamOptionsGating(); 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) { 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 * (Re)builds the subtitle <select> from the last-fetched playback
* options, called both on a genuine item load and whenever burn-in/ * 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(); 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) { async function onItemChanged(msg: ItemChangedMsg) {
itemTitleEl.textContent = msg.itemName || ''; itemTitleEl.textContent = msg.itemName || '';
setMediaSessionMetadata(msg.itemName || '', '', '', `/api/catalog/image/${msg.itemId}`);
hasNextQueued = !!msg.hasNext; hasNextQueued = !!msg.hasNext;
nextBtn.classList.toggle('hide', !(isHost && hasNextQueued)); nextBtn.classList.toggle('hide', !(isHost && hasNextQueued));
setMediaSessionNextAction(isHost && hasNextQueued ? goToNextItem : null);
// A new item's own stream indices/subtitle pick have nothing to do // A new item's own stream indices/subtitle pick have nothing to do
// with the old one's - PartyService.advanceQueue already resets this // with the old one's - PartyService.advanceQueue already resets this
// server-side too (see its own comment); this just keeps this client // 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; burnInAudioIndex = null;
textSubtitleIndex = null; textSubtitleIndex = null;
streamAudioIndex = null; streamAudioIndex = null;
loadMarkers(msg.itemId); // not awaited - never needed before playback can start, see loadVideo below
await loadTrackOptions(msg.itemId); await loadTrackOptions(msg.itemId);
loadVideo(msg.itemId, 0, true); 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 // cooldown (mirroring the server's own independent per-request
// throttle in SocketGateway) purely so a rapid double-click doesn't // throttle in SocketGateway) purely so a rapid double-click doesn't
// even bother emitting the second request. // even bother emitting the second request.
requestPauseBtn.addEventListener('click', () => { function requestPause() {
const now = Date.now(); const now = Date.now();
if (now < pauseRequestCooldownUntil || !conn) return; if (now < pauseRequestCooldownUntil || !conn) return;
pauseRequestCooldownUntil = now + REQUEST_COOLDOWN_MS; pauseRequestCooldownUntil = now + REQUEST_COOLDOWN_MS;
conn.requestPause(); conn.requestPause();
}); }
requestResumeBtn.addEventListener('click', () => { function requestResume() {
const now = Date.now(); const now = Date.now();
if (now < resumeRequestCooldownUntil || !conn) return; if (now < resumeRequestCooldownUntil || !conn) return;
resumeRequestCooldownUntil = now + REQUEST_COOLDOWN_MS; resumeRequestCooldownUntil = now + REQUEST_COOLDOWN_MS;
conn.requestResume(); conn.requestResume();
}); }
requestPauseBtn.addEventListener('click', requestPause);
requestResumeBtn.addEventListener('click', requestResume);
function goToNextItem() {
conn?.next();
}
async function init() { async function init() {
itemTitleEl.textContent = party.itemName || ''; 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; hasNextQueued = !!party.hasNext;
updateAmbientNotice(); updateAmbientNotice();
requestToolbar.classList.toggle('hide', party.youAreHost); 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. // image-subtitle option as disabled, as if they were a follower.
if (party.youAreHost) becomeHost(false); if (party.youAreHost) becomeHost(false);
loadMarkers(party.itemId); // not awaited - see onItemChanged's own comment
await loadTrackOptions(party.itemId); 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, { conn = connectParty(partyId, {
onMembers: renderMembers, onMembers: renderMembers,
@ -1127,12 +1300,28 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
if (takeOver) conn?.confirmDeviceTakeover(); if (takeOver) conn?.confirmDeviceTakeover();
else navigate('/home'); else navigate('/home');
}, },
onError: (message) => alert(message), onError: (message) => showToast(message, { variant: 'error' }),
onClose: () => {} // 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')); (root.querySelector('#leaveBtn') as HTMLButtonElement).addEventListener('click', () => navigate('/home'));
init(); 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 // other way (sidebar link, browser back, ...). This is the fix for the
// actual leak: previously, only the "Quitter" button ran any of this. // actual leak: previously, only the "Quitter" button ran any of this.
return function cleanup() { return function cleanup() {
leftIntentionally = true;
if (heartbeat) clearInterval(heartbeat); if (heartbeat) clearInterval(heartbeat);
if (progressTicker) clearInterval(progressTicker); if (progressTicker) clearInterval(progressTicker);
if (requestBannerTimer) clearTimeout(requestBannerTimer); if (requestBannerTimer) clearTimeout(requestBannerTimer);
clearControlsHideTimer(); clearControlsHideTimer();
if (hls) hls.destroy(); if (hls) hls.destroy();
clearMediaSession();
document.removeEventListener('click', onDocumentClick); document.removeEventListener('click', onDocumentClick);
document.removeEventListener('fullscreenchange', onFullscreenChange); document.removeEventListener('fullscreenchange', onFullscreenChange);
playerWrap.removeEventListener('pointermove', showControls); playerWrap.removeEventListener('pointermove', showControls);

View file

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

View file

@ -6,6 +6,7 @@
<p class="hint" id="playlistMeta"></p> <p class="hint" id="playlistMeta"></p>
<div class="playlistActions"> <div class="playlistActions">
<button id="launchBtn">Lancer cette playlist</button> <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> <a class="linklike" id="embyLink" target="_blank" rel="noopener">Voir sur Emby</a>
</div> </div>
</div> </div>

View file

@ -2,11 +2,13 @@ import './Playlist.css';
import html from './Playlist.html'; import html from './Playlist.html';
import { getItem, getPlaylistItems } from '../../api/embyApi'; import { getItem, getPlaylistItems } from '../../api/embyApi';
import { createParty } from '../../api/party'; import { createParty } from '../../api/party';
import { wireCreateStaticButton } from '../staticParty/createStaticButton';
import { joinMusicParty } from '../../musicBar/MusicBar'; import { joinMusicParty } from '../../musicBar/MusicBar';
import { navigate } from '../../router'; import { navigate } from '../../router';
import type { RouteParams } from '../../router'; import type { RouteParams } from '../../router';
import { escapeHtml, formatDuration } from '../../tiles'; import { escapeHtml, formatDuration } from '../../tiles';
import { LOADING_HINT } from '../../loading'; import { LOADING_HINT } from '../../loading';
import { showToast } from '../../toast';
import type { ItemDto } from '../../types'; import type { ItemDto } from '../../types';
const TICKS_PER_SECOND = 10000000; // Emby/.NET convention: 1 tick = 100ns, same as everywhere else this app touches durations 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; 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) => { listEl.innerHTML = items.map((item, i) => {
const meta = item.overview || [item.artistName, item.albumName].filter(Boolean).join(' · '); const meta = item.overview || [item.artistName, item.albumName].filter(Boolean).join(' · ');
const duration = item.durationTicks ? formatDuration(item.durationTicks / TICKS_PER_SECOND) : ''; 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}`); navigate(`/party/${party.partyId}`);
} }
} catch (err) { } catch (err) {
alert((err as Error).message); showToast((err as Error).message, { variant: 'error' });
btn.disabled = false; btn.disabled = false;
btn.textContent = idleLabel; btn.textContent = idleLabel;
} }

View file

@ -17,6 +17,20 @@
.seriesPoster.noImage { visibility: hidden; } .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) { @media (max-width: 600px) {
.seriesHeader { flex-direction: column; } .seriesHeader { flex-direction: column; }
.seriesPoster { width: 120px; } .seriesPoster { width: 120px; }

View file

@ -4,8 +4,16 @@
<div class="seriesInfo"> <div class="seriesInfo">
<h1 id="seriesTitle"></h1> <h1 id="seriesTitle"></h1>
<p class="seriesOverview hide" id="seriesOverview"></p> <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> <a class="linklike" id="embyLink" target="_blank" rel="noopener">Voir sur Emby</a>
</div> </div>
</div>
</div> </div>
<nav id="seasonTabs" class="tabs"></nav> <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> <div id="episodeList" class="episodeList"><p class="hint">Chargement...</p></div>

View file

@ -2,10 +2,12 @@ import './Series.css';
import html from './Series.html'; import html from './Series.html';
import { getItem, getSeriesEpisodes, getSeriesSeasons } from '../../api/embyApi'; import { getItem, getSeriesEpisodes, getSeriesSeasons } from '../../api/embyApi';
import { createParty } from '../../api/party'; import { createParty } from '../../api/party';
import { wireCreateStaticButton } from '../staticParty/createStaticButton';
import { navigate } from '../../router'; import { navigate } from '../../router';
import type { RouteParams } from '../../router'; import type { RouteParams } from '../../router';
import { escapeHtml } from '../../tiles'; import { escapeHtml } from '../../tiles';
import { LOADING_HINT } from '../../loading'; import { LOADING_HINT } from '../../loading';
import { showToast } from '../../toast';
import type { ItemDto, SeasonDto } from '../../types'; import type { ItemDto, SeasonDto } from '../../types';
export async function renderSeries(root: HTMLElement, params: RouteParams): Promise<void> { 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; (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) { 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>'; root.querySelector('#episodeList')!.innerHTML = '<p class="hint">Aucune saison trouvée.</p>';
return; 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')!; const tabsEl = root.querySelector('#seasonTabs')!;
tabsEl.innerHTML = seasons tabsEl.innerHTML = seasons
.map((s, i) => `<button class="tab${i === 0 ? ' active' : ''}" data-season-id="${s.id}">${escapeHtml(s.name)}</button>`) .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; if (tab.classList.contains('active')) return;
tabsEl.querySelectorAll('.tab').forEach((t) => t.classList.remove('active')); tabsEl.querySelectorAll('.tab').forEach((t) => t.classList.remove('active'));
tab.classList.add('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); 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> { async function loadEpisodes(root: HTMLElement, seriesId: string, seasonId: string): Promise<void> {
const listEl = root.querySelector('#episodeList')!; const listEl = root.querySelector('#episodeList')!;
listEl.innerHTML = LOADING_HINT; 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); const party = await createParty(btn.dataset.itemId as string);
navigate(`/party/${party.partyId}`); navigate(`/party/${party.partyId}`);
} catch (err) { } catch (err) {
alert((err as Error).message); showToast((err as Error).message, { variant: 'error' });
btn.disabled = false; btn.disabled = false;
btn.textContent = 'Lancer une watch party'; btn.textContent = 'Lancer une watch party';
} }

View 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; }
}

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

View 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('');
}

View 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; }
}

View file

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

View file

@ -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;
});
}

View 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;
});
});
}

View file

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

File diff suppressed because it is too large Load diff

View file

@ -46,6 +46,10 @@ COPY --from=build /app/client/dist ./client/dist
WORKDIR /app/server WORKDIR /app/server
ENV PORT=3000 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 EXPOSE 3000
CMD ["node", "dist/index.js"] CMD ["node", "dist/index.js"]

View file

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

View file

@ -83,4 +83,11 @@ export class Logger {
static playstate(message: string): void { static playstate(message: string): void {
console.log(`[watchparty:playstate] ${new Date().toISOString()} ${message}`); console.log(`[watchparty:playstate] ${new Date().toISOString()} ${message}`);
} }
/** Static (persistent) watch parties - created/joined/left/deleted,
* group and per-member progress, sessions launched, and every load/save
* of their JSON file (see StaticPartyService/StaticPartyStore). */
static staticParty(message: string): void {
console.log(`[watchparty:static] ${new Date().toISOString()} ${message}`);
}
} }

View file

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

View file

@ -5,10 +5,10 @@ import http from 'http';
import express, { type NextFunction, type Request, type Response } from 'express'; import express, { type NextFunction, type Request, type Response } from 'express';
import { Server as SocketIoServer } from 'socket.io'; 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 { Logger } from './Logger';
import { AuthService, SESSION_COOKIE, SESSION_TTL_MS } from './services/AuthService'; 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 { PartyService } from './services/PartyService';
import { MusicQueueService } from './services/MusicQueueService'; import { MusicQueueService } from './services/MusicQueueService';
import { TranscodeService } from './services/TranscodeService'; import { TranscodeService } from './services/TranscodeService';
@ -16,8 +16,13 @@ import { PartyStreamService } from './services/PartyStreamService';
import { PlaystateService } from './services/PlaystateService'; import { PlaystateService } from './services/PlaystateService';
import { EmbySessionService } from './services/EmbySessionService'; import { EmbySessionService } from './services/EmbySessionService';
import { CastTokenService } from './services/CastTokenService'; import { CastTokenService } from './services/CastTokenService';
import { StaticPartyService } from './services/StaticPartyService';
import { StaticPartyStore } from './services/StaticPartyStore';
import { SocketGateway } from './realtime/SocketGateway'; 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(); const app = express();
app.set('trust proxy', 1); // behind openresty/nginx - needed for req.secure to reflect the real scheme app.set('trust proxy', 1); // behind openresty/nginx - needed for req.secure to reflect the real scheme
@ -213,6 +218,13 @@ app.get('/api/catalog/item/:itemId/playback-options', requireSession, asyncRoute
res.json(await CatalogService.getPlaybackOptions(req.embySession!, param(req, 'itemId'))); 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) => { app.get('/api/catalog/libraries/:libraryId/items', requireSession, asyncRoute(async (req, res) => {
res.json({ items: await CatalogService.getLibraryItems(req.embySession!, param(req, 'libraryId')) }); res.json({ items: await CatalogService.getLibraryItems(req.embySession!, param(req, 'libraryId')) });
})); }));
@ -246,7 +258,7 @@ app.get('/api/catalog/party-stream/:partyId/master.m3u8', allowCastCors, asyncRo
if (!session) { res.status(401).end(); return; } if (!session) { res.status(401).end(); return; }
const party = PartyService.getById(partyId); const party = PartyService.getById(partyId);
if (!party || !party.members.has(session.userId)) { 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; return;
} }
await PartyStreamService.serveMasterPlaylist(req, res, party, session); await PartyStreamService.serveMasterPlaylist(req, res, party, session);
@ -260,7 +272,7 @@ app.get('/api/catalog/party-stream/:partyId/raw', allowCastCors, asyncRoute(asyn
if (!session) { res.status(401).end(); return; } if (!session) { res.status(401).end(); return; }
const party = PartyService.getById(partyId); const party = PartyService.getById(partyId);
if (!party || !party.members.has(session.userId)) { 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; return;
} }
await PartyStreamService.serveRawAsset(req, res, party, session); await PartyStreamService.serveRawAsset(req, res, party, session);
@ -290,7 +302,7 @@ app.get('/api/catalog/burn/:partyId/master.m3u8', allowCastCors, asyncRoute(asyn
if (!session) { res.status(401).end(); return; } if (!session) { res.status(401).end(); return; }
const party = PartyService.getById(partyId); const party = PartyService.getById(partyId);
if (!party || !party.members.has(session.userId)) { 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; return;
} }
if (!party.burnInSubtitleIndex) { if (!party.burnInSubtitleIndex) {
@ -306,7 +318,7 @@ app.get('/api/catalog/burn/:partyId/:segment', allowCastCors, asyncRoute(async (
if (!session) { res.status(401).end(); return; } if (!session) { res.status(401).end(); return; }
const party = PartyService.getById(partyId); const party = PartyService.getById(partyId);
if (!party || !party.members.has(session.userId)) { 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; return;
} }
await TranscodeService.serveSegment(req, res, party, param(req, 'segment')); await TranscodeService.serveSegment(req, res, party, param(req, 'segment'));
@ -338,6 +350,32 @@ app.post('/api/parties', requireSession, asyncRoute(async (req, res) => {
return; 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'; const mediaType: MediaType = item.Type === 'Audio' ? 'music' : 'video';
// Starting a party from an episode queues the rest of the series from // Starting a party from an episode queues the rest of the series from
@ -348,13 +386,8 @@ app.post('/api/parties', requireSession, asyncRoute(async (req, res) => {
let queue: QueueItem[] | null = null; let queue: QueueItem[] | null = null;
if (mediaType === 'video' && item.Type === 'Episode' && item.SeriesId) { if (mediaType === 'video' && item.Type === 'Episode' && item.SeriesId) {
const all = await CatalogService.getAllSeriesEpisodesRaw(session, item.SeriesId); const all = await CatalogService.getAllSeriesEpisodesRaw(session, item.SeriesId);
const startIndex = all.findIndex((e) => e.Id === itemId); const remaining = seriesQueueFrom(all, itemId);
const remaining = startIndex >= 0 ? all.slice(startIndex) : [item]; queue = (remaining.length ? remaining : [item]).map(toVideoQueueItem);
queue = remaining.map((e) => ({
itemId: e.Id,
itemName: formatEpisodeName(e),
itemImageUrl: `/api/catalog/image/${e.Id}`
}));
} }
const party = PartyService.create( const party = PartyService.create(
@ -391,6 +424,17 @@ function formatEpisodeName(episode: { ParentIndexNumber?: number; IndexNumber?:
return episode.Name; 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 * The "launch this playlist" half of POST /api/parties above - kept
* separate rather than merged into the single-item path, which is * separate rather than merged into the single-item path, which is
@ -425,41 +469,19 @@ async function createPartyFromPlaylist(res: Response, session: EmbySession, play
const items = startIndex > 0 ? allItems.slice(startIndex) : allItems; const items = startIndex > 0 ? allItems.slice(startIndex) : allItems;
if (mediaType === 'music') { if (mediaType === 'music') {
const first = items[0]!; const trackInputs: MusicTrackInput[] = items.map((track) => ({
const party = PartyService.create(
session.userId, session.userName,
first.Id, first.Name, imageUrlFor(first),
null, mediaType
);
PartyService.setMemberEmbyToken(party, session.userId, session.embyToken);
// Same mechanism POST /api/parties/:id/tracks already uses (see
// below) - init seeds+auto-starts the first track, addTrack queues
// the rest, exactly as if they'd been added one at a time.
items.forEach((track, i) => {
const trackInput = {
itemId: track.Id, itemId: track.Id,
itemName: track.Name, itemName: track.Name,
artistName: track.AlbumArtist || track.Artists?.[0] || null, artistName: track.AlbumArtist || track.Artists?.[0] || null,
albumName: track.Album || null, albumName: track.Album || null,
itemImageUrl: imageUrlFor(track), itemImageUrl: imageUrlFor(track),
durationTicks: track.RunTimeTicks || null durationTicks: track.RunTimeTicks || null
}; }));
if (i === 0) { createMusicPartyFromTracks(res, session, trackInputs);
MusicQueueService.init(party, trackInput, session.userId, session.userName);
} else {
MusicQueueService.addTrack(party, trackInput, session.userId, session.userName);
}
});
SocketGateway.broadcastLobby(io);
res.json(PartyService.toPublicState(party, session.userId));
return; return;
} }
const queue: QueueItem[] = items.map((e) => ({ const queue: QueueItem[] = items.map(toVideoQueueItem);
itemId: e.Id,
itemName: e.Type === 'Episode' ? formatEpisodeName(e) : e.Name,
itemImageUrl: `/api/catalog/image/${e.Id}`
}));
const party = PartyService.create( const party = PartyService.create(
session.userId, session.userName, session.userId, session.userName,
queue[0]!.itemId, queue[0]!.itemName, queue[0]!.itemImageUrl, queue[0]!.itemId, queue[0]!.itemName, queue[0]!.itemImageUrl,
@ -470,6 +492,112 @@ async function createPartyFromPlaylist(res: Response, session: EmbySession, play
res.json(PartyService.toPublicState(party, session.userId)); 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 // 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 // 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 // this is actually called from has no live connection to the party it's
@ -480,12 +608,12 @@ async function createPartyFromPlaylist(res: Response, session: EmbySession, play
app.post('/api/parties/:partyId/tracks', requireSession, (req, res) => { app.post('/api/parties/:partyId/tracks', requireSession, (req, res) => {
const party = PartyService.getById(param(req, 'partyId')); const party = PartyService.getById(param(req, 'partyId'));
if (!party || party.mediaType !== 'music') { if (!party || party.mediaType !== 'music') {
res.status(404).json({ error: 'Watch party musique introuvable.' }); res.status(404).json({ error: 'Listen party introuvable.' });
return; return;
} }
const session = req.embySession!; const session = req.embySession!;
if (!party.members.has(session.userId)) { 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; return;
} }
const track = req.body || {}; const track = req.body || {};
@ -498,6 +626,39 @@ app.post('/api/parties/:partyId/tracks', requireSession, (req, res) => {
res.json(PartyService.toPublicState(party, session.userId)); 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) => { app.post('/api/parties/:partyId/join', requireSession, (req, res) => {
const party = PartyService.getById(param(req, 'partyId')); const party = PartyService.getById(param(req, 'partyId'));
if (!party) { if (!party) {
@ -507,6 +668,12 @@ app.post('/api/parties/:partyId/join', requireSession, (req, res) => {
const session = req.embySession!; const session = req.embySession!;
PartyService.join(party, session.userId, session.userName); PartyService.join(party, session.userId, session.userName);
PartyService.setMemberEmbyToken(party, session.userId, session.embyToken); 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 SocketGateway.broadcastLobby(io); // member count changed - reflect it on the home page immediately
res.json(PartyService.toPublicState(party, session.userId)); res.json(PartyService.toPublicState(party, session.userId));
}); });
@ -514,10 +681,17 @@ app.post('/api/parties/:partyId/join', requireSession, (req, res) => {
app.post('/api/parties/:partyId/leave', requireSession, (req, res) => { app.post('/api/parties/:partyId/leave', requireSession, (req, res) => {
const party = PartyService.getById(param(req, 'partyId')); const party = PartyService.getById(param(req, 'partyId'));
if (party) { 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- // Shared with the socket "leave"/"disconnect" handlers so the host-
// handoff notification and the lobby refresh only live in one place. // handoff notification and the lobby refresh only live in one place.
SocketGateway.applyLeave(io, party, req.embySession!.userId); SocketGateway.applyLeave(io, party, req.embySession!.userId);
} }
}
res.json({ ok: true }); res.json({ ok: true });
}); });
@ -540,7 +714,7 @@ app.post('/api/parties/:partyId/cast-token', requireSession, (req, res) => {
const party = PartyService.getById(param(req, 'partyId')); const party = PartyService.getById(param(req, 'partyId'));
const session = req.embySession!; const session = req.embySession!;
if (!party || !party.members.has(session.userId)) { 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; return;
} }
res.json({ token: CastTokenService.issue(party.id, session) }); res.json({ token: CastTokenService.issue(party.id, session) });
@ -554,6 +728,229 @@ app.delete('/api/parties/:partyId/cast-token', requireSession, (req, res) => {
res.json({ ok: true }); 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) - // Catches whatever asyncRoute() forwarded (or a synchronous throw) -
// CatalogFetchError carries its own status/message; anything else is an // CatalogFetchError carries its own status/message; anything else is an
// unexpected bug, logged in full server-side but never leaked to the // unexpected bug, logged in full server-side but never leaked to the
@ -622,6 +1019,9 @@ setInterval(() => {
for (const party of PartyService.listActive()) { for (const party of PartyService.listActive()) {
const state = PartyService.getCurrentPlaybackState(party); const state = PartyService.getCurrentPlaybackState(party);
if (!state) continue; // nothing currently playing (e.g. an empty music queue) 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()) { for (const member of party.members.values()) {
if (!member.connection || !member.embyToken) continue; if (!member.connection || !member.embyToken) continue;
PlaystateService.updatePosition(member.embyToken, member.userId, state.itemId, state.positionTicks); PlaystateService.updatePosition(member.embyToken, member.userId, state.itemId, state.positionTicks);
@ -658,6 +1058,16 @@ setInterval(() => {
} }
}, 20 * 1000); }, 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, () => { server.listen(PORT, () => {
console.log(`[watchparty] listening on :${PORT} (Emby server: ${EMBY_SERVER_URL || 'NOT SET'})`); console.log(`[watchparty] listening on :${PORT} (Emby server: ${EMBY_SERVER_URL || 'NOT SET'})`);
}); });

View file

@ -3,6 +3,7 @@
import { Logger } from '../Logger'; import { Logger } from '../Logger';
import { MusicQueueService } from '../services/MusicQueueService'; import { MusicQueueService } from '../services/MusicQueueService';
import { PartyService } from '../services/PartyService'; import { PartyService } from '../services/PartyService';
import { StaticPartyService } from '../services/StaticPartyService';
import type { AppServer, LeaveResult, LobbyPartyDto, Party, SocketMemberDto } from '../types'; 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" {isPaused, positionTicks} (host only - ignored otherwise)
* "playstate" (server -> client, to everyone in the party but the sender) * "playstate" (server -> client, to everyone in the party but the sender)
* "next" (host only - advance the queue) * "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) * "itemChanged" (server -> client)
* "setBurnInSubtitle" {subtitleIndex, audioIndex?} (host only - picks * "setBurnInSubtitle" {subtitleIndex, audioIndex?} (host only - picks
* or clears the party's *shared* self-hosted-ffmpeg burn-in stream, * 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* // Host-only, video-only: picks (or clears) the party's *shared*
// burn-in stream - see TranscodeService and Party.burnInSubtitleIndex's // burn-in stream - see TranscodeService and Party.burnInSubtitleIndex's
// own comment. Deliberately doesn't touch TranscodeService directly // own comment. Deliberately doesn't touch TranscodeService directly
@ -283,6 +298,12 @@ class SocketGatewayClass {
MusicQueueService.setPaused(party, !!msg?.isPaused); 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 // Anyone (not just followers - harmless either way) can ask the host
// to pause; the presence overlay (the video player) is what triggers // to pause; the presence overlay (the video player) is what triggers
// this. Broadcast to the whole party, not just the host, so everyone // this. Broadcast to the whole party, not just the host, so everyone
@ -404,8 +425,11 @@ class SocketGatewayClass {
} }
private lobbySnapshot(): LobbyPartyDto[] { 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 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 // Video only - see LobbyPartyDto's own comment for why music parties
// get null here and fall back to createdUtc client-side instead. // get null here and fall back to createdUtc client-side instead.
// The host is authoritative for position/pause the same way they are // 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 createdUtc: party.createdUtc, // client computes/ticks elapsed time itself from this
positionTicks: isVideo && host ? host.positionTicks : null, positionTicks: isVideo && host ? host.positionTicks : null,
isPaused: isVideo && host ? host.isPaused : 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
}; };
}); });
} }

View file

@ -4,14 +4,15 @@ import type { Request, Response } from 'express';
import { EMBY_SERVER_URL } from '../config'; import { EMBY_SERVER_URL } from '../config';
import { EmbyClient } from '../emby/EmbyClient'; import { EmbyClient } from '../emby/EmbyClient';
import { Logger } from '../Logger'; 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 // 'MusicVideo' is Emby's own item type for a musicvideos library's
// contents (distinct from the generic 'Video', which is home videos). // contents (distinct from the generic 'Video', which is home videos).
// 'Audio' is a music track. Series/Season/MusicArtist/MusicAlbum aren't // 'Audio' is a music track. 'Series' isn't itself streamable, but
// directly playable - only show "start a party" for things Emby can // launching one queues the whole series (see seriesQueueFrom and POST
// actually stream. "Voir sur Emby" always works though. // /api/parties). Season/MusicArtist/MusicAlbum get no tile button - their
const WATCH_PARTY_TYPES = new Set(['Movie', 'Episode', 'Video', 'MusicVideo', 'Audio']); // 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 // Emby's /Views can return all sorts of things beyond actual media
// libraries - boxset collections, live TV, photos, books, a mixed-content // 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}`; 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[] } { export function splitPlaylistByMediaType(items: EmbyItem[]): { mediaType: MediaType; items: EmbyItem[] } {
if (!items.length) return { mediaType: 'video', items: [] }; if (!items.length) return { mediaType: 'video', items: [] };
const isMusic = items[0]!.Type === 'Audio'; const isMusic = items[0]!.Type === 'Audio';
@ -125,9 +156,23 @@ class CatalogServiceClass {
* mediaType from the clicked item's real Emby Type (see index.ts). * mediaType from the clicked item's real Emby Type (see index.ts).
* Unlike every other method here, this preserves Emby's own status * Unlike every other method here, this preserves Emby's own status
* (typically a real 404) rather than a blanket 502 - "titre introuvable" * (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> { 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> { 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 // so a 300 cap would have silently dropped 75% of them, not just
// trimmed an edge case. Rendering that many is tiles.ts's // trimmed an edge case. Rendering that many is tiles.ts's
// renderTilesInChunks's job, not this query'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.'); 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)); return (data.Items || []).map((a) => this.toItemDto(a, session.serverId));
} }
@ -261,7 +306,7 @@ class CatalogServiceClass {
ParentId: libraryId, ParentId: libraryId,
Recursive: 'false', // top-level: Movies as movies, TV as Series tiles (not every episode) Recursive: 'false', // top-level: Movies as movies, TV as Series tiles (not every episode)
SortBy: 'SortName', SortBy: 'SortName',
Fields: 'ProductionYear,AlbumId', Fields: 'ProductionYear,AlbumId,SortName',
ImageTypeLimit: '1' 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.'); 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 * The negotiated MediaSource's id/container/streams - used by
* TranscodeService to build its own ffmpeg source URL and translate * TranscodeService to build its own ffmpeg source URL and translate
@ -479,6 +548,7 @@ class CatalogServiceClass {
return { return {
id: item.Id, id: item.Id,
name: item.Name, name: item.Name,
sortName: item.SortName || item.Name,
overview: item.Overview, overview: item.Overview,
year: item.ProductionYear, year: item.ProductionYear,
type: item.Type, type: item.Type,

View file

@ -52,7 +52,42 @@ class MusicQueueServiceClass {
addTrack(party: Party, track: MusicTrackInput, addedByUserId: string, addedByUserName: string): void { addTrack(party: Party, track: MusicTrackInput, addedByUserId: string, addedByUserName: string): void {
const music = party.music!; 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, itemId: track.itemId,
itemName: track.itemName, itemName: track.itemName,
artistName: track.artistName || null, artistName: track.artistName || null,
@ -64,13 +99,6 @@ class MusicQueueServiceClass {
addedByUserId, addedByUserId,
addedByUserName 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). */ /** No host check - anyone can toggle this (see class comment). */
@ -92,6 +120,41 @@ class MusicQueueServiceClass {
this.broadcast(party); 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 * Registers `userId`'s vote to skip the current track - a strict
* majority of `connectedMemberCount` (computed by the caller, which has * majority of `connectedMemberCount` (computed by the caller, which has

View file

@ -8,6 +8,7 @@ import { PartyStreamService } from './PartyStreamService';
import { PlaystateService } from './PlaystateService'; import { PlaystateService } from './PlaystateService';
import { CastTokenService } from './CastTokenService'; import { CastTokenService } from './CastTokenService';
import { EmbySessionService } from './EmbySessionService'; import { EmbySessionService } from './EmbySessionService';
import { StaticPartyService } from './StaticPartyService';
import type { LeaveResult, MediaType, Party, PartyMember, PartyPublicState, QueueItem } from '../types'; import type { LeaveResult, MediaType, Party, PartyMember, PartyPublicState, QueueItem } from '../types';
/** /**
@ -144,6 +145,30 @@ class PartyServiceClass {
return { itemId: party.itemId, positionTicks: host.positionTicks, isPaused: host.isPaused }; 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 * Removes a member. If they were the host, promotes whoever's left
* (arbitrary but deterministic-ish - good enough for v1); if nobody's * (arbitrary but deterministic-ish - good enough for v1); if nobody's
@ -185,6 +210,7 @@ class PartyServiceClass {
MusicQueueService.destroy(party); // clears the auto-advance timer, if any - see its own comment 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 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 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)`); Logger.party(`destroyed partyId=${party.id} (last member ${whoLeft} left)`);
} else if (wasHost && newHostUserId != null) { } else if (wasHost && newHostUserId != null) {
const newHost = party.members.get(newHostUserId)!; const newHost = party.members.get(newHostUserId)!;
@ -206,7 +232,9 @@ class PartyServiceClass {
if (party.queueIndex >= party.queue.length - 1) { if (party.queueIndex >= party.queue.length - 1) {
return false; 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; party.queueIndex += 1;
const next = party.queue[party.queueIndex]!; const next = party.queue[party.queueIndex]!;
party.itemId = next.itemId; party.itemId = next.itemId;
@ -217,18 +245,6 @@ class PartyServiceClass {
member.isPaused = false; member.isPaused = false;
member.lastUpdatedUtc = Date.now(); 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 // 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 // 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, // show whatever track happens to share that index on the new item,
@ -253,6 +269,40 @@ class PartyServiceClass {
return true; 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. */ /** All active parties - powers the "watch parties en cours" home section. */
listActive(): Party[] { listActive(): Party[] {
return Array.from(this.partiesById.values()); return Array.from(this.partiesById.values());
@ -266,6 +316,7 @@ class PartyServiceClass {
MusicQueueService.destroy(party); // same leak this class's own destroy path guards against 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 TranscodeService.endSession(party.id); // same leak - see the "leave" destroy path above
PartyStreamService.endSession(party.id); PartyStreamService.endSession(party.id);
if (party.staticLink) StaticPartyService.onPartyDestroyed(party.staticLink, party.id);
// Same reasoning, for any member whose own "leave" (a clean // Same reasoning, for any member whose own "leave" (a clean
// disconnect) never fired for whatever reason - a real gap would // disconnect) never fired for whatever reason - a real gap would
// otherwise leave a stale entry sitting in Emby's own dashboard // otherwise leave a stale entry sitting in Emby's own dashboard

View 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();

View 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();

View file

@ -29,8 +29,18 @@ export interface ClientToServerEvents {
join: (msg: { partyId: string; deviceId: string; force?: boolean }) => void; join: (msg: { partyId: string; deviceId: string; force?: boolean }) => void;
playstate: (msg: { isPaused: boolean; positionTicks: number }) => void; playstate: (msg: { isPaused: boolean; positionTicks: number }) => void;
next: () => 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; voteSkip: () => void;
musicPlaystate: (msg: { isPaused: boolean }) => 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; requestPause: () => void;
requestResume: () => void; requestResume: () => void;
/** Host-only (checked server-side exactly like "next") - picks which /** Host-only (checked server-side exactly like "next") - picks which
@ -152,11 +162,19 @@ declare global {
export interface EmbyItem { export interface EmbyItem {
Id: string; Id: string;
Name: 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; Overview?: string;
ProductionYear?: number; ProductionYear?: number;
Type?: string; Type?: string;
SeriesId?: string; SeriesId?: string;
SeriesName?: string; SeriesName?: string;
/** An episode's own season - see CatalogService.seasonQueue. */
SeasonId?: string;
ParentIndexNumber?: number; ParentIndexNumber?: number;
IndexNumber?: number; IndexNumber?: number;
RunTimeTicks?: number; RunTimeTicks?: number;
@ -169,6 +187,11 @@ export interface EmbyItem {
* one sampled 500'd), only its album does - this is how an image URL * one sampled 500'd), only its album does - this is how an image URL
* falls back to the album's art instead. */ * falls back to the album's art instead. */
AlbumId?: string; 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, /** This app's own DTO shape - what toItemDto() in CatalogService produces,
@ -178,6 +201,16 @@ export interface EmbyItem {
export interface ItemDto { export interface ItemDto {
id: string; id: string;
name: 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; overview?: string;
year?: number; year?: number;
type?: string; type?: string;
@ -219,6 +252,25 @@ export interface PlaybackOptionsDto {
subtitleTracks: PlaybackTrackOption[]; 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) ----------------------------------------------------------- // --- Party (video) -----------------------------------------------------------
/** One entry in a video party's fixed, pre-built queue (a movie/standalone /** One entry in a video party's fixed, pre-built queue (a movie/standalone
@ -319,6 +371,25 @@ export interface Party {
* MusicBar.ts never had this picker in the first place. */ * MusicBar.ts never had this picker in the first place. */
streamAudioIndex: string | null; streamAudioIndex: string | null;
streamQuality: 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 { export interface PublicMember {
@ -375,6 +446,92 @@ export interface LobbyPartyDto {
positionTicks: number | null; positionTicks: number | null;
isPaused: boolean | null; isPaused: boolean | null;
positionUpdatedUtc: number | 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 { export interface LeaveResult {

View file

@ -4,7 +4,7 @@
import { describe, it } from 'node:test'; import { describe, it } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import type { Request, Response } from 'express'; 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 { EmbyClient } from '../src/emby/EmbyClient';
import type { EmbyItem, EmbySession } from '../src/types'; 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) => { 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({ t.mock.method(EmbyClient, 'fetch', async () => jsonResponse({
MediaSources: [{ MediaSources: [{
@ -117,6 +129,42 @@ describe('CatalogService', () => {
assert.equal(options.subtitleTracks.find((t) => t.index === 4)?.isTextBased, true, 'ass is text too'); 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) => { 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' }] })); t.mock.method(EmbyClient, 'fetch', async () => jsonResponse({ MediaSources: [{ Id: 'src7' }] }));
let capturedTarget = ''; let capturedTarget = '';
@ -266,3 +314,34 @@ describe('imageUrlFor', () => {
assert.equal(imageUrlFor({ Id: 't1', Name: 'Track', Type: 'Audio' }), '/api/catalog/image/t1'); assert.equal(imageUrlFor({ Id: 't1', Name: 'Track', Type: 'Audio' }), '/api/catalog/image/t1');
}); });
}); });
describe('seriesQueueFrom / seasonQueue', () => {
const ep = (id: string, season: number, seasonId?: string): EmbyItem => ({ Id: id, Name: id, Type: 'Episode', ParentIndexNumber: season, SeasonId: seasonId });
// Emby's own order: specials (season 0) first, then every season in turn.
const all = [ep('sp1', 0, 'se0'), ep('s1e1', 1, 'se1'), ep('s1e2', 1, 'se1'), ep('s2e1', 2, 'se2'), ep('s2e2', 2, 'se2')];
it('the whole series starts at S1E1 - specials sort first and are skipped', () => {
assert.deepEqual(seriesQueueFrom(all).map((e) => e.Id), ['s1e1', 's1e2', 's2e1', 's2e2']);
});
it('a series with only specials still queues them', () => {
assert.deepEqual(seriesQueueFrom([ep('sp1', 0), ep('sp2', 0)]).map((e) => e.Id), ['sp1', 'sp2']);
});
it('from an episode, queues it and everything after it, across seasons', () => {
assert.deepEqual(seriesQueueFrom(all, 's1e2').map((e) => e.Id), ['s1e2', 's2e1', 's2e2']);
});
it('an unknown starting episode yields nothing (the caller falls back to that one item)', () => {
assert.deepEqual(seriesQueueFrom(all, 'nope'), []);
});
it('a season queues only its own episodes', () => {
assert.deepEqual(seasonQueue(all, { Id: 'se1', IndexNumber: 1 }).map((e) => e.Id), ['s1e1', 's1e2']);
});
it('a season falls back to the season number for episodes without a SeasonId', () => {
const noIds = [ep('a', 1), ep('b', 2), ep('c', 2)];
assert.deepEqual(seasonQueue(noIds, { Id: 'se2', IndexNumber: 2 }).map((e) => e.Id), ['b', 'c']);
});
});

View file

@ -77,6 +77,60 @@ describe('MusicQueueService', () => {
assert.equal(music(party).queue.length, 1); 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', () => { it('voteSkip needs a strict majority of connected members, not any count', () => {
const party = fakeParty('p3'); const party = fakeParty('p3');
MusicQueueService.init(party, { itemId: 't1', itemName: 'Track One', itemImageUrl: '/img/t1', durationTicks: 10_000_000 }, 'u-alice', 'Alice'); MusicQueueService.init(party, { itemId: 't1', itemName: 'Track One', itemImageUrl: '/img/t1', durationTicks: 10_000_000 }, 'u-alice', 'Alice');
@ -144,6 +198,67 @@ describe('MusicQueueService', () => {
MusicQueueService.destroy(party); // avoid leaking this test's own long-duration timer 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 () => { it('a short track auto-advances on its own via a real timer, with no client reporting "ended"', async () => {
const party = fakeParty('p5'); const party = fakeParty('p5');
MusicQueueService.init(party, { itemId: 't1', itemName: 'Track One', itemImageUrl: '/img/t1', durationTicks: 10_000_000 /* 1s */ }, 'u-alice', 'Alice'); MusicQueueService.init(party, { itemId: 't1', itemName: 'Track One', itemImageUrl: '/img/t1', durationTicks: 10_000_000 /* 1s */ }, 'u-alice', 'Alice');

View file

@ -155,6 +155,41 @@ describe('PartyService', () => {
assert.equal(called, false); 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', () => { 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 party = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video');
const bob = PartyService.join(party, 'u-bob', 'Bob'); const bob = PartyService.join(party, 'u-bob', 'Bob');

View file

@ -56,7 +56,7 @@ function attach(io: ReturnType<typeof fakeIo>) {
} }
describe('SocketGateway', () => { 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(); const io = fakeIo();
attach(io); attach(io);
const party = PartyService.create('u-host', 'Host', 'm1', 'Un Film', '/img/m1', null, 'video'); 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; const before = io.roomEmits.length;
socket.trigger('voteSkip'); socket.trigger('voteSkip');
socket.trigger('musicPlaystate', { isPaused: true }); 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)', () => { 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); 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', () => { it('voteSkip needs a majority of connected members and broadcasts queueUpdated on the party room', () => {
const io = fakeIo(); const io = fakeIo();
attach(io); attach(io);

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