diff --git a/client/src/musicBar/MusicBar.css b/client/src/musicBar/MusicBar.css
index eec558a..83a3e0c 100644
--- a/client/src/musicBar/MusicBar.css
+++ b/client/src/musicBar/MusicBar.css
@@ -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;
diff --git a/client/src/musicBar/MusicBar.html b/client/src/musicBar/MusicBar.html
index 982cfc3..31ae95a 100644
--- a/client/src/musicBar/MusicBar.html
+++ b/client/src/musicBar/MusicBar.html
@@ -5,6 +5,11 @@
Rien en attente.
+
+
+
diff --git a/client/src/musicBar/MusicBar.ts b/client/src/musicBar/MusicBar.ts
index 4e3d06b..b923c22 100644
--- a/client/src/musicBar/MusicBar.ts
+++ b/client/src/musicBar/MusicBar.ts
@@ -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) => `
diff --git a/client/src/tiles.css b/client/src/tiles.css
index 35022b8..2f46fc6 100644
--- a/client/src/tiles.css
+++ b/client/src/tiles.css
@@ -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));
diff --git a/client/src/tiles.ts b/client/src/tiles.ts
index 36764c0..2a25ea9 100644
--- a/client/src/tiles.ts
+++ b/client/src/tiles.ts
@@ -87,3 +87,32 @@ export function wireTileButtons(container: ParentNode, onStart: (itemId: string)
});
});
}
+
+/**
+ * Wires a search 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(itemSelector).forEach((el) => {
+ const matches = !q || (el.textContent || '').toLowerCase().includes(q);
+ el.classList.toggle('hide', !matches);
+ if (matches) anyVisible = true;
+ });
+ const emptyNotice = scope.querySelector('.searchEmpty');
+ if (emptyNotice) emptyNotice.classList.toggle('hide', anyVisible || !q);
+ });
+}
diff --git a/client/src/views/album/Album.html b/client/src/views/album/Album.html
index 5fa44f0..d3417c2 100644
--- a/client/src/views/album/Album.html
+++ b/client/src/views/album/Album.html
@@ -7,4 +7,6 @@
Voir sur Emby
+
+
Aucun résultat.
diff --git a/client/src/views/album/Album.ts b/client/src/views/album/Album.ts
index 3e437f8..eafdf1c 100644
--- a/client/src/views/album/Album.ts
+++ b/client/src/views/album/Album.ts
@@ -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 = '
diff --git a/client/src/views/artist/Artist.ts b/client/src/views/artist/Artist.ts
index 8dc88c5..62a0d96 100644
--- a/client/src/views/artist/Artist.ts
+++ b/client/src/views/artist/Artist.ts
@@ -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('')
- : '
Aucun album trouvé pour cet artiste.
';
+ if (!albums.length) {
+ grid.innerHTML = '
Aucun album trouvé pour cet artiste.
';
+ root.querySelector('#searchInput')!.remove();
+ return;
+ }
+ grid.innerHTML = albums.map(tileHtml).join('');
+ wireSearchFilter(root.querySelector('#searchInput') as HTMLInputElement, root, '.tile');
}
diff --git a/client/src/views/home/Home.css b/client/src/views/home/Home.css
index 1becd23..d6209c9 100644
--- a/client/src/views/home/Home.css
+++ b/client/src/views/home/Home.css
@@ -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;
+}
diff --git a/client/src/views/home/Home.ts b/client/src/views/home/Home.ts
index 4d8f9ae..45af6e7 100644
--- a/client/src/views/home/Home.ts
+++ b/client/src/views/home/Home.ts
@@ -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) => `
';
+ 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');
}
diff --git a/docs/architecture.md b/docs/architecture.md
index c47c3f2..e364d4c 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -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