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>
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>
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>
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>
Three separate reports handled together.
Cast was video-only by design (docs/architecture.md flagged it as
video-parties-only for v1) - asked for directly, and turned out to need
almost nothing new server-side: CastTokenService and the 4 streaming
routes' sessionForStream fallback were already keyed on nothing more
specific than partyId. cast.ts's localDriver only needed its parameter
widened from HTMLVideoElement to HTMLMediaElement - both already share
the same currentTime/paused/duration/play()/pause() surface. MusicBar.ts
gained the same activeDriver indirection Player.ts already has, with one
real difference from the video case: a listen party's queue advances
far more often than a video party changes items, so dropping the cast
on every track (the video path's own choice there) would have made this
worse than not having it - a track change while casting instead calls
startCast() again, reusing the already-connected session (a
cache-busting query param added since the content URL itself never
changes between tracks - the server alone decides what's current).
Falls back to local playback if a re-cast throws, same recovery
instinct loadTrack's own hls.js try/catch already has.
The queue popup: reported separately, a fixed 480px width and a header
that scrolled away with the list - both leftover CSS from before the
row shape was reworked, never revisited since. Width cap dropped;
overflow/max-height moved off the outer .musicBarQueue (which wraps the
header too) down onto #queueList alone.
The catalog: still too slow, the front freezing while it loads.
CatalogService.getLibraryItems was asking Emby for Overview on every
one of up to 300 items and never using it (views/library's tiles only
ever show a poster/name/year) - dropped. getArtists had no Limit at
all, unlike every sibling query. Separately, tiles.ts's shared render
path built one HTML string for a whole batch and inserted it via a
single innerHTML write - fine for a 16-item carousel, a real hitch for
a 300-tile grid. renderTilesInChunks inserts the same tileHtml output
40 at a time across requestAnimationFrame callbacks instead;
wireTileButtons switched to one delegated listener so a tile from a
later chunk is still clickable.
Verified: full typecheck/test/build on both projects (122/122 server
tests). The chunked-render mechanism was verified directly - a
synthetic 300-item list against a real DOM confirms every item lands
and a button in the last chunk still fires through the delegated
listener. Not verified: no real Chromecast or Emby login reachable from
this sandbox, so the actual on-TV re-cast round trip and the
before/after catalog latency against a real large library are both
unconfirmed - same standing gap this project already flags for its
other casting work.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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>
Asked for directly: exactly one stream should ever be open between this
app and Emby per party, no matter how many members it has. Before this,
every viewer's own <video>/<audio> independently negotiated its own
PlaybackInfo and got its own PlaySessionId - a party of 5 meant Emby
transcoding the same title 5 separate times.
A bigger, higher-regression-risk change than most of this session's
other work: it replaces the core playback path every existing party
already relies on (CatalogService.proxyStream and its route are gone
entirely) rather than adding something alongside it - there's no other
consumer of the per-viewer path, and the ask was explicit that there
should only ever be one mechanism.
- server/src/services/CatalogService.ts: proxyStream's negotiate-then-
build-a-target logic extracted into negotiateStreamTarget(session,
itemId, isAudio, audioStreamIndex, maxStreamingBitrate) - identical
logic, returns the target instead of proxying it directly so it can
be cached. proxyStream/proxyRawStreamAsset removed.
- server/src/services/PartyStreamService.ts (new): one negotiated
target per party.id, reused by every member; renegotiates when the
desired item/audio/quality changes. Recovers from a dead upstream
session with no new plumbing - EmbyClient.proxyResponse already sets
res.statusCode before writing the body, so a failed proxied response
is detected right after it resolves and the cache entry is dropped,
so the next request (a manual retry, or the next segment fetch)
renegotiates fresh instead of repeating the same dead target forever.
- server/src/emby/EmbyClient.ts: rewritePlaylist/proxyResponse's
trailing itemId param genericized to routeKey - it's only ever read
back to build the raw-asset URL, so it's party.id for the new stream
routes and an inert real itemId for proxyImage/proxySubtitle (which
never reach that branch anyway).
- server/src/index.ts: GET /api/catalog/party-stream/:partyId/
master.m3u8 and .../raw replace /api/catalog/stream/:itemId/...
entirely, membership-checked like the burn-in routes; a new idle-reap
interval for PartyStreamService, same shape as TranscodeService's own.
- Forced consequence of "one stream", not a free design choice: audio
track/quality become a host-only, party-wide choice for video -
Party.streamAudioIndex/streamQuality, setStreamOptions/
streamOptionsChanged, byte-for-byte the same shape
setBurnInSubtitle/burnInChanged already established for burn-in
subtitles. server/src/realtime/SocketGateway.ts gains the host-only,
video-only handler; server/src/services/PartyService.ts resets
streamAudioIndex (not streamQuality - a bandwidth preference isn't
item-specific) on advanceQueue, same reasoning as burnIn* already
gets; MusicQueueService.playNext ends the party's cached session on
a track change.
- client/src/api/embyApi.ts: streamUrl/audioStreamUrl replaced by one
partyStreamUrl(partyId) - no query params needed any more, the party
itself is the source of truth for what it's streaming.
- client/src/api/party.ts: setStreamOptions/onStreamOptionsChanged.
- client/src/views/player/Player.ts: audio/quality <select>s disabled
for a follower (applyStreamOptionsGating, mirroring
renderSubtitleOptions' own gating), restore the party's current
choice on load/broadcast, and a host's change calls setStreamOptions
instead of reloading locally - every member (host included) only
ever reacts to the resulting broadcast, same discipline the rest of
this player already uses. Dead reloadWithCurrentOptions removed.
- client/src/musicBar/MusicBar.ts: loadTrack points at
partyStreamUrl(activePartyId) - no picker to gate, MusicBar never had
one.
- server/test/catalogService.test.ts: proxyStream's old cases moved to
exercise negotiateStreamTarget instead, plus new quality-override and
no-source-fails cases. server/test/partyStreamService.test.ts (new):
cache hit/miss/param-change/item-change, the failure-invalidation
path, endSession, idle reap. server/test/socketGateway.test.ts:
setStreamOptions host-only gating + broadcast, mirroring the existing
setBurnInSubtitle tests.
- docs/architecture.md, README.md: design, the forced audio/quality
trade-off spelled out, and what's still unverified (a different kind
than this session's other Emby-API guesses - the calls themselves
were already live code, what's untested is real multi-viewer load).
Verified: full typecheck+test+build on both projects (85/85 server
tests, 12 new). A real-browser check (mocked-fetch + real bundle, this
session's standing harness) confirmed a follower's audio/quality
selects render disabled and a host's render enabled, from the same
party data with only youAreHost flipped.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Asked for directly: "same account, same device already in the party"
should never need a confirmation, "same account, a genuinely different
device" should ask before disconnecting it instead of silently
overwriting. There was no concept of "device" at all before this -
party.members is keyed by userId only.
- client/src/deviceId.ts (new): a UUID kept in localStorage (not
sessionStorage) - that's what makes "device" mean "browser", not
"tab". Falls back to an in-memory id if storage is unavailable.
- server/src/types.ts, client/src/api/party.ts: "join" now carries
{partyId, deviceId, force?} instead of just {partyId}; new
PartyMember.deviceId and "deviceConflict" server->client event (sent
only to the joining socket, never broadcast).
- server/src/realtime/SocketGateway.ts: the "join" handler now tells
three cases apart - no existing connection or the same deviceId
reconnecting (attach silently, booting any stale connection for that
same device first), a different deviceId with no force (emit
deviceConflict and stop - the socket stays connected but inert), or
force:true (the confirmed takeover - boot the old connection, take
the slot).
- server/src/services/PartyService.ts: join() is now idempotent -
returns the existing member instead of always building a fresh one.
This was itself a real bug independent of the guard below: the REST
"Rejoindre" route calls this every time regardless of membership, so
a re-click/reload/retry used to silently reset connection/isPaused/
deviceId.
- server/src/realtime/SocketGateway.ts: "leave"/"disconnect" now also
check party.members.get(userId)?.connection === socket before acting
- generalizes the earlier stale-connection crash fix to the new
reality where a member can have more than one connection attempt in
flight (the superseded one's own eventual "disconnect" must not be
read as a real leave).
- client/src/views/player/Player.ts, client/src/musicBar/MusicBar.ts:
onDeviceConflict wired to a plain confirm() - yes calls the new
PartyConnection.confirmDeviceTakeover() (re-sends "join" with
force:true), no backs out (navigate home for video, leaveMusicParty
for music).
- Deliberately does NOT change the home page's live party list itself
(still just "Rejoindre" either way) - LobbyPartyDto is one snapshot
shared by the whole "lobby" room with no per-viewer membership info,
and giving it that is a real protocol change on its own. For video
the conflict check above already is the meaningful moment (you can't
be looking at the home page from inside your own party on the same
tab - the player is a full-screen takeover), so this doesn't cost
the feature much: Rejoindre reaches the check immediately, same
device reconnects silently, a different device asks first. See
docs/architecture.md for the fuller reasoning.
- server/test/socketGateway.test.ts, server/test/partyService.test.ts:
new tests for the conflict/takeover/reconnect paths and join()'s
idempotency; the earlier stale-connection crash regression test
updated to reach the same shared-slot shape through a real device
takeover instead of a raw PartyService.join() call (which no longer
reproduces it now that join() is idempotent).
Verified: full typecheck+test+build on both projects (39/39 server
tests passing); confirmed the "leave"/"disconnect" guard is load-
bearing by temporarily reverting it and watching the new takeover test
fail, then restoring it; deviceId.ts's localStorage persistence
checked against the real compiled bundle in a real browser (survives
a reload, stable across calls).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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>
Reported: selecting a subtitle track (only subtitles, not audio/quality)
made the request "time out". Confirmed from the browser's own error:
hls.js gave up with a fatal fragLoadTimeOut after 4 retries, each hitting
its default 10s fragment-load timeout - not a hang, and not a repeat of
the earlier "override doesn't reach Emby" bug (see docs/architecture.md's
Player-track-selection section) - the same error's frag.baseurl already
shows SubtitleMethod=Encode correctly present in the request Emby
received, so the override chain works end to end. It's just slow: burning
a subtitle in forces a real re-encode (not a rendition, not a remux),
which measurably takes Emby longer than 10s to produce a first segment
for.
Fixed by giving both players' Hls instances explicit longer timeouts
(manifestLoadingTimeOut/levelLoadingTimeOut: 30s, fragLoadingTimeOut: 60s)
instead of hls.js's untouched defaults - applied to musicBar/MusicBar.ts
too, not just the video player, since music tracks go through the same
PlaybackInfo/transcode pipeline and could hit the same latency for a
quality change even without subtitles being involved there today.
global.d.ts's HlsStatic constructor gained an optional HlsConfig
parameter (only the four properties actually used) so this doesn't need
an `as any`/unsafe cast to pass config through.
Verified the property names are real and actually honored by hls.js
1.5.17 (the exact pinned CDN build), not silently ignored: constructed a
real Hls instance with this config and read back hls.config.* - all four
values came back as set, not as hls.js's own (tighter) defaults.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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>