Music bar: refresh vote count on join/leave, progress bar; catalog search
All checks were successful
Publier l'image Docker / test (push) Successful in 23s
Publier l'image Docker / publish (push) Successful in 13s

Four smaller fixes from real usage:

- MusicBar.ts: "votes needed" for skip depends on the connected-member
  count (renderMembers/"members") AND the vote tally (applyMusicState/
  "queueUpdated") - the label was only ever recomputed from the latter,
  so a plain join/leave with no vote left it stale. Cached the last
  MusicPublicState and factored the label math into its own
  updateVoteSkipLabel(), called from both handlers now.
- Home.ts: the listen party this tab is already in still showed
  "Rejoindre" on the home page. Checks each card's partyId against
  musicBar/MusicBar.ts's own getActiveMusicPartyId() (already tracked
  client-side) and swaps the button for an inert "En écoute" label on
  that one card. No equivalent case for video - its player is a
  full-screen takeover, so you can't be looking at this list from inside
  one.
- MusicBar.html/.css/.ts: a read-only progress bar (a thin strip along
  the bar's own top edge). Deliberately not seekable - no host to
  arbitrate a jump in this model, and MusicQueueService has no "seek"
  concept server-side today; that would be a real protocol addition, out
  of scope here. Fill width is just currentTime/duration, ticked by the
  same interval that already updates the position text.
- tiles.ts (new wireSearchFilter), views/library, views/artist,
  views/album: a search input per catalog page, filtering whatever's
  already rendered client-side (a library page tops out at a few hundred
  items - well within "just filter the DOM" territory, no server round
  trip per keystroke). Shared helper so the three pages behave
  identically. The existing /api/catalog/search endpoint (video types
  only, never wired into any UI) is a separate, unrelated feature - a
  global cross-library search - left as-is.

Verified against the real compiled bundle: a "members" push alone
updates the skip label; the home page's own party shows "En écoute" while
others still show "Rejoindre"; the progress fill tracks a simulated
currentTime correctly; typing into a library's search box filters tiles
live with the empty-state notice appearing only when nothing matches.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Nicolas 2026-09-14 16:44:23 +02:00
parent 137977893a
commit 5be839d2f6
14 changed files with 197 additions and 17 deletions

View file

@ -18,6 +18,22 @@
box-shadow: 0 -4px 16px rgba(0, 0, 0, .3);
}
/* A thin strip along the bar's own top edge, not inside .musicBarRow -
the same "compact player" convention Spotify/YouTube Music use for
their mini-players. Read-only (see MusicBar.ts's own comment) - no
click/drag handling, just a visual position indicator. */
.musicBarProgressTrack {
height: 3px;
background: rgba(255, 255, 255, .12);
}
.musicBarProgressFill {
height: 100%;
width: 0%;
background: var(--accent);
transition: width .25s linear;
}
.musicBarRow {
display: flex;
align-items: center;

View file

@ -5,6 +5,11 @@
<div id="queueList" class="trackList"><p class="hint">Rien en attente.</p></div>
</div>
<!-- Read-only - see MusicBar.ts's own comment on why this doesn't seek
(no host to arbitrate a jump, and MusicQueueService has no "seek"
concept server-side today - just play/pause/advance). -->
<div id="trackProgress" class="musicBarProgressTrack"><div id="trackProgressFill" class="musicBarProgressFill"></div></div>
<div class="musicBarRow">
<div id="presenceOverlay" class="presenceOverlay musicBarPresence"></div>

View file

@ -54,6 +54,11 @@ let currentDurationSeconds = 0;
let suppressLocalEvents = false; // avoid feeding our own correction back as a new event - same pattern as Player.ts/MusicPlayer.ts
let positionTicker: number | null = null;
let latestMembers: SocketMember[] = [];
// Cached purely so renderMembers (a member joining/leaving) can recompute
// the vote-skip label on its own - "votes needed" depends on the
// connected-member count, which changes independently of any actual
// vote/track-change event (the only two things that used to refresh it).
let lastMusicState: MusicPublicState | null = null;
/** Whatever party is currently attached to the bar, if any - used by
* views/album to decide whether a track's button should start a brand
@ -84,14 +89,17 @@ export function initMusicBar(root: HTMLElement): void {
toggleQueueBtn.addEventListener('click', () => queuePanel.classList.toggle('hide'));
leaveBtn.addEventListener('click', () => leaveMusicParty());
// Ticks the visible position label between server updates, same
// "derive it client-side from a snapshot" approach as Home's party-
// elapsed labels and the old MusicPlayer.ts - runs continuously rather
// than only while a party is active (cheap: a no-op while !currentItemId).
// Ticks the visible position label AND the progress bar's fill between
// server updates, same "derive it client-side from a snapshot" approach
// as Home's party-elapsed labels and the old MusicPlayer.ts - runs
// continuously rather than only while a party is active (cheap: a
// no-op while !currentItemId).
positionTicker = window.setInterval(() => {
if (!currentItemId) return;
const position = barEl!.querySelector('#trackPosition')!;
position.textContent = `${formatDuration(audio.currentTime)} / ${formatDuration(currentDurationSeconds)}`;
const fill = barEl!.querySelector('#trackProgressFill') as HTMLElement;
fill.style.width = currentDurationSeconds > 0 ? `${Math.min(100, (audio.currentTime / currentDurationSeconds) * 100)}%` : '0%';
}, 500);
}
@ -138,6 +146,8 @@ export function leaveMusicParty(): void {
audio.pause();
audio.removeAttribute('src');
currentItemId = null;
lastMusicState = null;
latestMembers = [];
conn?.leave();
conn = null;
@ -199,9 +209,35 @@ function loadTrack(track: MusicCurrentPublic, elapsedSeconds: number, isPaused:
}
}
function applyMusicState(music: MusicPublicState): void {
const playPauseBtn = barEl!.querySelector('#playPauseBtn') as HTMLButtonElement;
/**
* "Votes needed" is a strict majority of currently CONNECTED members
* (mirrors MusicQueueService's own math server-side) - it depends on
* *two* independent things, the vote tally (music.skipVotes, only known
* on a "queueUpdated" push) and the connected-member count (only known
* on a "members" push). Recomputing it from whichever one last changed
* - not just whenever a vote itself happens - is the whole fix for a
* member joining/leaving not updating the "X/Y" shown: previously this
* was inlined into applyMusicState only, so renderMembers changing the
* member count had nothing to make the button's text catch up until the
* next unrelated queueUpdated (a vote, a track change) happened to fire.
*/
function updateVoteSkipLabel(): void {
const voteSkipBtn = barEl!.querySelector('#voteSkipBtn') as HTMLButtonElement;
const current = lastMusicState?.current;
voteSkipBtn.disabled = !current;
if (!current) {
voteSkipBtn.textContent = 'Passer (vote)';
return;
}
const connectedCount = latestMembers.filter((m) => m.connected).length || 1;
const needed = Math.floor(connectedCount / 2) + 1;
voteSkipBtn.textContent = `Passer (vote) - ${lastMusicState!.skipVotes}/${needed}`;
}
function applyMusicState(music: MusicPublicState): void {
lastMusicState = music;
const playPauseBtn = barEl!.querySelector('#playPauseBtn') as HTMLButtonElement;
const queueList = barEl!.querySelector('#queueList')!;
const trackName = barEl!.querySelector('#trackName')!;
const trackArtist = barEl!.querySelector('#trackArtist')!;
@ -213,10 +249,7 @@ function applyMusicState(music: MusicPublicState): void {
// own comment on why - same ordering as Player.ts/the old MusicPlayer.ts).
playPauseBtn.textContent = music.current && !music.current.isPaused ? '⏸' : '▶';
playPauseBtn.disabled = !music.current;
voteSkipBtn.disabled = !music.current;
const connectedCount = latestMembers.filter((m) => m.connected).length || 1;
const needed = Math.floor(connectedCount / 2) + 1;
voteSkipBtn.textContent = music.current ? `Passer (vote) - ${music.skipVotes}/${needed}` : 'Passer (vote)';
updateVoteSkipLabel();
queueList.innerHTML = music.queue.length
? music.queue.map((t) => `
@ -236,6 +269,7 @@ function applyMusicState(music: MusicPublicState): void {
if (hls) { hls.destroy(); hls = null; }
audio.pause();
audio.removeAttribute('src');
(barEl!.querySelector('#trackProgressFill') as HTMLElement).style.width = '0%';
} else if (music.current.itemId !== currentItemId) {
loadTrack(music.current, music.current.elapsedSeconds, music.current.isPaused);
} else {
@ -252,6 +286,7 @@ function applyMusicState(music: MusicPublicState): void {
function renderMembers(members: SocketMember[]): void {
latestMembers = members;
updateVoteSkipLabel(); // the connected-member count just changed - see its own comment
const presenceOverlay = barEl!.querySelector('#presenceOverlay')!;
presenceOverlay.innerHTML = members.map((m) => `
<div class="avatar" style="background:${colorForName(m.userName)}" title="${escapeHtml(m.userName)}">

View file

@ -1,6 +1,17 @@
/* ---- Tile grid (library/artist pages) + tiles (shared with the home
carousels) - see tiles.ts. ---- */
/* base.css's global `input {}` rule already gives this the right look
(padding/border/background) - this just bounds its width and spaces it
from the grid below, shared by views/library, views/artist, views/album -
see tiles.ts's wireSearchFilter. */
.searchBar {
max-width: 320px;
margin-bottom: 16px;
}
.searchEmpty { margin-top: 8px; }
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));

View file

@ -87,3 +87,32 @@ export function wireTileButtons(container: ParentNode, onStart: (itemId: string)
});
});
}
/**
* Wires a search <input> to show/hide `itemSelector` elements by name,
* live as you type - client-side only, filtering whatever's already
* rendered rather than a server round trip per keystroke. Deliberately
* simple for that reason: a library page tops out at a few hundred items
* (see getLibraryItems's own Limit), well within "just filter the DOM"
* territory - shared by views/library, views/artist, views/album so the
* three catalog pages that asked for this all behave identically instead
* of drifting into three slightly different implementations.
*
* `scope` should be wide enough to contain both the items themselves AND
* a `.searchEmpty` notice element - typically the view's own `root`, not
* just the immediate grid/list wrapper (which is usually a sibling of
* `.searchEmpty`, not an ancestor of it).
*/
export function wireSearchFilter(input: HTMLInputElement, scope: ParentNode, itemSelector: string): void {
input.addEventListener('input', () => {
const q = input.value.trim().toLowerCase();
let anyVisible = false;
scope.querySelectorAll<HTMLElement>(itemSelector).forEach((el) => {
const matches = !q || (el.textContent || '').toLowerCase().includes(q);
el.classList.toggle('hide', !matches);
if (matches) anyVisible = true;
});
const emptyNotice = scope.querySelector<HTMLElement>('.searchEmpty');
if (emptyNotice) emptyNotice.classList.toggle('hide', anyVisible || !q);
});
}

