Fix the listen-party queue's layout and its missing album art
All checks were successful
Publier l'image Docker / test (push) Successful in 19s
Publier l'image Docker / publish (push) Successful in 12s

Reported directly: the music bar's queue popup looked broken, and the
listen-party card on the home page never showed an image. Put together a
mockup first (this app's own theme tokens, not a generic one) to confirm
the redesign direction before touching any code, since the queue side
was a real UI rework, not a one-line fix.

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Nicolas 2026-09-15 10:34:39 +02:00
parent b4c81d820c
commit 5a1bc8d932
10 changed files with 186 additions and 17 deletions

View file

@ -302,6 +302,10 @@ Then put it behind your existing reverse proxy like everything else
- [x] Cast a party's video to a Chromecast (Default Media Receiver),
with a play/pause/seek remote on the sender's own screen — see
docs/architecture.md
- [x] Fixed the listen-party queue's broken layout (it was reusing an
album page's row grid) and its missing album art (a track almost
never has its own image — now falls back to its album's) — see
docs/architecture.md
- [ ] Try a full party end-to-end with two real accounts (still the main gap)
- [ ] Try casting to a real Chromecast (only the SDK's own bootstrap is
confirmed here — no real device/session)

View file

@ -99,3 +99,54 @@
.musicBarInfo { margin-right: 0; flex-basis: 100%; order: -1; text-align: center; }
.musicBarQueue { max-width: none; margin: 0; border-radius: 0; }
}
/* One queue entry - a plain flex row (image, then a shrinking/truncating
info column, then the duration), NOT views/album's shared .trackRow
grid (styles/trackList.css) - that one's 4 fixed columns assume
Album.ts's own index/name/duration/button shape, which crushed this
list's track names into its 28px index column when reused here. See
MusicBar.ts's own comment. */
.queueRow {
display: flex;
align-items: center;
gap: 10px;
background: var(--panel);
border-radius: 8px;
padding: 8px 10px;
}
.queueRowArt {
width: 38px;
height: 38px;
object-fit: cover;
border-radius: 6px;
background: var(--bg-header);
flex-shrink: 0;
}
.queueRowArt.noImage { visibility: hidden; }
.queueRowInfo {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
flex: 1;
}
.queueRowName {
font-size: 13px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Artist/album/"Ajouté par X" condensed onto one secondary line (see
MusicBar.ts) rather than each getting their own column - there's no
room for 3+ columns once a name needs to read in full, unlike
Album.ts's simpler index/name/duration/button row. */
.queueRowMeta {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

View file

@ -267,13 +267,22 @@ function applyMusicState(music: MusicPublicState): void {
playPauseBtn.disabled = !music.current;
updateVoteSkipLabel();
// Its own .queueRow, NOT views/album's shared .trackRow (see
// styles/trackList.css) - that one is a CSS grid whose 4 columns
// (28px index / 1fr name / auto duration / auto button) assume
// Album.ts's own row shape. Reusing it here used to crush the track
// NAME into the 28px column meant for a track number, since this
// queue's rows never had an index at all - a real, visibly broken
// layout bug, not just a style mismatch. See docs/architecture.md.
queueList.innerHTML = music.queue.length
? music.queue.map((t) => `
<div class="trackRow">
<span class="trackName">${escapeHtml(t.itemName)}</span>
<span class="hint">${escapeHtml([t.artistName, t.albumName].filter(Boolean).join(' - '))}</span>
<span class="trackDuration">${t.durationSeconds ? formatDuration(t.durationSeconds) : ''}</span>
<span class="hint">Ajouté par ${escapeHtml(t.addedByUserName)}</span>
<div class="queueRow">
<img class="queueRowArt" src="${escapeHtml(t.itemImageUrl || '')}" alt="" onerror="this.classList.add('noImage')" />
<div class="queueRowInfo">
<span class="queueRowName">${escapeHtml(t.itemName)}</span>
<span class="hint queueRowMeta">${escapeHtml([t.artistName, t.albumName, `Ajouté par ${t.addedByUserName}`].filter(Boolean).join(' · '))}</span>
</div>
<span class="hint trackDuration">${t.durationSeconds ? formatDuration(t.durationSeconds) : ''}</span>
</div>`).join('')
: '<p class="hint">Rien en attente.</p>';

View file

@ -30,6 +30,14 @@
flex-shrink: 0;
}
/* A music party's own cover art is square (an album's, not a movie/show
poster) - the portrait 48x72 box above was forcing a hard crop on it.
Video parties keep the portrait box unchanged. */
.partyCard img.partyCardArt--square {
width: 56px;
height: 56px;
}
.partyCard img.noImage { visibility: hidden; }
.partyCardInfo {

View file

@ -54,7 +54,7 @@ function renderParties(root: HTMLElement, parties: LobbyPartyDto[]): void {
section.hidden = false;
list.innerHTML = parties.map((p) => `
<div class="partyCard">
<img src="${p.itemImageUrl || ''}" alt="" onerror="this.classList.add('noImage')" />
<img class="${p.mediaType === 'music' ? 'partyCardArt--square' : ''}" src="${p.itemImageUrl || ''}" alt="" onerror="this.classList.add('noImage')" />
<div class="partyCardInfo">
<strong>${escapeHtml(p.itemName || '')}</strong>
<span class="hint">

View file

@ -2520,6 +2520,63 @@ output cleanly are all unconfirmed, the same way this project already
flags its other can't-verify-without-the-real-thing assumptions. Worth
trying with a real device once deployed.
## Fixing the listen-party queue's layout and its missing album art
Reported directly: the music bar's "File d'attente" popup looked broken,
and the listen-party card on the home page never showed an image. A
mockup was put together first (before touching any code) to confirm the
direction, since the queue side was a genuine UI rework, not just a
one-line fix.
**The layout bug**: `MusicBar.ts`'s queue rows were reusing
`styles/trackList.css`'s shared `.trackRow` - a CSS grid with 4 fixed
columns (`28px 1fr auto auto`) sized for `views/album`'s own row shape
(track number, name, duration, an "Ajouter"/"Écouter ensemble" button).
The queue's rows never had a track number at all, and rendered
completely different content in that same slot - the track's own NAME
was the first child, so it landed in the 28px column meant for a
one-or-two-digit index, crushed down to its first couple of characters,
while the artist/album text spilled into the wide `1fr` column instead
meant for the name. Fixed with a dedicated `.queueRow` (flex, not grid -
image, then a truncating info column, then the duration), rather than
trying to force this list's different shape through Album's grid with
more special-casing.
**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 (`200 image/jpeg` for two of three; the third
album genuinely has no artwork uploaded at all, a real data gap, not a
bug). Every place that built an image URL from a raw `EmbyItem`
independently reconstructed `/api/catalog/image/${item.Id}` by hand
(`CatalogService.toItemDto`, and three separate spots in `index.ts`'s
party-creation routes that skip `toItemDto` entirely) - all now funnel
through one new `CatalogService.imageUrlFor(item)`, which points a music
track at its `AlbumId` instead of its own `Id` when one's known.
`EmbyItem` gained the `AlbumId` field, and every `Fields` param that
lists Audio items among its results (`getAlbumTracks`,
`getPlaylistItemsRaw`, and defensively `getRecentItems`/
`getLibraryItems`) now requests it - a single-item detail fetch
(`getRawItem`, used by party creation) already includes it with no
`Fields` param needed, confirmed against the real server.
While fixing the home-page card's now-working image, its box turned out
to be the wrong shape for music too: `.partyCard img` was a fixed
48×72 portrait box - correct for a movie/show poster, but a hard crop
for an album's square cover. A video party's card is untouched; a
music party's now gets a 56×56 square box instead
(`.partyCardArt--square`, toggled from `LobbyPartyDto.mediaType` in
`Home.ts`).
Verified: full `typecheck`/`test`/`build` on both projects, including
new unit tests for `imageUrlFor` (a movie/episode unaffected, a track
with an `AlbumId` falls back to it, a track with none still falls back
to its own id rather than building a broken URL). No server is needed
to see the queue/card layout itself, so those were checked visually
against a mockup built from this app's own real theme tokens/typefaces
rather than a live instance.
## Known limitations
- Playback always goes through `hls.js` against whatever Emby's

View file

@ -8,7 +8,7 @@ import { Server as SocketIoServer } from 'socket.io';
import { EMBY_SERVER_URL, PORT } from './config';
import { Logger } from './Logger';
import { AuthService, SESSION_COOKIE, SESSION_TTL_MS } from './services/AuthService';
import { CatalogService, CatalogFetchError, splitPlaylistByMediaType } from './services/CatalogService';
import { CatalogService, CatalogFetchError, imageUrlFor, splitPlaylistByMediaType } from './services/CatalogService';
import { PartyService } from './services/PartyService';
import { MusicQueueService } from './services/MusicQueueService';
import { TranscodeService } from './services/TranscodeService';
@ -323,7 +323,7 @@ app.post('/api/parties', requireSession, asyncRoute(async (req, res) => {
session.userName,
itemId,
queue ? queue[0]!.itemName : item.Name,
`/api/catalog/image/${itemId}`,
imageUrlFor(item),
queue,
mediaType
);
@ -335,7 +335,7 @@ app.post('/api/parties', requireSession, asyncRoute(async (req, res) => {
itemName: item.Name,
artistName: item.AlbumArtist || item.Artists?.[0] || null,
albumName: item.Album || null,
itemImageUrl: `/api/catalog/image/${itemId}`,
itemImageUrl: imageUrlFor(item),
durationTicks: item.RunTimeTicks || null
}, session.userId, session.userName);
}
@ -375,7 +375,7 @@ async function createPartyFromPlaylist(res: Response, session: EmbySession, play
const first = items[0]!;
const party = PartyService.create(
session.userId, session.userName,
first.Id, first.Name, `/api/catalog/image/${first.Id}`,
first.Id, first.Name, imageUrlFor(first),
null, mediaType
);
PartyService.setMemberEmbyToken(party, session.userId, session.embyToken);
@ -388,7 +388,7 @@ async function createPartyFromPlaylist(res: Response, session: EmbySession, play
itemName: track.Name,
artistName: track.AlbumArtist || track.Artists?.[0] || null,
albumName: track.Album || null,
itemImageUrl: `/api/catalog/image/${track.Id}`,
itemImageUrl: imageUrlFor(track),
durationTicks: track.RunTimeTicks || null
};
if (i === 0) {

View file

@ -48,6 +48,26 @@ const IMAGE_SUBTITLE_CODECS = new Set(['hdmv_pgs_subtitle', 'dvd_subtitle', 'dvb
* unit-tested directly (see catalogService.test.ts) without needing to
* exercise the actual /api/parties route that calls it (index.ts).
*/
/**
* The image URL for a raw Emby item - every place that builds one from an
* `EmbyItem` (toItemDto below, and the party-creation routes in index.ts
* that hand-build one straight from a freshly-fetched item without going
* through a DTO) funnels through here. Almost always just `item.Id`
* itself, EXCEPT a music track: confirmed directly against a real server
* that an individual Audio item essentially never carries its own Primary
* image (every track sampled answered with a 500, not even a clean 404),
* while its parent album usually does - so a track's own id would just
* mean a permanently broken/missing thumbnail everywhere one is shown
* (the listen-party queue, the home page's live-party card) instead of
* the same cover art every other client shows next to it. Callers must
* request `AlbumId` in their own `Fields` param for this to have
* anything to fall back to - see getAlbumTracks/getPlaylistItemsRaw.
*/
export function imageUrlFor(item: EmbyItem): string {
const imageItemId = item.Type === 'Audio' && item.AlbumId ? item.AlbumId : item.Id;
return `/api/catalog/image/${imageItemId}`;
}
export function splitPlaylistByMediaType(items: EmbyItem[]): { mediaType: MediaType; items: EmbyItem[] } {
if (!items.length) return { mediaType: 'video', items: [] };
const isMusic = items[0]!.Type === 'Audio';
@ -129,7 +149,7 @@ class CatalogServiceClass {
/** Home page carousel: Emby's own "recently added" endpoint, one row per library. */
async getRecentItems(session: EmbySession, libraryId: string): Promise<ItemDto[]> {
const params = new URLSearchParams({ ParentId: libraryId, Limit: '16', Fields: 'ProductionYear' });
const params = new URLSearchParams({ ParentId: libraryId, Limit: '16', Fields: 'ProductionYear,AlbumId' });
const data = await this.fetchJson<EmbyItemsResponse | EmbyItem[]>(`/Users/${session.userId}/Items/Latest?${params}`, session, 'Impossible de récupérer les ajouts récents.');
// Unlike most Items endpoints, Latest returns a plain array, not {Items:[...]}.
const items = Array.isArray(data) ? data : (data.Items || []);
@ -164,7 +184,7 @@ class CatalogServiceClass {
* getPlaylistItems below for the DTO-shaped equivalent the client's
* own playlist page reads instead. */
async getPlaylistItemsRaw(session: EmbySession, playlistId: string): Promise<EmbyItem[]> {
const params = new URLSearchParams({ UserId: session.userId, Fields: 'Overview,ProductionYear,RunTimeTicks,IndexNumber,Album,AlbumArtist' });
const params = new URLSearchParams({ UserId: session.userId, Fields: 'Overview,ProductionYear,RunTimeTicks,IndexNumber,Album,AlbumArtist,AlbumId' });
const data = await this.fetchJson<EmbyItemsResponse>(`/Playlists/${playlistId}/Items?${params}`, session, 'Impossible de récupérer le contenu de cette playlist.');
return data.Items || [];
}
@ -203,7 +223,7 @@ class CatalogServiceClass {
UserId: session.userId,
IncludeItemTypes: 'Audio',
SortBy: 'IndexNumber',
Fields: 'RunTimeTicks,IndexNumber,Album,AlbumArtist'
Fields: 'RunTimeTicks,IndexNumber,Album,AlbumArtist,AlbumId'
});
const data = await this.fetchJson<EmbyItemsResponse>(`/Users/${session.userId}/Items?${params}`, session, 'Impossible de récupérer les morceaux.');
return (data.Items || []).map((t) => this.toItemDto(t, session.serverId));
@ -214,7 +234,7 @@ class CatalogServiceClass {
ParentId: libraryId,
Recursive: 'false', // top-level: Movies as movies, TV as Series tiles (not every episode)
SortBy: 'SortName',
Fields: 'Overview,ProductionYear',
Fields: 'Overview,ProductionYear,AlbumId',
ImageTypeLimit: '1',
Limit: '300'
});
@ -410,7 +430,7 @@ class CatalogServiceClass {
year: item.ProductionYear,
type: item.Type,
canWatchParty: WATCH_PARTY_TYPES.has(item.Type || ''),
imageUrl: `/api/catalog/image/${item.Id}`,
imageUrl: imageUrlFor(item),
embyUrl: `${EMBY_SERVER_URL}/web/index.html#!/item?id=${item.Id}&serverId=${encodeURIComponent(serverId || '')}`,
// Track-specific (null for anything else - harmless, every consumer
// that cares about these only reads them for an Audio item).

View file

@ -163,6 +163,12 @@ export interface EmbyItem {
Album?: string;
AlbumArtist?: string;
Artists?: string[];
/** The parent album's own id - only meaningful for Type: 'Audio'. See
* CatalogService.imageUrlFor: confirmed against a real server that an
* individual track essentially never has its own Primary image (every
* one sampled 500'd), only its album does - this is how an image URL
* falls back to the album's art instead. */
AlbumId?: string;
}
/** This app's own DTO shape - what toItemDto() in CatalogService produces,

View file

@ -4,7 +4,7 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import type { Request, Response } from 'express';
import { CatalogService, splitPlaylistByMediaType } from '../src/services/CatalogService';
import { CatalogService, imageUrlFor, splitPlaylistByMediaType } from '../src/services/CatalogService';
import { EmbyClient } from '../src/emby/EmbyClient';
import type { EmbyItem, EmbySession } from '../src/types';
@ -223,3 +223,17 @@ describe('splitPlaylistByMediaType', () => {
assert.equal(items.length, 0);
});
});
describe('imageUrlFor', () => {
it('a movie/episode points at its own id, same as ever', () => {
assert.equal(imageUrlFor({ Id: 'm1', Name: 'Un Film', Type: 'Movie' }), '/api/catalog/image/m1');
});
it('a music track falls back to its ALBUM\'s id - confirmed against a real server that a track essentially never has its own Primary image', () => {
assert.equal(imageUrlFor({ Id: 't1', Name: 'Track', Type: 'Audio', AlbumId: 'al1' }), '/api/catalog/image/al1');
});
it('a track with no AlbumId at all (nothing to fall back to) still points at its own id, not a broken URL', () => {
assert.equal(imageUrlFor({ Id: 't1', Name: 'Track', Type: 'Audio' }), '/api/catalog/image/t1');
});
});