Commit graph

9 commits

Author SHA1 Message Date
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
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
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
5a1bc8d932 Fix the listen-party queue's layout and its missing album art
All checks were successful
Publier l'image Docker / test (push) Successful in 19s
Publier l'image Docker / publish (push) Successful in 12s
Reported directly: the music bar's queue popup looked broken, and the
listen-party card on the home page never showed an image. Put together a
mockup first (this app's own theme tokens, not a generic one) to confirm
the redesign direction before touching any code, since the queue side
was a real UI rework, not a one-line fix.

The layout bug: MusicBar.ts's queue rows were reusing views/album's
shared .trackRow (styles/trackList.css) - a CSS grid with 4 fixed
columns sized for THAT page's own row shape (track number, name,
duration, a button). The queue never had a track number, and rendered
completely different content in that same slot, so the track's own name
- the first child - landed in the 28px column meant for a one-or-two-
digit index, crushed to its first couple of characters, while the
artist/album text filled the wide column meant for the name instead.
Fixed with a dedicated .queueRow (flex, not grid): album art, a
truncating name+artist/album/added-by info column, duration on the
right.

The missing images: confirmed directly against the real Emby server
(three sampled tracks, Fields=AlbumId,ImageTags) that an individual
Audio item essentially never carries its own Primary image - every one
tested answered 500, not even a clean 404 - while the same tracks'
parent albums mostly did. Every place that built an image URL from a
raw EmbyItem was independently reconstructing /api/catalog/image/{id}
by hand (CatalogService.toItemDto, and three separate spots in
index.ts's party-creation routes bypassing toItemDto entirely) - all
now funnel through one new CatalogService.imageUrlFor(item), which
points a track at its AlbumId instead of its own id when one's known.
EmbyItem gained AlbumId; every Fields param listing Audio items now
requests it.

While fixing the now-working image, the home page's party card turned
out to be the wrong shape for music too: a fixed 48x72 portrait box,
correct for a movie/show poster, forcing a hard crop on an album's
square cover. Video parties are untouched; a music party's card now
gets a 56x56 square box instead, toggled from LobbyPartyDto.mediaType.

Tests: imageUrlFor (movie/episode unaffected, a track with an AlbumId
falls back to it, a track with neither still falls back to its own id
rather than a broken URL). Full typecheck/test/build clean on both
projects (118/118 server tests). Verified visually against the actual
compiled app.css (not just the mockup) - injected real markup using the
new classes into a running instance and confirmed the queue rows and
both card shapes render as designed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 10:34:39 +02:00
939beb6fa9 Add a spinner to every loading state
All checks were successful
Publier l'image Docker / test (push) Successful in 21s
Publier l'image Docker / publish (push) Successful in 7s
Every "Chargement..." spot across the app was plain text with no
visual motion - easy to mistake a still-fetching page for a stuck one.

New .spinner in styles/base.css - a plain CSS ring (border +
border-top-color, no image/icon-font asset), sized and vertically
aligned to drop into an existing text node inline instead of needing
a flex-row restructure. prefers-reduced-motion turns the spin off.

New client/src/loading.ts exports one shared LOADING_HINT constant so
every dynamic call site (Album/Artist/Library/Playlist/Series's own
route entry, Home's per-library carousels, a season switch reloading
its episode list, the player's route-level load) renders identical
markup. Shell.html's sidebar library list and Library.html's #grid
placeholder (both static templates, no JS import) got the same
markup pasted directly. Player.html's #loadingNotice (video
buffering) got the spinner too.

Left alone: a couple of static templates' own inert copies of this
same placeholder text (Playlist.html, Series.html) that are always
synchronously overwritten before ever painting - nothing a viewer
could ever actually see differently. Also left alone: the brief
button-label loading states elsewhere ("Ajout...", "Démarrage...") -
a different, smaller UI element, typically resolved within one REST
round trip.

Verified: full typecheck/build on the client (no server changes at
all). Visually confirmed in a real browser that the spinner renders
correctly inline with text and is genuinely animating.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 23:30:25 +02:00
5be839d2f6 Music bar: refresh vote count on join/leave, progress bar; catalog search
All checks were successful
Publier l'image Docker / test (push) Successful in 23s
Publier l'image Docker / publish (push) Successful in 13s
Four smaller fixes from real usage:

- MusicBar.ts: "votes needed" for skip depends on the connected-member
  count (renderMembers/"members") AND the vote tally (applyMusicState/
  "queueUpdated") - the label was only ever recomputed from the latter,
  so a plain join/leave with no vote left it stale. Cached the last
  MusicPublicState and factored the label math into its own
  updateVoteSkipLabel(), called from both handlers now.
- Home.ts: the listen party this tab is already in still showed
  "Rejoindre" on the home page. Checks each card's partyId against
  musicBar/MusicBar.ts's own getActiveMusicPartyId() (already tracked
  client-side) and swaps the button for an inert "En écoute" label on
  that one card. No equivalent case for video - its player is a
  full-screen takeover, so you can't be looking at this list from inside
  one.