View file

@ -7,4 +7,6 @@
<a class="linklike" id="embyLink" target="_blank" rel="noopener">Voir sur Emby</a>
</div>
</div>
<input id="searchInput" class="searchBar" type="search" placeholder="Rechercher un morceau..." />
<p class="hint searchEmpty hide">Aucun résultat.</p>
<div id="trackList" class="trackList"></div>

View file

@ -4,7 +4,7 @@ import html from './Album.html';
import { getAlbumTracks, getItem } from '../../api/embyApi';
import { addTrackToQueue, createParty } from '../../api/party';
import { getActiveMusicPartyId, joinMusicParty } from '../../musicBar/MusicBar';
import { escapeHtml, formatDuration } from '../../tiles';
import { escapeHtml, formatDuration, wireSearchFilter } from '../../tiles';
import type { ItemDto, MusicTrackInput } from '../../types';
import type { RouteParams } from '../../router';
@ -43,6 +43,7 @@ export async function renderAlbum(root: HTMLElement, params: RouteParams): Promi
const listEl = root.querySelector('#trackList')!;
if (!tracks.length) {
listEl.innerHTML = '<p class="hint">Aucun morceau dans cet album.</p>';
root.querySelector('#searchInput')!.remove();
return;
}
@ -112,4 +113,6 @@ export async function renderAlbum(root: HTMLElement, params: RouteParams): Promi
}
});
});
wireSearchFilter(root.querySelector('#searchInput') as HTMLInputElement, root, '.trackRow');
}

