From b2310216ce46d6478025fe9411cfdf430ef48c6e Mon Sep 17 00:00:00 2001 From: Nicolas Date: Mon, 14 Sep 2026 10:43:12 +0200 Subject: [PATCH] Fix settings menu visibility bug; make the music player a persistent bar Fix: player settings menu stuck permanently open ---------------------------------------------- .hide { display: none; } and Player.css's .settingsPanel { display: flex; ... } are both single-class selectors - equal specificity - so whichever one esbuild happened to bundle later into app.css won the `display` property on an element carrying both classes. Player.css's own display:flex always landed after base.css's .hide, so #settingsPanel showed regardless of the hide class ever being toggled - the toggle logic itself was correct the whole time, it just had no visible effect. Fixed at the utility class, not the one call site: `.hide` now uses `display: none !important`, which wins a same-specificity tie regardless of bundling order - closes off the whole bug class, since several other components already set their own `display` for a visible state. Feature: music player is now a persistent bottom bar, not a page ------------------------------------------------------------------ The music player was its own route (/party/:id -> views/musicPlayer/), which meant navigating anywhere else - the sidebar, an album to queue up more tracks - tore the whole thing down (router.ts calls a route's cleanup, which used to call leaveParty, on every hash change). No way to browse and listen at the same time. - client/src/musicBar/MusicBar.ts (new): a Shell-level singleton, mounted once into a #musicBar element that's a sibling of #mainContent, never touched by route changes. Renders as a fixed bottom bar. Exposes initMusicBar/joinMusicParty/leaveMusicParty instead of the router's render()-returns-cleanup pattern, since it never registers as a route. - views/player/Player.ts's dispatcher: a music party no longer renders a page - it calls joinMusicParty() and bounces back to /home (a no-op for every reachable call site except album's). A video party now calls leaveMusicParty() first (harmless no-op if nothing's active) - starting or joining a video watch party always leaves the listen party, since every party-join call site already funnels through this one dispatcher. - views/album/Album.ts: the last per-track "start a party" UI now that the music player's own inline browse panel is gone (browsing just means navigating there normally). Each track's button starts a new party or adds to whichever one is already active, decided by getActiveMusicPartyId() at CLICK time (not render time) so the action taken is always correct even if the label is momentarily stale; every button is relabeled in place after a successful click. Starting a party from here no longer navigates away at all. - views/musicPlayer/ deleted entirely (MusicPlayer.ts/.html/.css) - superseded by musicBar/. - Comment updates in global.d.ts/presence.css/trackList.css/party.ts/ types.ts that named the old module by path. Verified both fixes by rebuilding and driving the real compiled bundle through a local static server, with fetch/window.io/window.Hls mocked: starting a party from an album track leaves the sidebar/page in place and relabels the other tracks; adding a track posts to the already-active party's id; navigating into a video party hides the bar, disconnects its socket, and posts a real leave for the music party before the video player renders; the settings gear opens/closes correctly for the host in that same run. Co-Authored-By: Claude Sonnet 5 --- README.md | 23 +- client/src/api/party.ts | 11 +- client/src/global.d.ts | 2 +- client/src/musicBar/MusicBar.css | 85 +++++ client/src/musicBar/MusicBar.html | 23 ++ client/src/musicBar/MusicBar.ts | 247 ++++++++++++++ client/src/shell/Shell.html | 1 + client/src/shell/Shell.ts | 5 + client/src/styles/base.css | 13 +- client/src/styles/presence.css | 4 +- client/src/styles/trackList.css | 3 +- client/src/types.ts | 2 +- client/src/views/album/Album.ts | 63 +++- client/src/views/musicPlayer/MusicPlayer.css | 61 ---- client/src/views/musicPlayer/MusicPlayer.html | 28 -- client/src/views/musicPlayer/MusicPlayer.ts | 320 ------------------ client/src/views/player/Player.ts | 28 +- docs/architecture.md | 115 +++++++ 18 files changed, 590 insertions(+), 444 deletions(-) create mode 100644 client/src/musicBar/MusicBar.css create mode 100644 client/src/musicBar/MusicBar.html create mode 100644 client/src/musicBar/MusicBar.ts delete mode 100644 client/src/views/musicPlayer/MusicPlayer.css delete mode 100644 client/src/views/musicPlayer/MusicPlayer.html delete mode 100644 client/src/views/musicPlayer/MusicPlayer.ts diff --git a/README.md b/README.md index 50d3a3c..4580f9a 100644 --- a/README.md +++ b/README.md @@ -35,9 +35,13 @@ including why this replaced an earlier Emby-plugin attempt (still in Album → Track) to start a listening party. There's no host for playback — anyone can add more tracks to the shared queue at any time, anyone can pause/resume, and skipping the current track needs a - majority vote from whoever's currently listening. The player has its - own inline "Ajouter des morceaux" browser, so building the queue - doesn't mean leaving the page. + majority vote from whoever's currently listening. The player itself is + a persistent bar docked at the bottom of the screen, not a page — the + sidebar and the rest of the app stay fully usable while it plays, so + adding more tracks is just browsing to an album and clicking a track + (its button reads "Ajouter à la file" instead of "Écouter ensemble" + once a party's already going). Starting or joining a video watch party + always leaves the listen party first — the two don't run at once. Everything — catalog, images, video, audio, sync — flows through this app's own backend, which proxies whatever it needs from Emby server-side. @@ -117,14 +121,18 @@ client/ Frontend: TypeScript, esbuild-bundled to client/dist small shared component styles (presence, detail header, track list) src/shell/Shell.* persistent sidebar + #mainContent the router swaps + src/musicBar/MusicBar.* persistent bottom-bar listen party - NOT a route, + mounted once by Shell.ts so it survives navigation + (see docs/architecture.md's "Music bar" section) src/views/login/Login.* sign-in form src/views/home/Home.* live parties carousel + per-library "recently added" src/views/library/Library.* one library's tile grid (or artist grid, for music) src/views/series/Series.* Series detail + season tabs + episode list src/views/artist/Artist.* one artist's albums - src/views/album/Album.* one album's tracks - src/views/player/Player.* the /party/:id route's dispatcher + the video player - src/views/musicPlayer/MusicPlayer.* the music half - now-playing, queue, inline browse+add + src/views/album/Album.* one album's tracks - each track starts or queues + onto the active listen party (see musicBar/ above) + src/views/player/Player.* the /party/:id route's dispatcher (video takeover, + or hands off to musicBar/ + bounces back for music) src/WatchParty/ The earlier Emby-plugin attempt (C#, unrelated to the TypeScript above). Builds fine and installs, but Emby @@ -201,6 +209,9 @@ Then put it behind your existing reverse proxy like everything else feature — see docs/architecture.md - [x] Automated server test suite (`node:test`) + CI job gating the Docker publish on typecheck+test+build passing — see docs/architecture.md +- [x] Music player as a persistent bottom bar instead of a page — browse + and queue tracks while listening; starting a video party always + leaves the listen party first — see docs/architecture.md - [ ] Try a full party end-to-end with two real accounts (still the main gap) - [ ] Tune transcoding target / support direct play when possible diff --git a/client/src/api/party.ts b/client/src/api/party.ts index 0afb515..27057e1 100644 --- a/client/src/api/party.ts +++ b/client/src/api/party.ts @@ -41,10 +41,11 @@ export function getParty(partyId: string): Promise { /** * Music only: adds a track to a party's shared queue. REST, not a socket * event, deliberately - unlike voteSkip/setMusicPaused below, this needs - * to work from the album-browsing page and the music player's own inline - * browser, neither of which is guaranteed to be the page with the live - * connection to the party being added into. `track` is whatever ItemDto - * shape the catalog endpoints already returned - see + * to work from the album-browsing page (views/album/Album.ts), which + * holds no live connection to the party at all - only the persistent + * musicBar/MusicBar.ts does (see its header comment for why browsing and + * the live party are two separate things now). `track` is whatever + * ItemDto shape the catalog endpoints already returned - see * server/src/services/MusicQueueService.ts. */ export function addTrackToQueue(partyId: string, track: MusicTrackInput): Promise { @@ -117,7 +118,7 @@ export function connectParty(partyId: string, callbacks: PartyConnectionCallback if (onItemChanged) socket.on('itemChanged', (m) => onItemChanged(m as ItemChangedMsg)); if (onHostChanged) socket.on('hostChanged', () => onHostChanged()); if (onPauseRequested) socket.on('pauseRequested', (m) => onPauseRequested(m as PauseRequestedMsg)); - if (onQueueUpdated) socket.on('queueUpdated', (m) => onQueueUpdated(m as MusicPublicState)); // music parties only - see MusicPlayer + if (onQueueUpdated) socket.on('queueUpdated', (m) => onQueueUpdated(m as MusicPublicState)); // music parties only - see musicBar/MusicBar.ts if (onError) socket.on('errorMessage', (m) => onError((m as { message: string }).message)); if (onClose) socket.on('disconnect', () => onClose()); diff --git a/client/src/global.d.ts b/client/src/global.d.ts index b655f15..2ab3b49 100644 --- a/client/src/global.d.ts +++ b/client/src/global.d.ts @@ -31,7 +31,7 @@ interface AppClientSocket { declare function io(): AppClientSocket; // --- hls.js (CDN global, see client/index.html) ----------------------------- -// Same idea - only the surface client/src/views/player and musicPlayer +// Same idea - only the surface client/src/views/player and musicBar // actually use. interface HlsErrorData { diff --git a/client/src/musicBar/MusicBar.css b/client/src/musicBar/MusicBar.css new file mode 100644 index 0000000..eec558a --- /dev/null +++ b/client/src/musicBar/MusicBar.css @@ -0,0 +1,85 @@ +/* Persistent bottom overlay for a music listen party - see MusicBar.ts's + header comment for why this is a Shell-level component instead of a + route (views/player/Player.css's .playerWrap is the video equivalent, + and stays a full route takeover on purpose - a shared audio queue has + no image to "theater mode" around, and the whole point here is staying + able to browse/add tracks while it plays). Sits above .mainContent, at + a lower z-index than .playerWrap (20) so a video party's takeover still + correctly covers it - moot in practice since joining a video party + calls leaveMusicParty() first (see Player.ts), but cheap to get right. */ +.musicBar { + position: fixed; + left: 0; + right: 0; + bottom: 0; + z-index: 15; + background: var(--bg-header); + border-top: 1px solid rgba(255, 255, 255, .08); + box-shadow: 0 -4px 16px rgba(0, 0, 0, .3); +} + +.musicBarRow { + display: flex; + align-items: center; + gap: 12px; + padding: 8px 16px; + flex-wrap: wrap; +} + +/* Smaller than the video player's presence row (Player.css/presence.css's + 36px default) - this bar is a thin strip, not an overlay on top of a + large video frame. */ +.musicBarPresence .avatar { + width: 26px; + height: 26px; + font-size: 10px; +} + +.musicBarImage { + width: 40px; + height: 40px; + object-fit: cover; + border-radius: 6px; + background: var(--panel); + flex-shrink: 0; +} + +.musicBarImage.noImage { visibility: hidden; } + +.musicBarInfo { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + margin-right: auto; +} + +.musicBarInfo strong { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 13px; +} + +.musicBarPosition { font-variant-numeric: tabular-nums; white-space: nowrap; } + +/* Pops up ABOVE the bar (same pattern as Player.css's .settingsPanel above + its gear button) - collapsed by default, see MusicBar.ts's toggleQueueBtn + handler. */ +.musicBarQueue { + max-width: 480px; + max-height: 50vh; + overflow-y: auto; + margin: 0 16px; + padding: 14px 14px 0; + background: var(--bg-header); + border: 1px solid rgba(255, 255, 255, .08); + border-bottom: none; + border-radius: 10px 10px 0 0; +} + +@media (max-width: 720px) { + .musicBarRow { justify-content: center; } + .musicBarInfo { margin-right: 0; flex-basis: 100%; order: -1; text-align: center; } + .musicBarQueue { max-width: none; margin: 0; border-radius: 0; } +} diff --git a/client/src/musicBar/MusicBar.html b/client/src/musicBar/MusicBar.html new file mode 100644 index 0000000..982cfc3 --- /dev/null +++ b/client/src/musicBar/MusicBar.html @@ -0,0 +1,23 @@ +
+ +

