.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>
.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>
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>
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>
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>
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>
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>
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>
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>
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>
Asked for directly: Emby's admin "now playing" dashboard should see
this app's streams the same way it sees a real web/mobile/TV client's.
This is exactly the follow-up "Sync watch-party progress back to Emby"
already anticipated and deliberately deferred at the time ("wiring
clean start/stop into every transition... is real complexity for a
secondary payoff") - that section also explains why this app abandoned
Emby's session-shaped PlayingItems endpoints entirely in favor of
direct UserData writes. Given that history (two separate rounds of a
wrong-but-schema-plausible request shape for that other, now-abandoned
endpoint family), this one was built checking the real API first
rather than assuming.
Confirmed against the real server's own /openapi.json that the actual
session-lifecycle family is POST /Sessions/Playing (start),
/Sessions/Playing/Progress, and /Sessions/Playing/Stopped - distinct
from PlayingItems. None of the three schemas declare a required list
at all, which - given the neighboring history - was read as "don't
trust that": ItemId/MediaSourceId/PlaySessionId/PositionTicks are sent
on every call anyway, matching what a real client obviously would,
rather than risking a repeat of "technically matches the schema, still
400s".
One real Emby session per PARTY MEMBER, not one per party - a faithful
copy of how a real client behaves, and the actual point of "let admin
see who's watching what" would be lost by collapsing everyone onto one
shared entry. New server/src/services/EmbySessionService.ts mirrors
PlaystateService's own best-effort shape (every failure caught and
logged, never thrown) but stays its own class - PlaystateService's own
opening comment is specifically about why it doesn't use a
session-shaped endpoint, so folding this into it would muddy that
exact distinction.
Getting a MediaSourceId/PlaySessionId per member needed a new
CatalogService.negotiatePlaybackSession - a fresh PlaybackInfo
negotiation under that specific member's own account, since Emby scopes
PlaybackInfo per user and PartyStreamService's existing shared
negotiation is never guaranteed valid for reporting under everyone
else's account too. Returns null on any failure rather than throwing -
this only ever feeds an admin-dashboard nicety, never something that
should disrupt real playback.
Driven entirely from index.ts's existing periodic sweep rather than new
hooks scattered across every queue-advance/track-skip call site -
that sweep already visits every connected member on every ~20s tick,
already positioned to notice a member's current item no longer matches
their cached session and react (stop the old one if any, negotiate
fresh ids, start the new one; same item as last tick just sends
Progress with the cached ids). PartyMember gained the nullable
embySession field this tracks. Explicit stop calls also happen at
PartyService.leave() (alongside the existing CastTokenService
.revokeForMember) and removeIdleParties (a backstop for a disconnect
that never triggered a clean leave).
Verified: full typecheck/test/build (134/134 server tests - new
coverage for negotiatePlaybackSession's null-on-any-failure contract,
every EmbySessionService method's exact request shape, that a non-ok
response/thrown error is swallowed not propagated, and that leave()
calls stop with a member's real cached session info but only when they
actually had one open). Not verified: an actual live write against the
real server - deliberately not attempted, since that would mean using
a real account's Emby token for a genuine state-changing call, beyond
the read-only diagnostic use this session's shared API key was ever
meant for. So while the request shape is checked against the real
schema and the per-member logic is unit-tested in full, whether Emby's
dashboard actually displays these sessions the way a native client's
would - correct device/client labeling in particular, since this app
never sends the X-Emby-Authorization Device/Client/Version header a
real client would, only X-Emby-Token - is genuinely unconfirmed. Worth
watching the real dashboard the first time a party runs after this ships.
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>
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>
Asked for directly: the auto-hide behavior itself (bottom bar collapses
to just its seek line after a stretch of no activity) already shipped
earlier - a mouse cursor left sitting over the video afterward is the
same kind of clutter that was the point of hiding in the first place.
.playerWrap now gets a .cursorHidden class alongside #bottomBar's own
.minimal, toggled at the exact same two call sites
(scheduleControlsHide/showControls) so the two states can never drift
out of sync with each other. cursor: none on that class does the rest -
no new listener needed, since playerWrap's own pointermove/pointerdown
already call showControls() on any mouse activity, which is exactly
when the cursor should reappear too.
Verified: typecheck + build (client). Confirmed the compiled bundle
carries both the classList calls and the CSS rule. Not verified in an
actual running player - no Emby login reachable from this sandbox to
open a real video party and let the auto-hide timer elapse.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Reported right after the previous PR merged: "the catalogs are broken,
not all the data is there". Confirmed directly against the real Emby
server, not guessed - this app's own "Musique" library has 1229
distinct artists and its "Films" library has 421 movies. getArtists
gained a 300-item Limit in that same previous PR (meant defensively,
alongside the render-side fix below); getLibraryItems already had one,
apparently written before this - both were silently dropping the tail
of the list with no error and no visible sign anything was missing,
which is exactly what "some of the data is gone" looks like from the
outside.
Removed both. Confirmed empirically that omitting Limit entirely makes
Emby return everything (TotalRecordCount matched Items.length exactly
for both the 421-item and 1229-item cases, no server-side default cap
kicking in) - there was never a reason for either app-side number
beyond guarding the render path, and that guard already exists
elsewhere: tiles.ts's renderTilesInChunks (the previous PR's fix for
"the front freezes while it loads") inserts any size of list in small
batches regardless of how large it is.
While in there: renderTilesInChunks itself was scheduling its next
chunk with requestAnimationFrame, which browsers deliberately pause for
a backgrounded/hidden tab - completely ordinary to hit in practice
(switching tabs while a 1229-item list is still loading, which now
takes meaningfully longer than the 300-item case this was written and
tested against) and would have stalled the render indefinitely,
presenting as the exact same "data missing" symptom for a different
reason. Switched to setTimeout(fn, 0), which keeps running (only
throttled, never paused) in a background tab.
Verified: full typecheck/test/build on both projects (122/122 server
tests). The two real counts above (421, 1229) and the "no Limit means
Emby returns everything" behavior were both confirmed with live,
read-only requests against the real server - not assumed. Not
verified: an actual browser render of either list end to end (no Emby
login reachable from this sandbox), though the render mechanism itself
was already verified separately in the previous PR and is unchanged
here beyond the scheduling primitive.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Discovered while opening the first-ever PR against this repo (previous
sessions always pushed straight to master): publish-docker.yml only
ever triggered on push (branches: [master]) or a tag - there was no
pull_request trigger at all, so a PR sat with zero CI signal until
whatever merged it also happened to push to master for the first time.
Added pull_request (branches: [master]) alongside the existing
triggers. The publish job needed its own guard for this to be safe -
it has no event-type check today, just needs: test, so without one, a
PR's own CI run would have gone on to build and push a real Docker
image (latest included) to the production registry from
unreviewed, unmerged code. if: github.event_name != 'pull_request'
keeps a PR run to the test job alone; push/tag/workflow_dispatch are
unaffected.
Note: a pull_request trigger only takes effect once it exists on the
BASE branch (master) - GitHub/Forgejo Actions won't run a trigger a PR
branch merely adds to its own copy of the workflow, for the obvious
reason (a PR from anywhere could otherwise grant itself a run with
repo secrets). So this doesn't give the PR carrying it its own CI run -
only the next one, once this merges. Verified by reading the existing
workflow and the job dependency it already had (needs: test with no
event guard); not verified against a real PR run (can't be, until this
lands on master) - flagged the same way this project already flags
what it can't check from here.
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 again after the castToken-propagation fix: "still doesn't
work, the stream doesn't play on the TV side." That fix was real, but
addressed what would break after the first segment - this was more
fundamental, blocking things from the very first request.
The Default Media Receiver plays this app's HLS through an MSE-based
player (Chromecast's Chromium build has no native HLS support), which
fetches every segment via fetch/XHR from whatever origin Google hosts
the receiver at - cross-origin from this server. This app never sent a
single CORS header anywhere; every consumer until now was its own
same-origin page. Without one, the browser blocks every one of those
reads outright, before this app's own cookie/castToken check even runs
- the master playlist request itself included.
Fixed with a small allowCastCors middleware on the same 4 streaming
routes sessionForStream already covers - Access-Control-Allow-Origin:
*, plus handling OPTIONS for the same 4 paths (Range, needed for
segment fetches, isn't CORS-safelisted, so a real cross-origin request
carrying one triggers a preflight this app had no answer for at all).
`*` is safe here specifically because these routes never need
credentialed cross-origin access: a real browser already reaches them
same-origin, and the one genuine cross-origin caller authenticates via
the castToken query param, never a cookie.
Verified: full typecheck/test/build (122/122). Confirmed against the
real running server that an OPTIONS preflight now answers 204 with the
right headers, a real GET still carries Access-Control-Allow-Origin
regardless of outcome, and an unrelated route carries no such header -
scoped to exactly the 4 routes a Chromecast needs. Not verified:
whether this is sufficient for a real receiver to actually play the
stream end to end - no device reachable from this sandbox.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Two bugs reported together, both surfaced by actually trying this for
real.
Casting: "the .m3u8 needs to be passed instead of a raw [url]". The
master playlist itself was fine - the initial master.m3u8?castToken=...
request already carries the token. What broke immediately after: every
URI line inside that playlist gets rewritten (EmbyClient.rewritePlaylist)
to route back through this app's own party-stream/:partyId/raw?u=...
proxy, and that rewrite never carried the castToken forward. A browser
never noticed (it's still sending its cookie regardless of the URL), but
a Chromecast has no cookie to fall back on, so every segment/variant-
playlist fetch after the first one 401'd, breaking playback moments
after the receiver appeared to connect. Fixed by threading the same
castToken into every rewritten line (rewritePlaylist(text, routeKey,
castToken?), sourced from req.query.castToken right inside
proxyResponse). The self-hosted burn-in path had the identical gap for
a different reason - ffmpeg's own bare relative segment filenames drop
any query string when resolved against a URL that had one, per ordinary
URL-resolution rules - fixed the same way with a new pure
appendCastTokenToSegments(text, castToken), extracted specifically so
it's unit-testable without a real ffmpeg session.
Player toolbar: "still doesn't work, mobile and PC" - the auto-hide fix
from two commits ago hadn't actually landed for anyone. Real bug, not a
browser quirk this time: becomeHost's 3-second heartbeat got wired to
the same onDriverStateChanged function real discrete play/pause/seeked
events use, which also re-arms the auto-hide countdown - correct for a
genuine event, wrong for a heartbeat that fires unconditionally every
3s regardless of any real change. With the hide delay also at 3000ms,
every heartbeat tick cancelled and restarted the countdown on the exact
cadence it needed to run uninterrupted to ever complete - so for a
host, on any platform, the bar could never actually finish hiding.
Fixed by giving the heartbeat its own function, reportHeartbeat - same
state sync, without the scheduleControlsHide call.
Verified: full typecheck/test/build (122/122 server tests, including
new coverage for rewritePlaylist's token propagation - had none at all
before this - and appendCastTokenToSegments). Confirmed the heartbeat
fix directly: 3 simulated heartbeat ticks call the hide-timer's reset
zero times, one simulated real event still calls it once. Not verified:
a real Chromecast fetching real rewritten segment URLs - no device
reachable from this sandbox.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Asked for directly: alongside "Lancer cette playlist" (always from the
first item), a way to start a playlist party from any chosen item.
views/playlist/Playlist.ts's own top comment used to rule this out,
written back when the page was deliberately read-only - the stated
reason was that a playlist item carries no back-reference to whichever
playlist it came from, unlike an Episode's own SeriesId, so "start from
item N, queue the rest" wasn't reconstructable the way views/series
does it for episodes.
That reasoning was correct for a bare item id on its own, but doesn't
actually block this exact page: renderPlaylist already fetches the
entire, ordered item list just to render it, so "item N of playlist P"
only ever needed playlist P's own already-in-hand list, never a
back-reference on the item itself. views/library's tiles and
views/album's per-track rows still can't do this - neither has that
ordered list around - which is why the feature lives only here.
createParty(itemId, startAtItemId?) gained the optional second
argument, threaded through POST /api/parties into
createPartyFromPlaylist (index.ts) - meaningless for every other item
type, so every other call site keeps working with just one argument.
createPartyFromPlaylist resolves it against the playlist's own
(post-splitPlaylistByMediaType) item list and slices from there, the
same "slice, don't wrap" shape the single-item path already uses for
SeriesId-based episode starts. Everything before the chosen item is
simply left out of the party, not queued for later - true for both
video and music.
Each row in the playlist page gets its own "Lire à partir d'ici"
button now, mirroring views/series' own per-episode button (reuses
.episodeRow's existing styling/responsive wrap, no new CSS). Both it
and the existing "Lancer cette playlist" button funnel through one
shared launch() helper.
Verified: full typecheck/test/build on both projects (118/118 server
tests - no dedicated test added for createPartyFromPlaylist itself,
consistent with this project's existing boundary that index.ts's route
glue isn't unit-tested, only the services it calls into are). Confirmed
in a real browser against the compiled bundle that the new per-row
button renders correctly at both desktop and phone width.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>