View file

@ -1,3 +1,5 @@
<button class="linklike" id="back">&larr; Retour</button>
<div class="pageHeader"><h1></h1></div>
<input id="searchInput" class="searchBar" type="search" placeholder="Rechercher un album..." />
<p class="hint searchEmpty hide">Aucun résultat.</p>
<div id="grid" class="grid"></div>

View file

@ -1,7 +1,7 @@
import html from './Artist.html';
import { getArtistAlbums, getItem } from '../../api/embyApi';
import type { RouteParams } from '../../router';
import { tileHtml } from '../../tiles';
import { tileHtml, wireSearchFilter } from '../../tiles';
/** Route `/artist/:id` - one artist's albums, reached from a music
* library's top-level grid (see views/library) via a MusicArtist tile's
@ -28,7 +28,11 @@ export async function renderArtist(root: HTMLElement, params: RouteParams): Prom
root.querySelector('#back')!.addEventListener('click', () => history.back());
const grid = root.querySelector('#grid')!;
grid.innerHTML = albums.length
? albums.map(tileHtml).join('')
: '<p class="hint">Aucun album trouvé pour cet artiste.</p>';
if (!albums.length) {
grid.innerHTML = '<p class="hint">Aucun album trouvé pour cet artiste.</p>';
root.querySelector('#searchInput')!.remove();
return;
}
grid.innerHTML = albums.map(tileHtml).join('');
wireSearchFilter(root.querySelector('#searchInput') as HTMLInputElement, root, '.tile');
}

View file

@ -42,3 +42,13 @@
.partyCardInfo strong { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.partyCard button { flex-shrink: 0; padding: 8px 14px; font-size: 13px; }
/* Stands in for "Rejoindre" on the one card that's this browser tab's own
active listen party - see renderParties in Home.ts. Same footprint as
the button it replaces, so the row doesn't visibly reflow. */
.partyCardMine {
flex-shrink: 0;
padding: 8px 14px;
color: var(--accent-bright);
font-weight: 600;
}

