58 commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
| 0bd95b03b9 |
Add an alphabet index to the library page: sticky letter headings + a right-edge A-Z rail
Groups an already-alphabetical library into per-letter sections (accents folded, so an item is bucketed under its base letter) with a sticky heading before each - still one chunked-rendered grid, not a big synchronous write, so a 1000+-item library stays smooth. The right-edge rail jumps to a letter on tap or drag (pointer-capture, same shape as the mobile nav sheet's own drag handle), dims letters with nothing in them, and tracks the header/music-bar's real height instead of a guessed offset so it never sits under either. Stacked on feature/library-sticky-header (Library.css's .libraryHeader, not yet merged) rather than master, per CLAUDE.md's sequential-PRs rule. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> |
|||
| 3fda483688 |
Make the library page header (title + search bar) fully sticky
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> |
|||
| acb45c8a1a |
Merge remote-tracking branch 'origin/master' into feature/toast-notifications
# Conflicts: # README.md # docs/architecture.md |
|||
| afae977b49 |
Make the mobile nav sheet swipe-to-close instead of fighting pull-to-refresh
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> |
|||
| ff664f2c2b |
Replace every blocking alert() with a light toast, fix a mobile grid bug
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> |
|||
| 617a3d0d49 |
Merge remote-tracking branch 'origin/master' into feature/music-ui
# Conflicts: # README.md # docs/architecture.md |
|||
| 2f10a46664 |
Merge remote-tracking branch 'origin/master' into feature/mobile-nav
# Conflicts: # README.md # docs/architecture.md |
|||
| c8655fc7c3 |
Merge remote-tracking branch 'origin/master' into feature/pwa
# Conflicts: # README.md |
|||
| 098f447e57 |
Rework the music UI: circles/squares instead of movie posters, a real now-playing screen
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> |
|||
| 40a897cf1b |
Replace the mobile sidebar with a bottom sheet, grouped by data shape
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> |
|||
| 75a9fef18a |
Make the app installable as a PWA
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> |
|||
| dfd109fc50 |
Merge remote-tracking branch 'origin/master' into feature/picture-in-picture
# Conflicts: # README.md # docs/architecture.md |
|||
| d6a65ce8b7 |
Add picture-in-picture to the video player
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> |
|||
| 62afa88bb7 |
Queue a whole album or an artist's entire discography in one action
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> |
|||
| 7bf8197bfe |
Merge remote-tracking branch 'origin/master' into feature/sticky-search-and-music-seek
# Conflicts: # README.md # docs/architecture.md |
|||
| bc878c8d1c |
Merge remote-tracking branch 'origin/master' into feature/intro-credits-skip
# Conflicts: # README.md # docs/architecture.md |
|||
| fafee359b9 |
Sticky catalog search bar, and let anyone seek within a music track
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> |
|||
| 57bb297bff |
Add skip intro / next episode at credits, via Emby's own detection
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> |
|||
| a01e0fa86f | Merge branch 'master' into fix/player-cursor-hide | |||
| 40e019cd81 | Correct docs/comments left stale by the Limit removal | |||
| 26d32632c7 |
Hide the cursor over the video once the bottom bar auto-hides
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> |
|||
| 05c4611403 |
Fix real data loss: two catalog queries were silently truncating at 300
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> |
|||
| e89a81a3f1 |
Cast a listen party too, and fix a slow/freezing catalog
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> |
|||
| 62b0abd572 |
Fix casting past the first segment, and the toolbar's real auto-hide bug
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> |
|||
| cf82ea5c2d |
Add "Lire à partir d'ici" to playlists
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> |
|||
| 80c6065dfd | fix cast | |||
| e61b302f6e |
Fix the bottom bar's auto-hide never firing on mobile
Reported right after shipping: the bar never actually collapsed on mobile. Classic mobile-web trap: mouseenter/mouseleave on the bar tracked "is the pointer resting on the controls" so hovering could hold it open, but a touch pointer has no real hover - tapping the bar fires a synthetic pointerenter with no pointerleave ever coming after it (no cursor left to lift away). The very first tap anywhere on the bar latched isPointerOverBottomBar permanently true, and the bar could never auto-hide again for the rest of that session. Fixed by checking event.pointerType === 'mouse' in both handlers - touch/pen never even attempt the "hovering holds it open" path, and fall back to the same plain activity-based wake-up (pointerdown/ pointermove on the whole player) every device already has. Verified: full typecheck/build. Reproduced the exact failure directly - dispatched a synthetic touch pointerenter with no matching pointerleave (the real-world case) against the fixed logic and confirmed it still transitions to .minimal on schedule, where the mouseenter-based version would have latched open. A real touchscreen's own event behavior isn't independently reproducible here, so this is the strongest confirmation available without one. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> |
|||
| d3e2811bd0 |
Auto-hide the bottom bar down to just its seek line
Reported right after the native-controls replacement shipped: the bottom bar, now always visible, sits exactly where a native <track> subtitle renders and was masking it. Same fix every real video player already uses: collapse to a minimal state after a stretch of no activity, expand again on the slightest interaction. #bottomBar gets a .minimal class (3s of no activity) that transitions away its padding/background/border and the buttons row's height/ opacity - #progressBar itself (and its own generous invisible seek hit-area) is untouched, so a thin line is always there, just without the box around it once minimal. Expands on any pointer activity anywhere in the player, or while the pointer sits on the bar itself (hovering pauses the countdown entirely rather than racing it). Stays expanded outright while genuinely paused, and every discrete playback event re-arms the countdown too - not just raw pointer movement - so resuming from pause restarts it instead of leaving the bar stuck open. Applies to host and follower alike. Verified: full typecheck/build. Confirmed in a real browser against the compiled bundle, side by side with an actual subtitle-shaped overlay: the expanded bar visibly overlaps it, the minimal state leaves it fully readable. Not driven through a real wall-clock wait for the timer itself - the class-toggling/transition logic was checked directly instead. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> |
|||
| a7944538bc |
Replace native <video controls> with custom transport controls
Reported right after trying the player rework: the action bar looked like two z-index layers with some elements duplicated, only in fullscreen. Root cause: video.controls = true (set for the host since this player's first version) has always rendered the browser's own native control bar - a separate piece of UI in its own always-on-top compositing layer, independent of this app's DOM/CSS. That was never visible before, since fullscreen used to show only the video; once it correctly started showing this app's own overlays too, the native bar and the custom one both became visible, and specifically duplicated - Chrome adds its own cast icon to a <video>'s native controls once it detects a Cast-capable device, entirely separate from #castBtn's own integration. Two fixes: - disableremoteplayback on <video> - the standard attribute telling the browser not to add its own cast icon, since this app has its own. Should have shipped with the original Chromecast feature. - Native <video controls> retired entirely, everywhere (not just fullscreen), replaced with a custom transport bar - asked for directly rather than just suppressing the symptom in fullscreen. PlaybackDriver (cast.ts) gained `duration` alongside currentTime/paused - the one thing a seek bar needs that the existing local/cast abstraction didn't yet expose. #bottomBar gained a seek row (progressBar/ progressFill) above the existing button row - host drags/clicks to seek by fraction of activeDriver.duration (pointer events, mouse and touch share one path); a follower's bar is the same element, read-only. A 500ms ticker keeps the fill/time label moving between discrete events. #playPauseBtn (host-only) and #muteBtn (everyone, local-only - never synced, hidden while casting since the local <video> is silent then) round out the replacement. One function, onDriverStateChanged, now threads through every discrete play/pause/seek event (the local <video>'s native events, and a cast session's RemotePlayerController events) so the button icon, the seek bar, and the party broadcast stay in lockstep regardless of which driver or gesture caused the change. Verified: full typecheck/build. Checked in a real browser against the compiled bundle - injected the new markup for both a playing/unmuted and a paused/muted state, confirmed icons/fill/time label/readOnly cursor all render as designed. Not verified: a real drag-to-seek gesture or real playback - no reachable Emby stream in this sandbox to attach a real <video> source to. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> |
|||
| f5a941525f |
Rework the player's bottom bar and add a real fullscreen toggle
Asked for directly - a UI rework of the video player in both normal and fullscreen mode, with one concrete requirement: a viewer in fullscreen still needs to see the pause/resume request buttons. Built a mockup first and iterated it through feedback before touching any code - it ended up covering three things, not the two first asked for. The actual fullscreen bug, found while looking into the request: this app never had its own fullscreen button - the only way in was the browser's native affordance on the bare <video> itself (double-click, or the host's own native <video controls> fullscreen button). Native per-element fullscreen only renders that element's own subtree, and every overlay - top bar, presence, and critically the request toolbar - is a sibling of <video>, so all of it vanished the instant fullscreen engaged. A follower in fullscreen had no way to reach the host at all. Fix: a new #fullscreenBtn calls playerWrap.requestFullscreen() - the whole player, video AND overlay chrome - never video.requestFullscreen(). The native per-video path is still reachable (nothing removed there - the host keeps native play/pause/seek/volume exactly as before), so a document-level fullscreenchange listener watches for document.fullscreenElement === video specifically and corrects it: exit, then re-enter on playerWrap instead. Bottom bar rework: three previously-independent floating pieces (a centered banner/notice column, a request toolbar cornered bottom-right, an "Épisode suivant" text link in the top bar) become one #bottomBar with three zones - left shows exactly one of #nextBtn (host, now a real accent-green pill with a skip-next icon) or #requestToolbar (follower, now icon + explicit "Demander pause"/"Demander reprise" labels) since the two are mutually exclusive by construction; center is a single #ambientNotice line (casting takes priority over "L'hôte contrôle la lecture" - never both at once); right is the fullscreen toggle, for everyone. The transient request banner stays independent near the top, not folded into the persistent bar, so it still reads as a toast. Also added #castingScreen: a plain icon + "Diffusion sur le Chromecast" covering the video area while casting, instead of leaving the local <video>'s last frame sitting there paused with no explanation. Verified: full typecheck/build on the client (pure markup/CSS/TS, no server changes). Checked in a real browser against the actual compiled app.css/bundle - injected the new markup for the host, follower, and casting views and confirmed each renders as designed; caught and fixed a real sizing bug this way (.fsBtn grew to 32px instead of its declared 30px - a flex item's default min-width:auto let button's own base padding win over the smaller explicit width - fixed with an explicit padding:0). Not verified: the real Fullscreen API transition itself needs a user gesture this sandbox can't exercise - worth trying once deployed. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> |
|||
| 5a1bc8d932 |
Fix the listen-party queue's layout and its missing album art
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>
|
|||
| b4c81d820c |
Cast a party's video to a Chromecast
Implements a detailed spec (Google Cast / Chromecast integration) asking
for a member to cast the party's video to a Chromecast's Default Media
Receiver, with play/pause/seek shown as a remote on the sender's own
screen. The spec's description of this app's architecture (one shared
Emby stream per party, the server proxying 100% of Emby access) was
verified directly against the real code before building anything, since
it named specific existing services/routes as the integration points.
A Chromecast is a new kind of client for the existing party-stream/burn
relay: it can't carry the sender's httpOnly session cookie at all, so it
needed its own narrow way in without weakening "the server holds the
only real Emby token" the rest of the app is built around.
- New CastTokenService (same shape as AuthService: in-memory Map, opaque
crypto.randomUUID() keys, TTL-checked) issues a token scoped to one
{partyId, userId}, standing in for that member's own EmbySession.
New POST/DELETE /api/parties/:partyId/cast-token to mint/revoke one.
- The 4 routes a Chromecast actually needs (party-stream/master.m3u8,
party-stream/raw, burn/master.m3u8, burn/:segment) gained one small
fallback (sessionForStream in index.ts): try the session cookie first,
same as ever, then a castToken query param - either way the result is
the same EmbySession type, so PartyStreamService/TranscodeService/
EmbyClient needed zero changes. The image/subtitle routes stay
cookie-only, outside the spec's own 4-route list.
- PartyService.leave() now revokes whatever cast token the leaving
member held, unconditionally - a token dies the instant its owner
actually leaves the party regardless of TTL or whether the client got
a chance to call the explicit stop-casting route.
- New client/src/cast.ts is the only module touching the CAF Sender SDK
(loaded as a pinned CDN script, same pattern as hls.js/socket.io) -
exposes a small PlaybackDriver interface (currentTime/paused/play/
pause) with two implementations, one over the local <video>, one over
CAF's RemotePlayer/RemotePlayerController. This was the only new
abstraction the feature needed, not a new sync mechanism: Player.ts's
existing sendLocalState/applyRemoteState already read and write "the
player" in one place each, so pointing them at whichever driver is
active carried the host-only gate, drift-tolerance reconciliation, and
3s heartbeat over unchanged. Seek was already "the host's next
broadcast" with no separate event, so seeking on the TV is party-wide
for free; a non-host caster already only ever received state, so
redirecting that to the cast driver reproduces "still dependent on the
host" automatically.
- Cast button in the player's action bar, shown to any member (not just
the host) once cast.framework reports a receiver on the network.
Starting a cast tears down the local hls.js instance; ending one
(user-stopped, or the receiver dropping off on its own) resyncs local
playback via the same loadVideo path an audio/quality change already
uses. A real reload for any other reason (item change, burn-in/
subtitle/stream-options change) simply ends an active cast first,
rather than keeping a live session in sync with every kind of party
transition - not worth the complexity for v1.
Scoped to video parties only - the spec's every concrete reference is
video-specific, and MusicBar.ts has no comparable control surface today.
Tests: CastTokenService (issue/resolve/reissue-invalidates-the-old-one/
revoke/wrong-party-rejection), PartyService.leave()'s new call. Full
typecheck/test/build clean on both projects (115/115 server tests).
Confirmed against the real running server that the 4 streaming routes
still reject a request with no auth exactly as before, and now also
reject an invalid castToken - both a plain 401, no regression. Confirmed
in a real browser that the Cast SDK loads and initializes correctly. Not
verified: no real Chromecast or Emby login credentials in this
environment to take a cast session, or the button's own live behavior,
any further - see docs/architecture.md's "Casting to a Chromecast"
section.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
|||
| 939beb6fa9 |
Add a spinner to every loading state
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>
|
|||
| 8c9852d854 |
Move the request-pause/resume buttons off the subtitle zone
Asked for directly: .requestToolbar (the non-host's "Demander une pause"/"Demander une reprise" buttons) shared .bottomOverlay's centered-bottom column with the request banner and the follower notice - which is also exactly where a native <track> subtitle renders. Real, clickable buttons sitting there for the whole session is a worse problem than the banner (transient, 4s) or the notice (static text) occasionally doing the same. Pulled #requestToolbar out of .bottomOverlay into its own sibling block in Player.html, positioned independently in the bottom-right corner instead (position: absolute; right: 16px; bottom: 20px), and stacked as a column (align-items: flex-end) rather than a row so the two pill buttons never fight a narrow phone screen for width side by side. .bottomOverlay itself is untouched - still centered-bottom, still just the banner and the notice. Verified against the real bundle (mocked-fetch + real dist, this session's standing harness) as a follower at both desktop and phone width: the two buttons render bottom-right, clear of the (still centered) follower notice, at both widths. Client typecheck/build clean. docs/architecture.md updated. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> |
|||
| 4efcd67fb3 |
Sync text subtitle choice too, and fix an empty audio option
Bug report: watch party members still didn't have synced subtitle settings, and the audio/language selector showed an empty choice on first load. Text subtitles were the gap left by the previous "one shared stream per party" change: that made audio/quality/burn-in host-only and party-wide, but a plain text subtitle pick (the common case - burn-in only applies to bitmap PGS/VobSub/DVB tracks) was still each viewer's own local <track> choice. Once people actually watched together, "everyone but me changed the subtitle" read as broken sync. Fixed with a parallel mechanism, not a merge into burnInSubtitleIndex - new Party.textSubtitleIndex, new setTextSubtitle/textSubtitleChanged socket pair, mirroring setBurnInSubtitle/burnInChanged's host-only, request/broadcast/react shape exactly. Kept separate from burn-in (rather than reusing one field for both) to avoid touching the already-proven burn-in path - only one subtitle can show at once, so the two are mutually exclusive, enforced server-side in both directions: a real pick on either clears the other. Clearing one to null/"Aucun" is deliberately not treated as a pick for this purpose, so it doesn't clobber an unrelated choice in the other field. Mechanically the two still work nothing alike: burn-in changes the actual encoded stream (ffmpeg), so it reloads for everyone; a text pick only swaps which native <track> each member already renders locally - no reload, no transcode, unless it's the one clearing an active burn-in, which does still need the reload since the stream itself changes back to the normal shared one. Player.ts's subtitle <select> gating stopped being conditional on burn-in - it's simply host-gated now, matching audio/quality, and its value restores from whichever kind of pick (if any) the party currently has. Second, unrelated bug in the same report: audioSelect.value = streamAudioIndex || '' assigned an empty string whenever no audio track had been picked yet (the common default) - since a real audio-track <option> never has value="", this matched nothing and left the select at selectedIndex=-1, a genuinely empty-looking choice. Fixed at both call sites (loadTrackOptions, onStreamOptionsChanged) by only assigning .value when truthy. Server: SocketGateway tests cover setTextSubtitle's host-only gating, broadcast, music no-op, and mutual exclusivity in both directions; PartyService tests cover textSubtitleIndex through create/ advanceQueue/toPublicState. Full typecheck/test/build green on both projects (91/91 server tests). Real-browser check (mocked-fetch + real bundle) confirmed: a follower's subtitle select renders disabled regardless of burn-in state; a host's select restores the party's current text or burn-in pick on load; and the audio select now lands on a real first option instead of an empty one. Not addressed here (raised in the same report, need more diagnostic detail first): occasional sync losses between clients, and a suspicion that per-user watch-status (Emby PlaystateService sync) might be broken. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> |
|||
| f78b2f1408 |
One shared Emby stream per party, not one per viewer
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> |
|||
| b45a243e59 |
Browse Emby playlists and launch one as a pre-queued party
Queued right after the ffmpeg burn-in work, asked for directly: see
what's inside a playlist, and launch it - pre-queued with the whole
thing, the same "one click, the rest queues itself" shape starting from
an episode already gets for its series.
- server/src/services/CatalogService.ts: VISIBLE_COLLECTION_TYPES gains
'playlists' - Emby's /Views already returns that CollectionType once
the user has any, so it appears in the sidebar through the exact same
code every other library already uses, nothing else needed there. New
getPlaylistItemsRaw/getPlaylistItems (Emby's own /Playlists/{id}/Items,
mirroring getSeriesEpisodes/getAlbumTracks). New exported
splitPlaylistByMediaType: a playlist can mix movies/episodes and songs,
this app's party model can't - decides video vs music from the first
item's type and drops anything that doesn't match (logged when it
does) - pure, unit-tested on its own.
- server/src/index.ts: new GET /api/catalog/playlists/:playlistId/items
route. POST /api/parties gains a sibling early branch for
item.Type === 'Playlist' (a new createPartyFromPlaylist helper,
deliberately NOT merged into the existing single-item path, which
didn't need touching) - builds a video QueueItem[] exactly like the
episode-queue path already does, or seeds MusicQueueService.init +
addTrack per track for a music result, the same mechanism
POST /api/parties/:id/tracks already uses.
- client/src/tiles.ts: one more DRILL_DOWN entry (Playlist -> its own
page) - a playlist was never directly streamable, same as
Series/MusicArtist/MusicAlbum before it.
- client/src/api/embyApi.ts: getPlaylistItems(). No new API for
*launching* one - createParty(playlistId) already does everything,
same as clicking any other item; the server decides what it means.
- client/src/views/playlist/Playlist.{ts,html,css} (new): a playlist's
contents, read-only past the one "Lancer cette playlist" button -
deliberately no per-item start/add the way album tracks or episode
rows get (an Emby item carries no back-reference to "which playlist
contains me" the way an Episode's SeriesId does, so "start from item
N, queue the rest of the playlist" isn't cheaply reconstructable the
same way - not asked for here anyway). The launch button branches on
the created party's mediaType, same check Player.ts's own dispatcher
already makes.
- client/src/app.ts: registers /playlist/:id.
- server/test/catalogService.test.ts: the new methods' target
URL/mapping, plus splitPlaylistByMediaType's own tests (homogeneous
video, homogeneous music, mixed, empty).
- docs/architecture.md, README.md: design + the scope decision (no
per-item actions) spelled out.
Verified: full typecheck+test+build on both projects (62/62 server
tests). The new page driven through the same mocked-fetch + real-bundle
harness used throughout this session, in a real browser: header/meta/
item list render correctly from mocked catalog data (including the
sidebar showing "Playlists" once a mocked library declares that
CollectionType), and clicking "Lancer cette playlist" reaches
POST /api/parties with the right id and correctly routes into the video
player for a video result. Actual playback past that point still needs
a real Emby server - same standing caveat as the rest of this app.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
|||
| 26da8fc5dc |
Self-hosted ffmpeg pipeline for PGS (image) subtitle burn-in
Asked for directly, right after the client-side <track> redesign closed
every *text* subtitle case: PGS/VobSub/DVB subtitles (Blu-ray/DVD rips)
are bitmaps, not text - there's nothing for Emby's Stream.vtt conversion
to extract, so a native <track> fundamentally can't render one. Rather
than keep asking Emby's own transcode to burn these in (the exact thing
that kept failing in earlier sessions - missing fontconfig, then an
unrelated "Error starting ffmpeg"), this runs ffmpeg ourselves: a PGS
stream decodes like a second video track, and ffmpeg's `overlay` filter
composites it directly - no text rendering, no libass, no fontconfig
anywhere in this path.
One shared stream per PARTY, not per viewer - explicitly asked for after
the initial plan: every member plugs into the exact same server-emitted
stream, mirroring "host drives, everyone follows" rather than a
per-viewer model. Picking a burn-in subtitle is therefore a host-only,
party-wide choice, like play/pause/next already are.
- server/src/services/TranscodeService.ts (new): one ffmpeg session per
partyId. getOrStartSession negotiates PlaybackInfo (via the new
CatalogService.getMediaSource), converts Emby's absolute
MediaStreams[].Index into ffmpeg's type-relative 0:s:N/0🅰️N
(absoluteToRelativeIndex - pure, unit-tested), and spawns ffmpeg
against Emby's own direct/static file endpoint (TODO(verify) - no
Emby server reachable from this dev environment, see the function's
own comment). Output is real progressive HLS to a temp dir, served by
two new routes that poll briefly for the next file. endSession/
reapIdleSessions handle teardown; PartyService's destroy path and
advanceQueue both end a party's session immediately, the idle sweep
is the backstop.
- server/src/services/PartyService.ts: Party/PartyPublicState gain
burnInSubtitleIndex/burnInAudioIndex (null together = no burn-in);
advanceQueue clears them (a new item's indices mean nothing on the
old one).
- server/src/realtime/SocketGateway.ts: new host-only "setBurnInSubtitle"
handler broadcasting "burnInChanged" - same shape as "next"/
"itemChanged".
- server/src/services/CatalogService.ts: new getMediaSource() (id/
container/streams together - what TranscodeService needs); extended
the local PlaybackInfoResponse type with Container.
- server/src/Logger.ts: new Logger.transcode().
- server/src/index.ts: GET /api/catalog/burn/:partyId/master.m3u8 and
.../:segment (membership-checked, same pattern as the party REST
routes); a dedicated idle-reap interval for TranscodeService.
- server/Dockerfile: `apk add ffmpeg` in the runtime stage - no font
packages needed, unlike Emby's own text-subtitle burn-in.
- client/src/api/party.ts, client/src/api/embyApi.ts: setBurnInSubtitle/
onBurnInChanged on PartyConnection, burnInStreamUrl().
- client/src/views/player/Player.{html,css,ts}: the subtitle picker's
image-track options go from unconditionally disabled to host-only
selectable, labeled to say picking one restarts the stream for
everyone. Every member (host included) only ever reacts to the
resulting "burnInChanged" broadcast - never applies a pick
optimistically, same discipline the rest of the player already uses.
Audio/quality are disabled while a burn-in is active (one fixed
encode); a follower's subtitle select is disabled too (nothing left
for them to meaningfully pick).
- server/test/transcodeService.test.ts (new): absoluteToRelativeIndex,
session start/reuse/replace, endSession, reapIdleSessions -
child_process.spawn mocked via a small explicit test seam
(setSpawnForTesting), never invoking a real ffmpeg binary.
- server/test/socketGateway.test.ts, partyService.test.ts,
catalogService.test.ts: host-only gating + broadcast for
setBurnInSubtitle, advanceQueue's reset, toPublicState exposing the
new fields, getMediaSource.
- docs/architecture.md, README.md: design + an explicit "assumptions to
verify" note (the direct-source URL shape, first and foremost).
Verified: full typecheck+test+build on both projects (56/56 server
tests). No Emby server or ffmpeg binary is reachable from this dev
environment, so the actual command against a real PGS file is
unverified - flagged explicitly in the docs, same "TODO(verify)" spirit
already used elsewhere in this codebase for an Emby response shape that
can't be checked locally.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
|||
| daab755ce9 |
Device detection: confirm before disconnecting another device
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>
|
|||
| c14d0bc08b |
Replace avatar-click with a request toolbar; add "request resume"
Asked for directly: a visible bottom toolbar for non-host members
instead of clicking your own small avatar circle (never a very
discoverable affordance), and a second request alongside pause -
"please resume".
- server/src/types.ts, server/src/realtime/SocketGateway.ts: new
requestResume/resumeRequested, byte-for-byte the same shape and
handler pattern as requestPause/pauseRequested, independently
throttled (its own lastResumeRequestUtc - not a shared budget with
pause). Kept as a plain broadcast signal, same as pause always was -
no accept/deny state tracked server-side, the host sees the same
banner/avatar highlight as everyone else and acts with their own
existing controls.
- client/src/api/party.ts, client/src/types.ts: requestResume() on
PartyConnection, onResumeRequested callback, a ResumeRequestedMsg type
(same shape as PauseRequestedMsg, kept separate so a callback's own
signature says which request it's for).
- client/src/views/player/Player.{html,css,ts}: the avatar is presence-
only now (no click handler, no "- demander une pause" title) - a new
.bottomOverlay wraps the request banner, the follower notice, and a
new .requestToolbar (Demander une pause / Demander une reprise) as one
flex column, same "stack in normal flow instead of hand-picking a
pixel offset per box" fix .topOverlay already needed. Pause and resume
share one showRequestBanner() - they only ever differed by which word
appears in the banner text. Renamed the shared CSS classes
(avatarPauseRequested/avatarPauseBadge -> avatarActionRequested/
avatarRequestBadge) since they're no longer pause-specific.
- server/test/socketGateway.test.ts: new case confirming requestResume's
throttle is independent from requestPause's own.
Verified against the real compiled bundle: the toolbar shows for a
follower and stays hidden for the host; each button's own client-side
cooldown collapses a rapid double-click to one emission; a pushed
resumeRequested shows the banner with the right text and highlights the
right avatar.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
|||
| 5be839d2f6 |
Music bar: refresh vote count on join/leave, progress bar; catalog search
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> |
|||
| 137977893a |
Render subtitles client-side (<track>) instead of asking Emby to burn them in
Six rounds of real-server debugging (see docs/architecture.md's player-
track-selection section) found three different, unrelated reasons a
server-side burn-in could fail - missing fonts, a bitmap format libass
fundamentally can't burn, ffmpeg failing outright on an otherwise
unremarkable file - none of which this app's own request ever caused or
could have fixed, all downstream of the one thing every attempt had in
common: asking Emby for a real-time video re-encode. Not needing that
re-encode at all closes the whole class of failure instead of chasing
each one.
- server/src/services/CatalogService.ts: new proxySubtitle asks Emby to
convert the track to WebVTT (GET /Videos/{id}/{mediaSourceId}/
Subtitles/{index}/Stream.vtt - a real Emby endpoint their own web
client already uses for this) - entirely separate from the video
transcode. applyPlaybackOverrides lost its SubtitleStreamIndex/
SubtitleMethod=Encode branch - the video request no longer needs to
know a subtitle was picked at all.
- server/src/types.ts, client/src/types.ts: PlaybackTrackOption's flag
renamed supportsBurnIn -> isTextBased - same underlying fact (image vs
text codec), but the name shouldn't keep describing a mechanism this
app no longer uses.
- server/src/index.ts: new GET /api/catalog/subtitle/:itemId/:subtitleIndex
route, grouped with the other cookie-authed media-element routes (a
<track> can't send a bearer header either).
- client/src/api/embyApi.ts: new subtitleUrl(); streamUrl no longer
carries SubtitleStreamIndex at all.
- client/src/views/player/Player.ts: the subtitle <select> no longer
reloads the video (reloadWithCurrentOptions, still used for audio/
quality, never touches subtitles now). New applySubtitleTrack()
removes/re-adds a fresh <track> and sets video.textTracks[0].mode =
'showing' synchronously right after appending - deliberately not
waiting on the <track>'s own "load" event, confirmed against a real
browser that it doesn't fire reliably at all (a known cross-browser
quirk), which would have left a subtitle silently never shown - the
exact symptom this whole saga started from. Re-applied on every video
(re)load so an audio/quality change (which does still reload the
video) doesn't drop whatever subtitle was showing.
- server/test/catalogService.test.ts: updated/new cases for
proxySubtitle and the renamed isTextBased flag; removed the test for
the now-deleted subtitle-forcing behavior.
Verified against the real compiled bundle: picking a text track adds one
<track> with the right URL and flips it to showing; "Aucun" removes it;
an image-coded track is listed but disabled; changing audio reloads the
video but the subtitle survives it, same element, still showing.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
|||
| 2f670ec9a4 |
Flag image-based subtitle tracks (PGS/VobSub/DVB) as not burn-in-able
Two different items both failed the same way when picking a subtitle - without knowing whether the second one was also a PGS (image/bitmap) track like the first confirmed one, that result was ambiguous: still the same known issue, or something new? Rather than keep digging through server logs per item, this app can just know the answer itself - Emby's PlaybackInfo response already includes each subtitle stream's Codec. - server/src/services/CatalogService.ts: getPlaybackOptions reads it and reports supportsBurnIn: false for image-based subtitle codecs (hdmv_pgs_subtitle/dvd_subtitle/dvb_subtitle - IMAGE_SUBTITLE_CODECS). This app forces SubtitleMethod=Encode whenever a subtitle is picked (applyPlaybackOverrides), asking ffmpeg's `subtitles` filter (libass) to burn it in - libass renders text via a font, it has no path for compositing a pre-rendered bitmap at all, confirmed against a real server (see docs/architecture.md's Round 4/5). - server/src/types.ts, client/src/types.ts: PlaybackTrackOption gains supportsBurnIn (subtitle tracks only, undefined for audio). - client/src/views/player/Player.ts: the subtitle <select> now disables these options with a "(image, non disponible)" label instead of letting anyone pick a track that's guaranteed to break playback - listed so it's clear why, not silently missing. - server/test/catalogService.test.ts: new case covering subrip/ass (supportsBurnIn: true) vs hdmv_pgs_subtitle (false) from the same MediaStreams response. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> |
|||
| a5aca4a823 |
Fix home page's party "elapsed" label ignoring pauses
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> |
|||
| fff0b7a984 |
Fix subtitle selection timing out: hls.js's default timeouts too tight
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> |
|||
| b2310216ce |
Fix settings menu visibility bug; make the music player a persistent bar
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>
|
|||
| 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> |
|||
| efaa7f55f0 |
Add music: Artist/Album/Track browsing + participative listen-party queue
All checks were successful
Publier l'image Docker / publish (push) Successful in 7s
The last deferred piece from the original spec. Chosen permission model (asked, not assumed): anyone can add a track to the shared queue any time; anyone can pause/resume directly; skipping the current track needs a strict majority vote of currently connected members. No host for playback - a genuinely different shape of shared state than video's "host drives, everyone follows", which is why it's a separate service (server/src/musicQueueService.js, MusicQueueService) rather than more branches in partyManager.js/socketHandler.js's existing video-shaped logic. Server: - musicQueueService.js: owns a music party's queue/current-track/pause/ vote-skip state. Server-authoritative timing (no client is "the host" reporting position) - a wall-clock startedUtc plus the track's known duration is the only state; auto-advance is one setTimeout scheduled for exactly when that runs out, not any client reporting "it ended" (which could race across several players finishing near-simultaneously with no host to arbitrate). attachIo(io, onTrackChanged), called once at startup like PartyManager's own wiring, lets it broadcast "queueUpdated" from inside that timer's callback, which has no socket/request in scope at all. - The timer is the one new leak surface this adds, given how much this project has already had to fix real leaks - destroy(party) clears it, wired into both of partyManager.js's existing destruction paths (leave's last-member case, removeIdleParties' sweep). - Party gained mediaType: 'video'|'music' (inferred server-side from the clicked item's Emby Type - the client never has to know or say which). - Three new catalog endpoints (Artists -> Albums -> Tracks), same reasoning as the existing Shows -> Seasons -> Episodes: a real Emby endpoint per level, not generic recursive /Items guessing. - Audio streams through the *same* /master.m3u8 route as video (Emby's PlaybackInfo negotiation isn't video-specific) - the one difference is the hand-built-fallback path prefix (/Audio/ vs /Videos/), which the client tags via a mediaType=audio query hint rather than costing an extra Emby round trip to detect server-side. - Adding a track is REST (POST /api/parties/:partyId/tracks), not a socket event like voteSkip/musicPlaystate - it needs to work from the player's inline browse-and-add panel regardless of realtime-connection specifics, and MusicQueueService's own broadcast reaches everyone connected either way. Client: - player.js's renderPartyPlayer is now a small dispatcher: fetches the party once, then delegates to renderVideoPlayer or the new renderMusicPlayer (views/musicPlayer.js) based on mediaType. Can't be async despite awaiting that fetch - route()'s contract needs its cleanup function back synchronously (router.js's `typeof cleanup === 'function'` check would silently skip a Promise, reintroducing the exact leak class already fixed once) - solved with a mutable box returned immediately, filled in once the fetch resolves. - musicPlayer.js: not full-viewport/theater-mode like the video player (no image to declutter around, sidebar stays up) - which is what makes its own inline Artist -> Album -> Track browser work for "add tracks while listening" without navigating away and losing player state or threading party context through query params across three route levels. - New views/artist.js, views/album.js (library.js's music branch renders an artist grid instead of the old placeholder). tiles.js's DRILL_DOWN table generalizes the old single seriesHref special-case now that there are three drill-down targets, not two. - party.js: addTrackToQueue (REST) alongside voteSkip/setMusicPaused (socket) on the existing connectParty() connection object. Real bug found while testing, not just the mock's fault: applyMusicState used to update the queue list/vote button *after* the current-track branch, which calls loadTrack() - and loadTrack()'s hls.js setup can throw. Since JS doesn't roll back earlier statements when a later one throws but does abort everything after it, an exception there took the unrelated queue list and vote tally down with it, and on the first render escaped all the way to the dispatcher's catch, which alerts and bounces back to /home - discarding the whole view over what's really just "this one track's audio failed to load". Fixed by reordering (queue/vote/icon updates - which can't fail - before the track-loading branch, mirroring how player.js's onItemChanged already updates title/next-button before calling loadVideo) and wrapping loadTrack's hls.js setup in its own try/catch so a failure there logs and stops instead of propagating. Verified server-side directly against the real shipped modules (not reimplemented mocks): 18 checks against musicQueueService.js alone (auto -start, queueing, vote-skip majority, pause/resume elapsed-time math, a real setTimeout auto-advance, timer cleanup on destroy - instrumented setTimeout/clearTimeout counting, same technique as the earlier client -side leak tests) and 7 more through socketHandler.js + partyManager.js together (no-ops on a video party, a non-host pausing/resuming, a two-vote majority skip, the resulting broadcast really reaching the room). Client-side against the real local server with mocked fetch/Hls/ io: the full browsing chain, party creation, the dispatcher choosing the right player (and still choosing video when mediaType is absent - back -compat), now-playing/vote-tally/queue rendering and updates including a live track change, play/pause and vote-skip emitting the right events, the inline browse-and-add panel POSTing the right payload, and the crash fix re-confirmed fixed via the same broken scenario that first found it. Not verified: the real Emby round trip for any of this (Artists/albums -tracks response shapes, audio PlaybackInfo/TranscodingUrl behavior, actual playback quality) - same limit as everything else in this project tested without live Emby access. docs/architecture.md has the full writeup. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> |
|||
| 0f3c364b42 |
Fix "Voir les épisodes" rendering as a bare link instead of a button
All checks were successful
Publier l'image Docker / publish (push) Successful in 28s
Bug report: the button wasn't as visible as the others on a tile.
Cause: the global `button {}` rule in app.css that gives "Lancer une watch
party" its solid green look is tag-scoped, so it never applied to "Voir
les épisodes" - the one .tileBtn that's an <a> (a plain hash link, not a
<button>). "Voir sur Emby" avoided the same fate only because it also
carries .tileBtnLink, which explicitly sets its own background/color.
"Voir les épisodes" had neither, so it rendered as a bare, small,
default-blue link sitting on the tile overlay next to two solid buttons.
Fixed by moving the solid-button look onto .tileBtn itself (tag-agnostic:
background/color/border-radius/font-weight/hover), with .tileBtnLink now
overriding just the two properties that make "Voir sur Emby" read as the
secondary action. This is the semantically right fix, not just a visual
patch: for a Series tile "Voir les épisodes" IS the primary action (a
Series is never itself directly playable - WATCH_PARTY_TYPES in
server/src/index.js - so the two buttons never compete on the same tile)
- it should look like one, matching "Lancer une watch party"'s weight
exactly rather than "Voir sur Emby"'s secondary one.
Verified via computed styles on a mocked library grid (one Movie tile, one
Series tile): "Lancer une watch party" and "Voir les épisodes" now compute
to the identical rgb(82, 181, 75) background / white text / 600 weight /
8px radius, while "Voir sur Emby" on both tiles still computes to the
distinct secondary rgb(38, 38, 38).
docs/architecture.md has the full writeup.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
|||
| f6a8010647 |
Move presence overlay to the top, orange badge for pause requests, series detail page
All checks were successful
Publier l'image Docker / publish (push) Successful in 7s
Two rounds of feedback on the previous commit, plus a related feature request: 1. Presence overlay moved from bottom-left to the top of the player. client/js/views/player.js: the top gradient bar is now .topOverlay, a column of two stacked rows - .actionBar (what used to be the whole bar: title/members-text/next/settings/leave) and #presenceOverlay directly below it. Stacking them in normal flow means the avatar row always lands right after the action bar regardless of how tall that bar renders (it wraps on narrow screens), instead of a second absolutely-positioned block at a guessed pixel offset. 2. "Someone requested a pause" is now a specific orange border (.avatarPauseRequested, #ff9800) plus a small pause-icon badge in the circle's corner (.avatarPauseBadge - a plain inline two-bar SVG, not a Unicode glyph, since pause/play characters default to emoji presentation on several platforms and look muddy at 10px) - replacing the previous generic box-shadow pulse. This also absorbed the separate "click feedback" ring that used to live only on the requester's own circle (.avatarSent): since the server already broadcasts "pauseRequested" back to the requester too, one state driven purely by that broadcast is simpler than two overlapping ones. Found while repositioning the banner above .followerNotice: the notice is a <p>, and for an absolutely positioned element "bottom" places the MARGIN edge, so its default ~1em user-agent margin was quietly rendering it 13px higher than its declared bottom:24px ever suggested. Gave it margin:0. 3. Series detail page (client/js/views/series.js): the page now opens with an actual detail header - poster, title + year, overview, "Voir sur Emby" - above the season tabs/episode list, instead of a bare <h1>. The season/episode browsing and per-episode "Lancer une watch party" already worked end to end (library grid -> Series tile -> this route); this pass only made the page look like a detail page. Reused toItemDto's existing fields (GET /api/catalog/item/:id), no backend changes needed. Widened .linklike's CSS selector from `button.linklike` to `.linklike` so the new "Voir sur Emby" <a> picks it up too - app.css has no global `a` reset, so an unclassed anchor would otherwise render with the browser's default blue/underline. Verified with mocked Socket.IO events / API responses against the real running local server (see session history): presence row renders directly under the action bar; a pauseRequested event gives the right circle rgb(255, 152, 0) as its computed border color and display:flex on its badge while leaving every other circle untouched (checked via computed styles, not just class names); rapid repeat clicks on your own circle still collapse to exactly one requestPause emission; banner/followerNotice gap measured 15px after the margin fix, versus 2px before it; the series page's header renders correctly, season-tab switching swaps the episode list, a tvshows library's Series tiles link to /series/:id via both the poster and "Voir les épisodes", and starting an episode's party correctly creates it and navigates to the player. docs/architecture.md has the full writeup. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> |