Rien en attente.

+
+ +
+
+ + +
+ - + +
+ + 0:00 / 0:00 + + + + +
+ diff --git a/client/src/musicBar/MusicBar.ts b/client/src/musicBar/MusicBar.ts new file mode 100644 index 0000000..5f1a5f3 --- /dev/null +++ b/client/src/musicBar/MusicBar.ts @@ -0,0 +1,247 @@ +import '../styles/presence.css'; +import '../styles/trackList.css'; +import './MusicBar.css'; +import html from './MusicBar.html'; +import { audioStreamUrl } from '../api/embyApi'; +import { connectParty, leaveParty } from '../api/party'; +import type { PartyConnection } from '../api/party'; +import { colorForName, escapeHtml, formatDuration, initialsFor } from '../tiles'; +import type { MusicCurrentPublic, MusicPublicState, PartyPublicState, SocketMember } from '../types'; + +/** + * Persistent, app-lifetime "listen party" bar - mounted ONCE by Shell.ts + * (see renderShell), living outside the router's #mainContent so it + * survives every navigation instead of being torn down like a normal + * route (contrast with the old, deleted views/musicPlayer/, which WAS a + * route - see docs/architecture.md). This is what actually lets someone + * browse the library/an artist/an album and add tracks to the shared + * queue while still listening, which a full-page route can't do without + * losing player state on every click away from it. + * + * Because this module is a singleton rather than something re-rendered + * per view, its lifecycle is three plain functions instead of the + * render()-returns-a-CleanupFn pattern the rest of the client uses: + * initMusicBar() wires the static DOM once at boot, joinMusicParty()/ + * leaveMusicParty() attach/detach a live party on top of it. There is no + * per-visit `root.innerHTML = html` here - the DOM (and its event + * listeners) are set up exactly once and just get shown/hidden and + * re-populated in place, which is also why this doesn't need router.ts's + * cleanup-function contract at all: it's never registered as a route + * handler in the first place - see views/player/Player.ts's dispatcher, + * which calls joinMusicParty/leaveMusicParty instead of rendering a route + * for music parties. + */ + +let barEl: HTMLElement | null = null; +let activePartyId: string | null = null; +let conn: PartyConnection | null = null; +let hls: HlsInstance | null = null; +let currentItemId: string | null = null; +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[] = []; + +/** 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 + * new party or add to this one's queue instead (see Album.ts). */ +export function getActiveMusicPartyId(): string | null { + return activePartyId; +} + +/** Wires the bar's static DOM once - called by Shell.ts right after it + * renders the shell chrome. `root` stays in the DOM (just hidden via + * .hide) for the rest of the session, sibling to #mainContent. */ +export function initMusicBar(root: HTMLElement): void { + barEl = root; + root.innerHTML = html; + root.classList.add('hide'); + + const audio = barEl.querySelector('#barAudio') as HTMLAudioElement; + const playPauseBtn = barEl.querySelector('#playPauseBtn') as HTMLButtonElement; + const voteSkipBtn = barEl.querySelector('#voteSkipBtn') as HTMLButtonElement; + const toggleQueueBtn = barEl.querySelector('#toggleQueueBtn')!; + const queuePanel = barEl.querySelector('#musicBarQueue')!; + const leaveBtn = barEl.querySelector('#leaveBtn')!; + + audio.addEventListener('play', () => { if (!suppressLocalEvents && conn) conn.setMusicPaused(false); }); + audio.addEventListener('pause', () => { if (!suppressLocalEvents && conn) conn.setMusicPaused(true); }); + playPauseBtn.addEventListener('click', () => conn?.setMusicPaused(!audio.paused)); + voteSkipBtn.addEventListener('click', () => conn?.voteSkip()); + 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). + positionTicker = window.setInterval(() => { + if (!currentItemId) return; + const position = barEl!.querySelector('#trackPosition')!; + position.textContent = `${formatDuration(audio.currentTime)} / ${formatDuration(currentDurationSeconds)}`; + }, 500); +} + +/** + * Attaches the bar to a music party - called from Player.ts's dispatcher + * (both for a freshly created party and for joining one already live) and + * directly from Album.ts when starting a new party without leaving the + * album page. Safe to call again for the SAME party (no-op) or for a + * DIFFERENT one (leaves the old one first, exactly like clicking "Quitter" + * would - a listener can only meaningfully be in one listen party at once). + */ +export function joinMusicParty(partyId: string, party: PartyPublicState): void { + if (activePartyId === partyId) return; + if (activePartyId) leaveMusicParty(); + + activePartyId = partyId; + barEl!.classList.remove('hide'); + if (party.music) applyMusicState(party.music); + + conn = connectParty(partyId, { + onMembers: renderMembers, + onQueueUpdated: applyMusicState, + onError: (message) => alert(message), + onClose: () => {} + }); +} + +/** + * Detaches the bar from whatever party it's currently in - the "Quitter" + * button's handler, and also called by Player.ts's dispatcher before + * entering ANY video party (see its own comment: you can't stay in a + * shared audio queue while also joining a video watch party, so starting + * one always exits the other first). A harmless no-op when nothing is + * active, so every call site can call it unconditionally rather than + * checking getActiveMusicPartyId() itself first. + */ +export function leaveMusicParty(): void { + if (!activePartyId) return; + const partyId = activePartyId; + activePartyId = null; + + if (hls) { hls.destroy(); hls = null; } + const audio = barEl!.querySelector('#barAudio') as HTMLAudioElement; + audio.pause(); + audio.removeAttribute('src'); + currentItemId = null; + + conn?.leave(); + conn = null; + + barEl!.classList.add('hide'); + barEl!.querySelector('#musicBarQueue')!.classList.add('hide'); + + leaveParty(partyId).catch(() => {}); +} + +// Same HLS pipeline as the video player and the old MusicPlayer.ts - +// Emby's PlaybackInfo negotiation isn't video-specific, so a music +// track's TranscodingUrl just as plausibly comes back HLS-shaped, and +// most browsers besides Safari have no native .m3u8 support at all. +function loadTrack(track: MusicCurrentPublic, elapsedSeconds: number, isPaused: boolean): void { + const audio = barEl!.querySelector('#barAudio') as HTMLAudioElement; + const trackImage = barEl!.querySelector('#trackImage') as HTMLImageElement; + const trackName = barEl!.querySelector('#trackName')!; + const trackArtist = barEl!.querySelector('#trackArtist')!; + + currentItemId = track.itemId; + currentDurationSeconds = track.durationSeconds || 0; + trackImage.src = track.itemImageUrl || ''; + trackImage.classList.remove('noImage'); + trackName.textContent = track.itemName || ''; + trackArtist.textContent = [track.artistName, track.albumName].filter(Boolean).join(' - '); + + if (hls) { hls.destroy(); hls = null; } + + const src = audioStreamUrl(track.itemId); + const resume = () => { + suppressLocalEvents = true; + audio.currentTime = elapsedSeconds || 0; + if (!isPaused) audio.play().catch(() => {}); + setTimeout(() => { suppressLocalEvents = false; }, 250); + }; + + // Caught, not left to propagate - see MusicPlayer.ts's original comment + // on this same try/catch: a bad manifest/network hiccup for THIS track + // must not take the queue list and vote button down with it, since a + // skip vote is the actual recovery path, not a crashed bar. + try { + if (window.Hls && window.Hls.isSupported()) { + hls = new window.Hls(); + hls.on(window.Hls.Events.MANIFEST_PARSED, resume); + hls.on(window.Hls.Events.ERROR, (_event, data) => { + if (data.fatal) console.error('[watchparty musicBar] HLS error', data); + }); + hls.loadSource(src); + hls.attachMedia(audio); + } else if (audio.canPlayType('application/vnd.apple.mpegurl')) { + audio.src = src; + audio.addEventListener('loadedmetadata', resume, { once: true }); + } else { + console.error('[watchparty musicBar] no HLS support and hls.js unavailable'); + } + } catch (err) { + console.error('[watchparty musicBar] failed to load track audio', err); + } +} + +function applyMusicState(music: MusicPublicState): void { + const playPauseBtn = barEl!.querySelector('#playPauseBtn') as HTMLButtonElement; + const voteSkipBtn = barEl!.querySelector('#voteSkipBtn') as HTMLButtonElement; + const queueList = barEl!.querySelector('#queueList')!; + const trackName = barEl!.querySelector('#trackName')!; + const trackArtist = barEl!.querySelector('#trackArtist')!; + const trackImage = barEl!.querySelector('#trackImage') as HTMLImageElement; + const audio = barEl!.querySelector('#barAudio') as HTMLAudioElement; + + // Queue/vote-button/play-icon updates happen first, unconditionally, + // deliberately BEFORE the track-loading branch below (see loadTrack's + // 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)'; + + queueList.innerHTML = music.queue.length + ? music.queue.map((t) => ` +
+ ${escapeHtml(t.itemName)} + ${escapeHtml([t.artistName, t.albumName].filter(Boolean).join(' - '))} + ${t.durationSeconds ? formatDuration(t.durationSeconds) : ''} + Ajouté par ${escapeHtml(t.addedByUserName)} +
`).join('') + : '

Rien en attente.

'; + + if (!music.current) { + currentItemId = null; + trackName.textContent = 'Rien ne joue pour le moment.'; + trackArtist.textContent = ''; + trackImage.src = ''; + if (hls) { hls.destroy(); hls = null; } + audio.pause(); + audio.removeAttribute('src'); + } else if (music.current.itemId !== currentItemId) { + loadTrack(music.current, music.current.elapsedSeconds, music.current.isPaused); + } else { + // Same track still playing - just resync pause state/position, don't reload the source. + suppressLocalEvents = true; + if (Math.abs(audio.currentTime - music.current.elapsedSeconds) > 2) { + audio.currentTime = music.current.elapsedSeconds; + } + if (music.current.isPaused && !audio.paused) audio.pause(); + if (!music.current.isPaused && audio.paused) audio.play().catch(() => {}); + setTimeout(() => { suppressLocalEvents = false; }, 250); + } +} + +function renderMembers(members: SocketMember[]): void { + latestMembers = members; + const presenceOverlay = barEl!.querySelector('#presenceOverlay')!; + presenceOverlay.innerHTML = members.map((m) => ` +
+ ${escapeHtml(initialsFor(m.userName))} +
`).join(''); +} diff --git a/client/src/shell/Shell.html b/client/src/shell/Shell.html index 711fce3..de9bd4e 100644 --- a/client/src/shell/Shell.html +++ b/client/src/shell/Shell.html @@ -9,4 +9,5 @@
+
diff --git a/client/src/shell/Shell.ts b/client/src/shell/Shell.ts index c7b8966..f10797d 100644 --- a/client/src/shell/Shell.ts +++ b/client/src/shell/Shell.ts @@ -4,11 +4,16 @@ import './Shell.css'; import html from './Shell.html'; import { auth, getLibraries, logout } from '../api/embyApi'; +import { initMusicBar } from '../musicBar/MusicBar'; export async function renderShell(appEl: HTMLElement): Promise { appEl.innerHTML = html; appEl.querySelector('#logout')!.textContent = `Se déconnecter (${auth.userName || ''})`; + // Mounted once, outside #mainContent, so a listen party survives + // navigating the router - see musicBar/MusicBar.ts's header comment. + initMusicBar(appEl.querySelector('#musicBar') as HTMLElement); + appEl.querySelector('#logout')!.addEventListener('click', async () => { await logout(); // Simplest reliable way back to a bare login screen: a real reload, diff --git a/client/src/styles/base.css b/client/src/styles/base.css index 66c82c8..92e550d 100644 --- a/client/src/styles/base.css +++ b/client/src/styles/base.css @@ -119,4 +119,15 @@ button[disabled] { opacity: .5; cursor: default; } margin-bottom: 20px; } -.hide { display: none; } +/* !important is deliberate, not cargo-culted: .hide is a generic toggle + applied from .ts (classList.add/remove/toggle('hide')) onto elements + whose OWN component CSS often also sets `display` for its visible state + (e.g. .settingsPanel { display: flex; ... } in Player.css). Without + !important, both rules are single-class selectors - equal specificity - + so the cascade falls back to source order, and whichever one esbuild + happens to bundle later wins; that's how #settingsPanel ended up + permanently visible regardless of the 'hide' class (its own display:flex + loaded after base.css's .hide in the bundled app.css). !important makes + .hide win unconditionally, independent of bundling order, for this + element and any future one with the same pattern. */ +.hide { display: none !important; } diff --git a/client/src/styles/presence.css b/client/src/styles/presence.css index b7020da..8f3209d 100644 --- a/client/src/styles/presence.css +++ b/client/src/styles/presence.css @@ -1,7 +1,7 @@ /* Shared base for the "who's here" circle, used by both the video player (Player.css adds the click-to-request-pause states on top) and the - music player (which just shows presence, no per-avatar interaction - - see MusicPlayer.css's .musicPresence for its positioning tweak). */ + music bar (which just shows presence, no per-avatar interaction - see + MusicBar.css's .musicBarPresence for its size/positioning tweak). */ .presenceOverlay { display: flex; gap: 8px; diff --git a/client/src/styles/trackList.css b/client/src/styles/trackList.css index adf227d..7d107e4 100644 --- a/client/src/styles/trackList.css +++ b/client/src/styles/trackList.css @@ -1,6 +1,5 @@ /* Shared track-list rows, used by both views/album (an album's tracks) - and views/musicPlayer (the current queue, and its inline browse panel's - own track lists). */ + and musicBar/MusicBar (the current queue, in its collapsible popup). */ .trackList { display: flex; flex-direction: column; diff --git a/client/src/types.ts b/client/src/types.ts index 5521d43..b56e832 100644 --- a/client/src/types.ts +++ b/client/src/types.ts @@ -66,7 +66,7 @@ export interface MusicTrack { } /** What this app's own catalog DTO (ItemDto) looks like once reshaped into - * a track add request - see tiles/album/musicPlayer's addTrackToQueue calls. */ + * a track add request - see Album.ts's addTrackToQueue calls. */ export interface MusicTrackInput { itemId: string; itemName: string; diff --git a/client/src/views/album/Album.ts b/client/src/views/album/Album.ts index d21bdaa..3e437f8 100644 --- a/client/src/views/album/Album.ts +++ b/client/src/views/album/Album.ts @@ -2,11 +2,11 @@ import './Album.css'; import '../../styles/trackList.css'; import html from './Album.html'; import { getAlbumTracks, getItem } from '../../api/embyApi'; -import { createParty } from '../../api/party'; -import { navigate } from '../../router'; -import type { RouteParams } from '../../router'; +import { addTrackToQueue, createParty } from '../../api/party'; +import { getActiveMusicPartyId, joinMusicParty } from '../../musicBar/MusicBar'; import { escapeHtml, formatDuration } from '../../tiles'; -import type { ItemDto } from '../../types'; +import type { ItemDto, MusicTrackInput } from '../../types'; +import type { RouteParams } from '../../router'; const TICKS_PER_SECOND = 10000000; // Emby/.NET convention: 1 tick = 100ns, same as everywhere else this app touches durations/positions @@ -46,26 +46,69 @@ export async function renderAlbum(root: HTMLElement, params: RouteParams): Promi return; } + // Label reflects whether a listen party is already active *at render + // time* - a harmless, occasionally-stale label (see refreshLabels() + // below for when it's corrected). The actual behavior on click always + // re-checks getActiveMusicPartyId() fresh, so clicking never does the + // wrong thing even if the label itself hasn't caught up yet (e.g. + // someone hit "Quitter" on the bar while this page was already open). + const startLabel = 'Écouter ensemble'; + const addLabel = 'Ajouter à la file'; + listEl.innerHTML = tracks.map((t) => `
${t.indexNumber != null ? t.indexNumber : ''} ${escapeHtml(t.name)} ${t.durationTicks ? formatDuration(t.durationTicks / TICKS_PER_SECOND) : ''} - +
`).join(''); - listEl.querySelectorAll('.startPartyBtn').forEach((btn) => { + function trackInputFor(t: ItemDto): MusicTrackInput { + return { + itemId: t.id, + itemName: t.name, + artistName: t.artistName, + albumName: t.albumName, + itemImageUrl: t.imageUrl, + durationTicks: t.durationTicks + }; + } + + // Re-labels every track button from current state, not just the one + // just clicked - starting a party from track 3 means tracks 1, 2 and 4 + // can now also be queued instead of starting a party of their own. + function refreshLabels(): void { + listEl.querySelectorAll('.startPartyBtn').forEach((b) => { + if (!b.disabled) b.textContent = getActiveMusicPartyId() ? addLabel : startLabel; + }); + } + + listEl.querySelectorAll('.startPartyBtn').forEach((btn, i) => { + const track = tracks[i] as ItemDto; btn.addEventListener('click', async () => { btn.disabled = true; - btn.textContent = 'Démarrage...'; + const activePartyId = getActiveMusicPartyId(); try { - const party = await createParty(btn.dataset.itemId as string); - navigate(`/party/${party.partyId}`); + if (activePartyId) { + btn.textContent = 'Ajout...'; + await addTrackToQueue(activePartyId, trackInputFor(track)); + } else { + btn.textContent = 'Démarrage...'; + // Attaches the persistent bottom bar directly and stays right + // here - no navigate() - so adding a few more tracks from this + // same album is just clicking their buttons too. See + // Player.ts's dispatcher for the equivalent when a party is + // instead joined from elsewhere (home's live list, etc). + const party = await createParty(track.id); + joinMusicParty(party.partyId, party); + } + btn.textContent = 'Ajouté !'; + setTimeout(() => { btn.disabled = false; refreshLabels(); }, 1500); } catch (err) { alert((err as Error).message); btn.disabled = false; - btn.textContent = 'Écouter ensemble'; + refreshLabels(); } }); }); diff --git a/client/src/views/musicPlayer/MusicPlayer.css b/client/src/views/musicPlayer/MusicPlayer.css deleted file mode 100644 index 1daec71..0000000 --- a/client/src/views/musicPlayer/MusicPlayer.css +++ /dev/null @@ -1,61 +0,0 @@ -/* Music player's "now playing" header. Not inside a .playerWrap like the - video player: there's no video image to declutter around, so this - renders as a normal page (sidebar stays up) rather than theater-mode - full-viewport - see Player.css for the contrast. */ -.nowPlaying { - display: flex; - gap: 16px; - align-items: center; - background: var(--bg-header); - border-radius: 10px; - padding: 14px; - margin-bottom: 20px; -} - -.nowPlayingImage { - width: 96px; - aspect-ratio: 1 / 1; - object-fit: cover; - border-radius: 8px; - background: var(--panel); - flex-shrink: 0; -} - -.nowPlayingImage.noImage { visibility: hidden; } - -.nowPlayingInfo { display: flex; flex-direction: column; gap: 4px; min-width: 0; } -.nowPlayingInfo strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } - -.nowPlayingControls { - display: flex; - align-items: center; - gap: 12px; - margin-top: 6px; -} - -.nowPlayingControls .iconBtn { font-size: 20px; } - -/* The presence row (styles/presence.css's .avatar) in normal page flow - here, not absolutely positioned over a video frame like the video - player's. */ -.musicPresence { margin-bottom: 16px; } - -.browsePanel { - background: var(--bg-header); - border-radius: 10px; - padding: 14px; - margin-top: 8px; -} - -.browseList { - display: flex; - flex-direction: column; - gap: 2px; -} - -.browseItem { - display: block; - width: 100%; - text-align: left; - padding: 8px 4px; -} diff --git a/client/src/views/musicPlayer/MusicPlayer.html b/client/src/views/musicPlayer/MusicPlayer.html deleted file mode 100644 index 4db71f2..0000000 --- a/client/src/views/musicPlayer/MusicPlayer.html +++ /dev/null @@ -1,28 +0,0 @@ - - -
- -
- -
- - - -
- - 0:00 / 0:00 - -
-
-
- - - -

Rien en attente.

- -
diff --git a/client/src/views/musicPlayer/MusicPlayer.ts b/client/src/views/musicPlayer/MusicPlayer.ts deleted file mode 100644 index f07af9a..0000000 --- a/client/src/views/musicPlayer/MusicPlayer.ts +++ /dev/null @@ -1,320 +0,0 @@ -import '../../styles/presence.css'; -import '../../styles/trackList.css'; -import './MusicPlayer.css'; -import html from './MusicPlayer.html'; -import { audioStreamUrl, getAlbumTracks, getArtistAlbums, getArtists, getLibraries } from '../../api/embyApi'; -import { addTrackToQueue, connectParty, leaveParty } from '../../api/party'; -import type { PartyConnection } from '../../api/party'; -import { navigate } from '../../router'; -import type { CleanupFn } from '../../router'; -import { colorForName, escapeHtml, formatDuration, initialsFor } from '../../tiles'; -import type { MusicCurrentPublic, MusicPublicState, MusicTrackInput, PartyPublicState, SocketMember } from '../../types'; - -/** - * Music half of the party route (see Player.ts's dispatcher). Deliberately - * NOT full-viewport/theater-mode like the video player: there's no image - * to declutter around, so the sidebar stays up and this renders as a - * normal page - which also means "browse and add a track" can happen - * inline, right here, instead of navigating away and losing player state. - * - * Permission model (chosen, not assumed - see session history): anyone can - * add a track to the shared queue at any time; anyone can pause/resume; - * skipping the current track needs a strict majority vote of currently - * connected members. No host-only controls at all - see - * server/src/services/MusicQueueService.ts for the full reasoning and the - * server-authoritative auto-advance timer that drives this without any - * single client having to be "in charge". - */ -export function renderMusicPlayer(root: HTMLElement, partyId: string, party: PartyPublicState): CleanupFn { - root.innerHTML = html; - - const audio = root.querySelector('#audio') as HTMLAudioElement; - const presenceOverlay = root.querySelector('#presenceOverlay')!; - const trackImage = root.querySelector('#trackImage') as HTMLImageElement; - const trackName = root.querySelector('#trackName')!; - const trackArtist = root.querySelector('#trackArtist')!; - const playPauseBtn = root.querySelector('#playPauseBtn') as HTMLButtonElement; - const trackPosition = root.querySelector('#trackPosition')!; - const voteSkipBtn = root.querySelector('#voteSkipBtn') as HTMLButtonElement; - const queueList = root.querySelector('#queueList')!; - const browsePanel = root.querySelector('#browsePanel')!; - - let conn: PartyConnection | null = null; - let hls: HlsInstance | null = null; - let currentItemId: string | null = null; - let currentDurationSeconds = 0; - let suppressLocalEvents = false; // avoid feeding our own correction back as a new event - same pattern as Player.ts - let positionTicker: number | null = null; - let latestMembers: SocketMember[] = []; - - // Same HLS pipeline as the video player (Player.ts's loadVideo) - Emby's - // PlaybackInfo negotiation isn't video-specific, so its TranscodingUrl - // for a music track just as plausibly comes back HLS-shaped, and most - // browsers (everything but Safari) have no native .m3u8 support at all - - // an