View file

@ -2,6 +2,7 @@ import './Home.css';
import html from './Home.html';
import { getLibraries, getRecentItems } from '../../api/embyApi';
import { connectLobby, createParty, joinParty } from '../../api/party';
import { getActiveMusicPartyId } from '../../musicBar/MusicBar';
import { navigate } from '../../router';
import { escapeHtml, tileHtml, wireTileButtons } from '../../tiles';
import type { LobbyPartyDto } from '../../types';
@ -40,6 +41,15 @@ function renderParties(root: HTMLElement, parties: LobbyPartyDto[]): void {
return;
}
// A music party this browser tab is already attached to (see
// musicBar/MusicBar.ts) has nothing useful for "Rejoindre" to do -
// you're already listening, right now, via the bar already on screen.
// Video has no equivalent case to check: its player is a full-screen
// takeover, so you can't be looking at this list while inside one -
// only leaving ever brings you back here, at which point you're not a
// member any more either.
const myMusicPartyId = getActiveMusicPartyId();
section.hidden = false;
list.innerHTML = parties.map((p) => `
<div class="partyCard">
@ -56,7 +66,9 @@ function renderParties(root: HTMLElement, parties: LobbyPartyDto[]): void {
>--:--</span>
</span>
</div>
<button class="joinPartyBtn" data-party-id="${p.partyId}">Rejoindre</button>
${p.partyId === myMusicPartyId
? '<span class="hint partyCardMine">En écoute</span>'
: `<button class="joinPartyBtn" data-party-id="${p.partyId}">Rejoindre</button>`}
</div>
`).join('');

View file

@ -1,2 +1,4 @@
<div class="pageHeader"><h1></h1></div>
<input id="searchInput" class="searchBar" type="search" placeholder="Rechercher dans cette bibliothèque..." />
<p class="hint searchEmpty hide">Aucun résultat.</p>
<div id="grid" class="grid"><p class="hint">Chargement...</p></div>

View file

@ -3,7 +3,7 @@ import { getArtists, getLibraries, getLibraryItems } from '../../api/embyApi';
import { createParty } from '../../api/party';
import { navigate } from '../../router';
import type { RouteParams } from '../../router';
import { tileHtml, wireTileButtons } from '../../tiles';
import { tileHtml, wireSearchFilter, wireTileButtons } from '../../tiles';
/**
* Generic "one library" page, reached from the sidebar. What it renders
@ -45,10 +45,12 @@ export async function renderLibrary(root: HTMLElement, params: RouteParams): Pro
: await getLibraryItems(library.id);
} catch {
grid.innerHTML = '<p class="error">Impossible de charger cette bibliothèque.</p>';
root.querySelector('#searchInput')!.remove();
return;
}
if (!items.length) {
grid.innerHTML = '<p class="hint">Bibliothèque vide.</p>';
root.querySelector('#searchInput')!.remove();
return;
}
@ -57,4 +59,5 @@ export async function renderLibrary(root: HTMLElement, params: RouteParams): Pro
const party = await createParty(itemId);
navigate(`/party/${party.partyId}`);
});
wireSearchFilter(root.querySelector('#searchInput') as HTMLInputElement, root, '.tile');
}

View file

@ -1103,6 +1103,52 @@ same way a video's does (reasoned to, per "Streaming" above, not
observed), and the real audio quality/gapless-ness of playback through a
transcode. First things to check once this gets a real retest.
## Music bar polish + catalog search
Four smaller, real-usage-driven fixes, once the music bar and the
persistent-overlay redesign had actually been used for a while:
- **Vote-skip's "X/Y needed" didn't update on a join/leave.** The
majority threshold depends on the connected-member count, which
`renderMembers` (the `"members"` push) updates - but the label itself
was only ever recomputed inside `applyMusicState` (a `"queueUpdated"`
push - an actual vote or track change). Two different events, each
only refreshing half of what the label needs. Fixed by caching the
last `MusicPublicState` (`lastMusicState`) and factoring the label math
into its own `updateVoteSkipLabel()`, called from *both* handlers now.
- **The listen party you're already in still offered "Rejoindre" on the
home page.** `Home.ts`'s card rendering had no way to know "this one's
mine" - fixed by checking each card's `partyId` against
`musicBar/MusicBar.ts`'s own `getActiveMusicPartyId()` (already tracked
client-side, no server change needed) and swapping the button for an
inert "En écoute" label on that one card. Video has no equivalent case:
its player is a full-screen takeover, so you can't be looking at this
list while inside one.
- **A progress bar for the music bar.** Read-only, deliberately - see
`MusicBar.html`'s own comment: there's no host to arbitrate a seek in
this model, and `MusicQueueService` has no "jump to position" concept
server-side at all today (a real protocol addition, not a UI-only
change - out of scope for what was asked here). The fill's width is
just `audio.currentTime / durationSeconds`, ticked by the same interval
that already updates the position text.
- **Search bars for the catalog pages** (`views/library`, `views/artist`,
`views/album`). Client-side only, filtering whatever's already
rendered/fetched rather than a server round trip per keystroke - a
library page tops out at a few hundred items (`getLibraryItems`'s own
`Limit`), comfortably inside "just filter the DOM" territory. Shared as
`tiles.ts`'s `wireSearchFilter` so the three pages behave identically.
The existing `/api/catalog/search` endpoint (`CatalogService.search`,
video types only, never wired into any UI) is a separate, unrelated
feature - a cross-library global search, not asked for here - left
as-is.
Verified against the real compiled bundle for all four: a `"members"`
push alone (no vote, no track change) updates the skip label; the home
page's own active party shows "En écoute" while every other card still
shows "Rejoindre"; the progress fill tracks a simulated `currentTime`
correctly; typing into a library's search box hides non-matching tiles
live and the "Aucun résultat" notice appears only once nothing matches.
## TypeScript and a service-oriented architecture
A full rewrite, requested directly: both `server/` and `client/` are now