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>
This commit is contained in:
parent
137977893a
commit
5be839d2f6
14 changed files with 197 additions and 17 deletions
|
|
@ -18,6 +18,22 @@
|
||||||
box-shadow: 0 -4px 16px rgba(0, 0, 0, .3);
|
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 {
|
.musicBarRow {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,11 @@
|
||||||
<div id="queueList" class="trackList"><p class="hint">Rien en attente.</p></div>
|
<div id="queueList" class="trackList"><p class="hint">Rien en attente.</p></div>
|
||||||
</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 class="musicBarRow">
|
||||||
<div id="presenceOverlay" class="presenceOverlay musicBarPresence"></div>
|
<div id="presenceOverlay" class="presenceOverlay musicBarPresence"></div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 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 positionTicker: number | null = null;
|
||||||
let latestMembers: SocketMember[] = [];
|
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
|
/** 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
|
* 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'));
|
toggleQueueBtn.addEventListener('click', () => queuePanel.classList.toggle('hide'));
|
||||||
leaveBtn.addEventListener('click', () => leaveMusicParty());
|
leaveBtn.addEventListener('click', () => leaveMusicParty());
|
||||||
|
|
||||||
// Ticks the visible position label between server updates, same
|
// Ticks the visible position label AND the progress bar's fill between
|
||||||
// "derive it client-side from a snapshot" approach as Home's party-
|
// server updates, same "derive it client-side from a snapshot" approach
|
||||||
// elapsed labels and the old MusicPlayer.ts - runs continuously rather
|
// as Home's party-elapsed labels and the old MusicPlayer.ts - runs
|
||||||
// than only while a party is active (cheap: a no-op while !currentItemId).
|
// continuously rather than only while a party is active (cheap: a
|
||||||
|
// no-op while !currentItemId).
|
||||||
positionTicker = window.setInterval(() => {
|
positionTicker = window.setInterval(() => {
|
||||||
if (!currentItemId) return;
|
if (!currentItemId) return;
|
||||||
const position = barEl!.querySelector('#trackPosition')!;
|
const position = barEl!.querySelector('#trackPosition')!;
|
||||||
position.textContent = `${formatDuration(audio.currentTime)} / ${formatDuration(currentDurationSeconds)}`;
|
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);
|
}, 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -138,6 +146,8 @@ export function leaveMusicParty(): void {
|
||||||
audio.pause();
|
audio.pause();
|
||||||
audio.removeAttribute('src');
|
audio.removeAttribute('src');
|
||||||
currentItemId = null;
|
currentItemId = null;
|
||||||
|
lastMusicState = null;
|
||||||
|
latestMembers = [];
|
||||||
|
|
||||||
conn?.leave();
|
conn?.leave();
|
||||||
conn = null;
|
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 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 queueList = barEl!.querySelector('#queueList')!;
|
||||||
const trackName = barEl!.querySelector('#trackName')!;
|
const trackName = barEl!.querySelector('#trackName')!;
|
||||||
const trackArtist = barEl!.querySelector('#trackArtist')!;
|
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).
|
// own comment on why - same ordering as Player.ts/the old MusicPlayer.ts).
|
||||||
playPauseBtn.textContent = music.current && !music.current.isPaused ? '⏸' : '▶';
|
playPauseBtn.textContent = music.current && !music.current.isPaused ? '⏸' : '▶';
|
||||||
playPauseBtn.disabled = !music.current;
|
playPauseBtn.disabled = !music.current;
|
||||||
voteSkipBtn.disabled = !music.current;
|
updateVoteSkipLabel();
|
||||||
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)';
|
|
||||||
|
|
||||||
queueList.innerHTML = music.queue.length
|
queueList.innerHTML = music.queue.length
|
||||||
? music.queue.map((t) => `
|
? music.queue.map((t) => `
|
||||||
|
|
@ -236,6 +269,7 @@ function applyMusicState(music: MusicPublicState): void {
|
||||||
if (hls) { hls.destroy(); hls = null; }
|
if (hls) { hls.destroy(); hls = null; }
|
||||||
audio.pause();
|
audio.pause();
|
||||||
audio.removeAttribute('src');
|
audio.removeAttribute('src');
|
||||||
|
(barEl!.querySelector('#trackProgressFill') as HTMLElement).style.width = '0%';
|
||||||
} else if (music.current.itemId !== currentItemId) {
|
} else if (music.current.itemId !== currentItemId) {
|
||||||
loadTrack(music.current, music.current.elapsedSeconds, music.current.isPaused);
|
loadTrack(music.current, music.current.elapsedSeconds, music.current.isPaused);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -252,6 +286,7 @@ function applyMusicState(music: MusicPublicState): void {
|
||||||
|
|
||||||
function renderMembers(members: SocketMember[]): void {
|
function renderMembers(members: SocketMember[]): void {
|
||||||
latestMembers = members;
|
latestMembers = members;
|
||||||
|
updateVoteSkipLabel(); // the connected-member count just changed - see its own comment
|
||||||
const presenceOverlay = barEl!.querySelector('#presenceOverlay')!;
|
const presenceOverlay = barEl!.querySelector('#presenceOverlay')!;
|
||||||
presenceOverlay.innerHTML = members.map((m) => `
|
presenceOverlay.innerHTML = members.map((m) => `
|
||||||
<div class="avatar" style="background:${colorForName(m.userName)}" title="${escapeHtml(m.userName)}">
|
<div class="avatar" style="background:${colorForName(m.userName)}" title="${escapeHtml(m.userName)}">
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,17 @@
|
||||||
/* ---- Tile grid (library/artist pages) + tiles (shared with the home
|
/* ---- Tile grid (library/artist pages) + tiles (shared with the home
|
||||||
carousels) - see tiles.ts. ---- */
|
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 {
|
.grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,4 +7,6 @@
|
||||||
<a class="linklike" id="embyLink" target="_blank" rel="noopener">Voir sur Emby</a>
|
<a class="linklike" id="embyLink" target="_blank" rel="noopener">Voir sur Emby</a>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
<div id="trackList" class="trackList"></div>
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import html from './Album.html';
|
||||||
import { getAlbumTracks, getItem } from '../../api/embyApi';
|
import { getAlbumTracks, getItem } from '../../api/embyApi';
|
||||||
import { addTrackToQueue, createParty } from '../../api/party';
|
import { addTrackToQueue, createParty } from '../../api/party';
|
||||||
import { getActiveMusicPartyId, joinMusicParty } from '../../musicBar/MusicBar';
|
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 { ItemDto, MusicTrackInput } from '../../types';
|
||||||
import type { RouteParams } from '../../router';
|
import type { RouteParams } from '../../router';
|
||||||
|
|
||||||
|
|
@ -43,6 +43,7 @@ export async function renderAlbum(root: HTMLElement, params: RouteParams): Promi
|
||||||
const listEl = root.querySelector('#trackList')!;
|
const listEl = root.querySelector('#trackList')!;
|
||||||
if (!tracks.length) {
|
if (!tracks.length) {
|
||||||
listEl.innerHTML = '<p class="hint">Aucun morceau dans cet album.</p>';
|
listEl.innerHTML = '<p class="hint">Aucun morceau dans cet album.</p>';
|
||||||
|
root.querySelector('#searchInput')!.remove();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -112,4 +113,6 @@ export async function renderAlbum(root: HTMLElement, params: RouteParams): Promi
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
wireSearchFilter(root.querySelector('#searchInput') as HTMLInputElement, root, '.trackRow');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
<button class="linklike" id="back">← Retour</button>
|
<button class="linklike" id="back">← Retour</button>
|
||||||
<div class="pageHeader"><h1></h1></div>
|
<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>
|
<div id="grid" class="grid"></div>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import html from './Artist.html';
|
import html from './Artist.html';
|
||||||
import { getArtistAlbums, getItem } from '../../api/embyApi';
|
import { getArtistAlbums, getItem } from '../../api/embyApi';
|
||||||
import type { RouteParams } from '../../router';
|
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
|
/** Route `/artist/:id` - one artist's albums, reached from a music
|
||||||
* library's top-level grid (see views/library) via a MusicArtist tile's
|
* 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());
|
root.querySelector('#back')!.addEventListener('click', () => history.back());
|
||||||
|
|
||||||
const grid = root.querySelector('#grid')!;
|
const grid = root.querySelector('#grid')!;
|
||||||
grid.innerHTML = albums.length
|
if (!albums.length) {
|
||||||
? albums.map(tileHtml).join('')
|
grid.innerHTML = '<p class="hint">Aucun album trouvé pour cet artiste.</p>';
|
||||||
: '<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');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -42,3 +42,13 @@
|
||||||
|
|
||||||
.partyCardInfo strong { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
.partyCardInfo strong { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
.partyCard button { flex-shrink: 0; padding: 8px 14px; font-size: 13px; }
|
.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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import './Home.css';
|
||||||
import html from './Home.html';
|
import html from './Home.html';
|
||||||
import { getLibraries, getRecentItems } from '../../api/embyApi';
|
import { getLibraries, getRecentItems } from '../../api/embyApi';
|
||||||
import { connectLobby, createParty, joinParty } from '../../api/party';
|
import { connectLobby, createParty, joinParty } from '../../api/party';
|
||||||
|
import { getActiveMusicPartyId } from '../../musicBar/MusicBar';
|
||||||
import { navigate } from '../../router';
|
import { navigate } from '../../router';
|
||||||
import { escapeHtml, tileHtml, wireTileButtons } from '../../tiles';
|
import { escapeHtml, tileHtml, wireTileButtons } from '../../tiles';
|
||||||
import type { LobbyPartyDto } from '../../types';
|
import type { LobbyPartyDto } from '../../types';
|
||||||
|
|
@ -40,6 +41,15 @@ function renderParties(root: HTMLElement, parties: LobbyPartyDto[]): void {
|
||||||
return;
|
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;
|
section.hidden = false;
|
||||||
list.innerHTML = parties.map((p) => `
|
list.innerHTML = parties.map((p) => `
|
||||||
<div class="partyCard">
|
<div class="partyCard">
|
||||||
|
|
@ -56,7 +66,9 @@ function renderParties(root: HTMLElement, parties: LobbyPartyDto[]): void {
|
||||||
>--:--</span>
|
>--:--</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
`).join('');
|
`).join('');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,4 @@
|
||||||
<div class="pageHeader"><h1></h1></div>
|
<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>
|
<div id="grid" class="grid"><p class="hint">Chargement...</p></div>
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { getArtists, getLibraries, getLibraryItems } from '../../api/embyApi';
|
||||||
import { createParty } from '../../api/party';
|
import { createParty } from '../../api/party';
|
||||||
import { navigate } from '../../router';
|
import { navigate } from '../../router';
|
||||||
import type { RouteParams } 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
|
* 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);
|
: await getLibraryItems(library.id);
|
||||||
} catch {
|
} catch {
|
||||||
grid.innerHTML = '<p class="error">Impossible de charger cette bibliothèque.</p>';
|
grid.innerHTML = '<p class="error">Impossible de charger cette bibliothèque.</p>';
|
||||||
|
root.querySelector('#searchInput')!.remove();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!items.length) {
|
if (!items.length) {
|
||||||
grid.innerHTML = '<p class="hint">Bibliothèque vide.</p>';
|
grid.innerHTML = '<p class="hint">Bibliothèque vide.</p>';
|
||||||
|
root.querySelector('#searchInput')!.remove();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -57,4 +59,5 @@ export async function renderLibrary(root: HTMLElement, params: RouteParams): Pro
|
||||||
const party = await createParty(itemId);
|
const party = await createParty(itemId);
|
||||||
navigate(`/party/${party.partyId}`);
|
navigate(`/party/${party.partyId}`);
|
||||||
});
|
});
|
||||||
|
wireSearchFilter(root.querySelector('#searchInput') as HTMLInputElement, root, '.tile');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
observed), and the real audio quality/gapless-ness of playback through a
|
||||||
transcode. First things to check once this gets a real retest.
|
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
|
## TypeScript and a service-oriented architecture
|
||||||
|
|
||||||
A full rewrite, requested directly: both `server/` and `client/` are now
|
A full rewrite, requested directly: both `server/` and `client/` are now
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue