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 = '

Aucun morceau dans cet album.

'; + 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'); } diff --git a/client/src/views/artist/Artist.html b/client/src/views/artist/Artist.html index b5a91d1..12694c8 100644 --- a/client/src/views/artist/Artist.html +++ b/client/src/views/artist/Artist.html @@ -1,3 +1,5 @@ + +

Aucun résultat.

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) => `
@@ -56,7 +66,9 @@ function renderParties(root: HTMLElement, parties: LobbyPartyDto[]): void { >--:--
- + ${p.partyId === myMusicPartyId + ? 'En écoute' + : ``} `).join(''); diff --git a/client/src/views/library/Library.html b/client/src/views/library/Library.html index 7312e2e..434a9df 100644 --- a/client/src/views/library/Library.html +++ b/client/src/views/library/Library.html @@ -1,2 +1,4 @@ + +

Aucun résultat.

Chargement...

diff --git a/client/src/views/library/Library.ts b/client/src/views/library/Library.ts index b10d6aa..bf43972 100644 --- a/client/src/views/library/Library.ts +++ b/client/src/views/library/Library.ts @@ -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 = '

Impossible de charger cette bibliothèque.

'; + root.querySelector('#searchInput')!.remove(); return; } if (!items.length) { grid.innerHTML = '

Bibliothèque vide.

'; + 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