- MusicBar.html/.css/.ts: a read-only progress bar (a thin strip along
  the bar's own top edge). Deliberately not seekable - no host to
  arbitrate a jump in this model, and MusicQueueService has no "seek"
  concept server-side today; that would be a real protocol addition, out
  of scope here. Fill width is just currentTime/duration, ticked by the
  same interval that already updates the position text.
- tiles.ts (new wireSearchFilter), views/library, views/artist,
  views/album: a search input per catalog page, filtering whatever's
  already rendered client-side (a library page tops out at a few hundred
  items - well within "just filter the DOM" territory, no server round
  trip per keystroke). Shared helper so the three pages behave
  identically. The existing /api/catalog/search endpoint (video types
  only, never wired into any UI) is a separate, unrelated feature - a
  global cross-library search - left as-is.

Verified against the real compiled bundle: a "members" push alone
updates the skip label; the home page's own party shows "En écoute" while
others still show "Rejoindre"; the progress fill tracks a simulated
currentTime correctly; typing into a library's search box filters tiles
live with the empty-state notice appearing only when nothing matches.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 16:44:23 +02:00
a5aca4a823 Fix home page's party "elapsed" label ignoring pauses
All checks were successful
Publier l'image Docker / test (push) Successful in 24s
Publier l'image Docker / publish (push) Successful in 15s
Reported: the time shown for an ongoing party is the movie's elapsed
time. True, but only by coincidence - the label was actually just
Date.now() - party.createdUtc, which climbs at wall-clock speed
regardless of whether the movie is actually playing. Pause it for five
minutes and the home page keeps counting through the pause the player
itself has frozen.

- server/src/types.ts, client/src/types.ts: LobbyPartyDto gains
  positionTicks/isPaused/positionUpdatedUtc - the host's own tracked
  playback state (same values SocketGateway already maintains for their
  party's own room), null for music parties, where there's no single
  "elapsed" across a rotating queue of tracks.
- server/src/realtime/SocketGateway.ts: lobbySnapshot() fills these from
  the host's member record for video parties. Two new broadcastLobby()
  trigger points to keep it reasonably fresh: an actual pause/play
  transition (not every 3s heartbeat resend - same guard already used
  for that transition's log line) and advancing the queue (advanceQueue
  already reset every member's position for the new item; nothing had
  ever pushed that to the lobby before, so a card's title/poster/position
  could sit stale until an unrelated join/leave refreshed it).
- client/src/views/home/Home.ts: updateElapsedLabels() now computes a
  live, pause-aware position for a video party (frozen while paused,
  ticking from position+timestamp while playing) instead of always
  falling back to createdUtc - which is still exactly what a music party
  (or a party missing these fields) uses.
- server/test/socketGateway.test.ts: two new cases - the lobby snapshot's
  video-vs-music shape, and that a transition/next() refreshes it while
  an unchanged heartbeat resend doesn't.

Verified against the real bundled client with a faked "parties" push:
a paused party's label held still across a real wait; a playing one, and
a music party on the unchanged createdUtc path, both kept ticking up.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 13:53:10 +02:00
4a31ee741e Rewrite server + client in TypeScript, service-oriented server architecture
All checks were successful
Publier l'image Docker / publish (push) Successful in 18s
Full rewrite, requested directly. Two independent decisions since
TypeScript can't compile itself away at runtime - "no build step" and
"TypeScript" are mutually exclusive, not a small addition to the existing
setup:
- Server: plain tsc (no bundling need - Node resolves its own requires).
  server/dist mirrors server/src 1:1.
- Client: esbuild (does need bundling) - picked over a heavier tool
  (Vite, webpack) since this app's whole client is a couple thousand
  lines; its built-in CSS-from-JS bundling and a `text` loader for
  importing an HTML template as a string cover everything needed via a
  ~30-line hand-written build script (client/build.mjs), not a config
  file for something built for much bigger apps.
Both projects compile independently (no shared-types package - considered
and rejected as more structural commitment than this app's size
justifies; server/src/types.ts and client/src/types.ts have their own
overlapping DTO copies instead). server/'s `npm run dev` uses tsx for a
compile-free dev loop; `npm run build` (tsc) is what server/Dockerfile's
build stage actually runs. client/'s `npm run watch` is esbuild's own
watch mode.

Server: one singleton service class per feature, generalizing the
PartyManager/MusicQueueService pattern that already existed, rather than
route handlers with logic inline:
- AuthService (from embyAuth.js), CatalogService (NEW - previously ~15
  route handlers' worth of Emby queries/DTO shaping inlined directly in
  index.js), PartyService (renamed from PartyManager), MusicQueueService
  (ported as-is, already service-shaped), SocketGateway (from
  socketHandler.js), EmbyClient (from embyProxy.js - low-level plumbing,
  not called a "service" since it has no state/feature of its own).
- index.ts is now thin wiring only: every route reads the session, calls
  one service method, returns the result.
- Real error-handling pass, not just a port: Express 4 doesn't catch a
  rejected Promise from an async handler on its own - a new asyncRoute()
  wrapper + a single error-handling middleware replace what used to be
  zero handling for a request that outright throws. CatalogService
  methods throw a CatalogFetchError (own HTTP status - getRawItem now
  preserves Emby's real 404 instead of a blanket 502) rather than
  touching res directly.

Client: one folder per view (src/views/<name>/<Name>.ts + .html + .css)
instead of one .js file with the markup inline as a template literal. A
view's static shell moved to its .html (esbuild's text loader); per-item
dynamic content (tile grids, episode/track lists) stays as template
-literal-returning functions in the .ts, same as before. Genuinely shared
CSS (tiles, the presence avatar, the series/album detail-header pattern,
track-list rows) lives under src/styles/, imported by whichever views
need it; src/styles/base.css holds the global primitives, imported once
by app.ts. Deliberately not pushed to zero duplication/renaming shared
classes across views for tidiness alone - not worth the risk in a change
already this large. Player.ts keeps its pre-existing internal shape (the
/party/:id dispatcher + the video renderer in one file, importing
renderMusicPlayer from the sibling folder) - that pattern predates this
rewrite and only needed types, not restructuring.

Two real things the port itself caught, not just carried over:
- A type bug: porting SocketGateway's "members" broadcast, reusing
  PartyService's PublicMember interface would have type-checked fine but
  was the wrong (larger) shape - the socket broadcast never included
  isPaused/positionTicks, only the REST response did. Caught by
  comparing against the original JS line by line, fixed with a dedicated
  SocketMemberDto type.
- A real Express+TypeScript friction point: noUncheckedIndexedAccess
  (enabled in both tsconfigs - genuinely useful elsewhere, e.g.
  PartyService.advanceQueue's array indexing) makes every req.params.xxx
  read a type error, since Express's index-signature typing doesn't know
  a matched route's required :name segment is always present. Resolved
  once with a param(req, name) helper rather than 15 routes' worth of
  scattered assertions.

Verified: both projects type-check cleanly in strict mode (tsc --noEmit -
not just "esbuild didn't complain", which it wouldn't even with type
errors present, since it transpiles without checking). Server: every
unit/integration test from the JS version (party lifecycle logging, the
music queue's timer/vote/pause behavior incl. real setTimeout
auto-advance, the socket integration through SocketGateway+PartyService+
MusicQueueService together, the library-type filter, the playback
-override forcing) re-run against the compiled server/dist output -
identical results; the compiled server was actually started and confirmed
listening cleanly. Client: since esbuild bundles everything into one
app.js (no more individually-importable view modules), testing moved to
driving the real compiled bundle through its actual boot -> login ->
router -> view chain (mocked fetch/Socket.IO/hls.js, real clicks/hash
navigation) - arguably more realistic than the old per-module approach.
Confirmed this way: login, the sidebar, home's carousels, a video party
(presence, host controls, leave), the full music hierarchy, a music party
(presence, vote-skip, pause toggle, the inline browse-and-add panel), and
the series page - all through client/dist's actual output, no thrown
errors anywhere.

Not verified: an actual `docker build` of the new multi-stage Dockerfile -
this dev environment has no Docker at all. The build stage runs the exact
npm run build commands already confirmed working locally, just inside
node:20-alpine - the main remaining risk is something environment
-specific, not the build commands themselves, but a real build is still
the first thing to try after this lands.

docs/architecture.md has the full writeup (new "TypeScript and a
service-oriented architecture" section, plus a corrected top-level
"Components" overview that had gone stale even before this change).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 09:38:14 +02:00
Renamed from client/js/views/home.js (Browse further)