Commit graph

7 commits

Author SHA1 Message Date
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
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
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
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
b2310216ce Fix settings menu visibility bug; make the music player a persistent bar
Some checks failed
Publier l'image Docker / publish (push) Has been skipped
Publier l'image Docker / test (push) Failing after 8s
Fix: player settings menu stuck permanently open
----------------------------------------------
.hide { display: none; } and Player.css's .settingsPanel { display: flex;
... } are both single-class selectors - equal specificity - so whichever
one esbuild happened to bundle later into app.css won the `display`
property on an element carrying both classes. Player.css's own
display:flex always landed after base.css's .hide, so #settingsPanel
showed regardless of the hide class ever being toggled - the toggle logic
itself was correct the whole time, it just had no visible effect.

Fixed at the utility class, not the one call site: `.hide` now uses
`display: none !important`, which wins a same-specificity tie regardless
of bundling order - closes off the whole bug class, since several other
components already set their own `display` for a visible state.

Feature: music player is now a persistent bottom bar, not a page
------------------------------------------------------------------
The music player was its own route (/party/:id -> views/musicPlayer/),
which meant navigating anywhere else - the sidebar, an album to queue up
more tracks - tore the whole thing down (router.ts calls a route's
cleanup, which used to call leaveParty, on every hash change). No way to
browse and listen at the same time.

- client/src/musicBar/MusicBar.ts (new): a Shell-level singleton, mounted
  once into a #musicBar element that's a sibling of #mainContent, never
  touched by route changes. Renders as a fixed bottom bar. Exposes
  initMusicBar/joinMusicParty/leaveMusicParty instead of the router's
  render()-returns-cleanup pattern, since it never registers as a route.
- views/player/Player.ts's dispatcher: a music party no longer renders a
  page - it calls joinMusicParty() and bounces back to /home (a no-op for
  every reachable call site except album's). A video party now calls
  leaveMusicParty() first (harmless no-op if nothing's active) - starting
  or joining a video watch party always leaves the listen party, since
  every party-join call site already funnels through this one dispatcher.
- views/album/Album.ts: the last per-track "start a party" UI now that
  the music player's own inline browse panel is gone (browsing just
  means navigating there normally). Each track's button starts a new
  party or adds to whichever one is already active, decided by
  getActiveMusicPartyId() at CLICK time (not render time) so the action
  taken is always correct even if the label is momentarily stale; every
  button is relabeled in place after a successful click. Starting a
  party from here no longer navigates away at all.
- views/musicPlayer/ deleted entirely (MusicPlayer.ts/.html/.css) -
  superseded by musicBar/.
- Comment updates in global.d.ts/presence.css/trackList.css/party.ts/
  types.ts that named the old module by path.

Verified both fixes by rebuilding and driving the real compiled bundle
through a local static server, with fetch/window.io/window.Hls mocked:
starting a party from an album track leaves the sidebar/page in place and
relabels the other tracks; adding a track posts to the already-active
party's id; navigating into a video party hides the bar, disconnects its
socket, and posts a real leave for the music party before the video
player renders; the settings gear opens/closes correctly for the host in
that same run.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 10:43:12 +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