Fix settings menu visibility bug; make the music player a persistent bar
Some checks failed
Publier l'image Docker / publish (push) Has been skipped
Publier l'image Docker / test (push) Failing after 8s

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 <noreply@anthropic.com>
This commit is contained in:
Nicolas 2026-09-14 10:43:12 +02:00
parent fc678b313c
commit b2310216ce
18 changed files with 590 additions and 444 deletions

View file

@ -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 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, playback — anyone can add more tracks to the shared queue at any time,
anyone can pause/resume, and skipping the current track needs a anyone can pause/resume, and skipping the current track needs a
majority vote from whoever's currently listening. The player has its majority vote from whoever's currently listening. The player itself is
own inline "Ajouter des morceaux" browser, so building the queue a persistent bar docked at the bottom of the screen, not a page — the
doesn't mean leaving the page. 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 Everything — catalog, images, video, audio, sync — flows through this
app's own backend, which proxies whatever it needs from Emby server-side. 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, small shared component styles (presence,
detail header, track list) detail header, track list)
src/shell/Shell.* persistent sidebar + #mainContent the router swaps 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/login/Login.* sign-in form
src/views/home/Home.* live parties carousel + per-library "recently added" 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/library/Library.* one library's tile grid (or artist grid, for music)
src/views/series/Series.* Series detail + season tabs + episode list src/views/series/Series.* Series detail + season tabs + episode list
src/views/artist/Artist.* one artist's albums src/views/artist/Artist.* one artist's albums
src/views/album/Album.* one album's tracks src/views/album/Album.* one album's tracks - each track starts or queues
src/views/player/Player.* the /party/:id route's dispatcher + the video player onto the active listen party (see musicBar/ above)
src/views/musicPlayer/MusicPlayer.* the music half - now-playing, queue, inline browse+add 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 src/WatchParty/ The earlier Emby-plugin attempt (C#, unrelated to the
TypeScript above). Builds fine and installs, but Emby 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 feature — see docs/architecture.md
- [x] Automated server test suite (`node:test`) + CI job gating the Docker - [x] Automated server test suite (`node:test`) + CI job gating the Docker
publish on typecheck+test+build passing — see docs/architecture.md 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) - [ ] Try a full party end-to-end with two real accounts (still the main gap)
- [ ] Tune transcoding target / support direct play when possible - [ ] Tune transcoding target / support direct play when possible

View file

@ -41,10 +41,11 @@ export function getParty(partyId: string): Promise<PartyPublicState> {
/** /**
* Music only: adds a track to a party's shared queue. REST, not a socket * Music only: adds a track to a party's shared queue. REST, not a socket
* event, deliberately - unlike voteSkip/setMusicPaused below, this needs * event, deliberately - unlike voteSkip/setMusicPaused below, this needs
* to work from the album-browsing page and the music player's own inline * to work from the album-browsing page (views/album/Album.ts), which
* browser, neither of which is guaranteed to be the page with the live * holds no live connection to the party at all - only the persistent
* connection to the party being added into. `track` is whatever ItemDto * musicBar/MusicBar.ts does (see its header comment for why browsing and
* shape the catalog endpoints already returned - see * the live party are two separate things now). `track` is whatever
* ItemDto shape the catalog endpoints already returned - see
* server/src/services/MusicQueueService.ts. * server/src/services/MusicQueueService.ts.
*/ */
export function addTrackToQueue(partyId: string, track: MusicTrackInput): Promise<PartyPublicState> { export function addTrackToQueue(partyId: string, track: MusicTrackInput): Promise<PartyPublicState> {
@ -117,7 +118,7 @@ export function connectParty(partyId: string, callbacks: PartyConnectionCallback
if (onItemChanged) socket.on('itemChanged', (m) => onItemChanged(m as ItemChangedMsg)); if (onItemChanged) socket.on('itemChanged', (m) => onItemChanged(m as ItemChangedMsg));
if (onHostChanged) socket.on('hostChanged', () => onHostChanged()); if (onHostChanged) socket.on('hostChanged', () => onHostChanged());
if (onPauseRequested) socket.on('pauseRequested', (m) => onPauseRequested(m as PauseRequestedMsg)); 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 (onError) socket.on('errorMessage', (m) => onError((m as { message: string }).message));
if (onClose) socket.on('disconnect', () => onClose()); if (onClose) socket.on('disconnect', () => onClose());

View file

@ -31,7 +31,7 @@ interface AppClientSocket {
declare function io(): AppClientSocket; declare function io(): AppClientSocket;
// --- hls.js (CDN global, see client/index.html) ----------------------------- // --- 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. // actually use.
interface HlsErrorData { interface HlsErrorData {

View file

@ -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; }
}

View file

@ -0,0 +1,23 @@
<div id="musicBarQueue" class="musicBarQueue hide">
<div class="pageHeader">
<h2>File d'attente</h2>
</div>
<div id="queueList" class="trackList"><p class="hint">Rien en attente.</p></div>
</div>
<div class="musicBarRow">
<div id="presenceOverlay" class="presenceOverlay musicBarPresence"></div>
<img id="trackImage" class="musicBarImage" alt="" onerror="this.classList.add('noImage')" />
<div class="musicBarInfo">
<strong id="trackName">-</strong>
<span class="hint" id="trackArtist"></span>
</div>
<span id="trackPosition" class="hint musicBarPosition">0:00 / 0:00</span>
<button class="iconBtn" id="playPauseBtn" title="Lecture / pause" aria-label="Lecture / pause">&#9654;</button>
<button class="linklike" id="voteSkipBtn">Passer (vote)</button>
<button class="linklike" id="toggleQueueBtn">File d'attente</button>
<button class="linklike" id="leaveBtn">Quitter</button>
</div>
<audio id="barAudio"></audio>

View file

@ -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) => `
<div class="trackRow">
<span class="trackName">${escapeHtml(t.itemName)}</span>
<span class="hint">${escapeHtml([t.artistName, t.albumName].filter(Boolean).join(' - '))}</span>
<span class="trackDuration">${t.durationSeconds ? formatDuration(t.durationSeconds) : ''}</span>
<span class="hint">Ajouté par ${escapeHtml(t.addedByUserName)}</span>
</div>`).join('')
: '<p class="hint">Rien en attente.</p>';
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) => `
<div class="avatar" style="background:${colorForName(m.userName)}" title="${escapeHtml(m.userName)}">
${escapeHtml(initialsFor(m.userName))}
</div>`).join('');
}

View file

@ -9,4 +9,5 @@
<button class="linklike sidebarLogout" id="logout"></button> <button class="linklike sidebarLogout" id="logout"></button>
</aside> </aside>
<main id="mainContent" class="mainContent"></main> <main id="mainContent" class="mainContent"></main>
<div id="musicBar" class="musicBar"></div>
</div> </div>

View file

@ -4,11 +4,16 @@
import './Shell.css'; import './Shell.css';
import html from './Shell.html'; import html from './Shell.html';
import { auth, getLibraries, logout } from '../api/embyApi'; import { auth, getLibraries, logout } from '../api/embyApi';
import { initMusicBar } from '../musicBar/MusicBar';
export async function renderShell(appEl: HTMLElement): Promise<HTMLElement> { export async function renderShell(appEl: HTMLElement): Promise<HTMLElement> {
appEl.innerHTML = html; appEl.innerHTML = html;
appEl.querySelector('#logout')!.textContent = `Se déconnecter (${auth.userName || ''})`; 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 () => { appEl.querySelector('#logout')!.addEventListener('click', async () => {
await logout(); await logout();
// Simplest reliable way back to a bare login screen: a real reload, // Simplest reliable way back to a bare login screen: a real reload,

View file

@ -119,4 +119,15 @@ button[disabled] { opacity: .5; cursor: default; }
margin-bottom: 20px; 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; }

View file

@ -1,7 +1,7 @@
/* Shared base for the "who's here" circle, used by both the video player /* 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 (Player.css adds the click-to-request-pause states on top) and the
music player (which just shows presence, no per-avatar interaction - music bar (which just shows presence, no per-avatar interaction - see
see MusicPlayer.css's .musicPresence for its positioning tweak). */ MusicBar.css's .musicBarPresence for its size/positioning tweak). */
.presenceOverlay { .presenceOverlay {
display: flex; display: flex;
gap: 8px; gap: 8px;

View file

@ -1,6 +1,5 @@
/* Shared track-list rows, used by both views/album (an album's tracks) /* 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 and musicBar/MusicBar (the current queue, in its collapsible popup). */
own track lists). */
.trackList { .trackList {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View file

@ -66,7 +66,7 @@ export interface MusicTrack {
} }
/** What this app's own catalog DTO (ItemDto) looks like once reshaped into /** 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 { export interface MusicTrackInput {
itemId: string; itemId: string;
itemName: string; itemName: string;

View file

@ -2,11 +2,11 @@ import './Album.css';
import '../../styles/trackList.css'; import '../../styles/trackList.css';
import html from './Album.html'; import html from './Album.html';
import { getAlbumTracks, getItem } from '../../api/embyApi'; import { getAlbumTracks, getItem } from '../../api/embyApi';
import { createParty } from '../../api/party'; import { addTrackToQueue, createParty } from '../../api/party';
import { navigate } from '../../router'; import { getActiveMusicPartyId, joinMusicParty } from '../../musicBar/MusicBar';
import type { RouteParams } from '../../router';
import { escapeHtml, formatDuration } from '../../tiles'; 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 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; 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) => ` listEl.innerHTML = tracks.map((t) => `
<div class="trackRow"> <div class="trackRow">
<span class="trackIndex">${t.indexNumber != null ? t.indexNumber : ''}</span> <span class="trackIndex">${t.indexNumber != null ? t.indexNumber : ''}</span>
<span class="trackName">${escapeHtml(t.name)}</span> <span class="trackName">${escapeHtml(t.name)}</span>
<span class="trackDuration">${t.durationTicks ? formatDuration(t.durationTicks / TICKS_PER_SECOND) : ''}</span> <span class="trackDuration">${t.durationTicks ? formatDuration(t.durationTicks / TICKS_PER_SECOND) : ''}</span>
<button class="startPartyBtn" data-item-id="${t.id}">Écouter ensemble</button> <button class="startPartyBtn" data-item-id="${t.id}">${getActiveMusicPartyId() ? addLabel : startLabel}</button>
</div> </div>
`).join(''); `).join('');
listEl.querySelectorAll<HTMLButtonElement>('.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<HTMLButtonElement>('.startPartyBtn').forEach((b) => {
if (!b.disabled) b.textContent = getActiveMusicPartyId() ? addLabel : startLabel;
});
}
listEl.querySelectorAll<HTMLButtonElement>('.startPartyBtn').forEach((btn, i) => {
const track = tracks[i] as ItemDto;
btn.addEventListener('click', async () => { btn.addEventListener('click', async () => {
btn.disabled = true; btn.disabled = true;
btn.textContent = 'Démarrage...'; const activePartyId = getActiveMusicPartyId();
try { try {
const party = await createParty(btn.dataset.itemId as string); if (activePartyId) {
navigate(`/party/${party.partyId}`); 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) { } catch (err) {
alert((err as Error).message); alert((err as Error).message);
btn.disabled = false; btn.disabled = false;
btn.textContent = 'Écouter ensemble'; refreshLabels();
} }
}); });
}); });

View file

@ -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;
}

View file

@ -1,28 +0,0 @@
<div class="pageHeader">
<h1>Écoute partagée</h1>
<button class="linklike" id="leaveBtn">Quitter</button>
</div>
<div id="presenceOverlay" class="presenceOverlay musicPresence"></div>
<div class="nowPlaying" id="nowPlaying">
<img id="trackImage" class="nowPlayingImage" alt="" onerror="this.classList.add('noImage')" />
<div class="nowPlayingInfo">
<strong id="trackName">-</strong>
<span class="hint" id="trackArtist"></span>
<div class="nowPlayingControls">
<button class="iconBtn" id="playPauseBtn" title="Lecture / pause" aria-label="Lecture / pause">&#9654;</button>
<span id="trackPosition" class="hint">0:00 / 0:00</span>
<button class="linklike" id="voteSkipBtn">Passer (vote)</button>
</div>
</div>
</div>
<audio id="audio"></audio>
<div class="pageHeader">
<h2>File d'attente</h2>
<button class="linklike" id="toggleBrowseBtn">Ajouter des morceaux</button>
</div>
<div id="queueList" class="trackList"><p class="hint">Rien en attente.</p></div>
<div id="browsePanel" class="browsePanel hide"></div>

View file

@ -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 <audio src="...m3u8"> alone would silently just not play anywhere
// but Safari. `elapsedSeconds`/`isPaused` are applied once the manifest
// is actually parsed, not immediately after setting src - setting
// currentTime before a fresh source has loaded metadata is unreliable
// and gets silently reset by most browsers.
function loadTrack(track: MusicCurrentPublic, elapsedSeconds: number, isPaused: boolean) {
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: this runs from inside applyMusicState,
// which every "queueUpdated" push calls - an uncaught throw here would
// abort that entire handler (and, the first time it happens, the
// player's whole synchronous init - see Player.ts's dispatcher), taking
// the queue list and vote button down with a problem that's really
// just "this one track's audio failed to load". A skip vote is still
// this feature's actual recovery path (see MusicQueueService) - this
// just needs to not crash on the way there.
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 musicPlayer] 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 musicPlayer] no HLS support and hls.js unavailable');
}
} catch (err) {
console.error('[watchparty musicPlayer] failed to load track audio', err);
}
}
function applyMusicState(music: MusicPublicState) {
// Queue/vote-button/play-icon updates happen first, unconditionally -
// deliberately BEFORE the track-loading branch below, which touches
// hls.js and could throw (a bad manifest, a network hiccup). Same
// ordering the video player already uses (see onItemChanged in
// Player.ts, which updates the title/next-button before calling
// loadVideo): a media-loading failure should never also take the
// queue list and vote tally down with it, since they're not actually
// related to whether THIS particular track's audio loaded.
playPauseBtn.textContent = music.current && !music.current.isPaused ? '⏸' : '▶';
playPauseBtn.disabled = !music.current;
voteSkipBtn.disabled = !music.current;
// "needed" mirrors the exact math MusicQueueService uses server-side
// (strict majority of currently connected members) - computed here from
// the member list the "members" event already provides, rather than
// MusicQueueService's own broadcast repeating that count on every
// single queue change (see its getState()'s own comment on this).
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) => `
<div class="trackRow">
<span class="trackName">${escapeHtml(t.itemName)}</span>
<span class="hint">${escapeHtml([t.artistName, t.albumName].filter(Boolean).join(' - '))}</span>
<span class="trackDuration">${t.durationSeconds ? formatDuration(t.durationSeconds) : ''}</span>
<span class="hint">Ajouté par ${escapeHtml(t.addedByUserName)}</span>
</div>`).join('')
: '<p class="hint">Rien en attente.</p>';
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 sendLocalPlaystate() {
if (suppressLocalEvents || !conn) return;
conn.setMusicPaused(audio.paused);
}
audio.addEventListener('play', sendLocalPlaystate);
audio.addEventListener('pause', sendLocalPlaystate);
playPauseBtn.addEventListener('click', () => {
if (!conn) return;
conn.setMusicPaused(!audio.paused);
});
voteSkipBtn.addEventListener('click', () => conn?.voteSkip());
// Ticks the visible position label between server updates - the same
// "derive it client-side from a snapshot" approach as the home page's
// party-elapsed-time labels, rather than the server pushing this every
// second for every listener.
positionTicker = window.setInterval(() => {
if (!currentItemId) return;
trackPosition.textContent = `${formatDuration(audio.currentTime)} / ${formatDuration(currentDurationSeconds)}`;
}, 500);
function renderMembers(members: SocketMember[]) {
latestMembers = members;
presenceOverlay.innerHTML = members.map((m) => `
<div class="avatar" style="background:${colorForName(m.userName)}" title="${escapeHtml(m.userName)}">
${escapeHtml(initialsFor(m.userName))}
</div>`).join('');
}
// --- Inline "add tracks" browser: artist -> album -> tracks, entirely
// within this page (see class comment for why not a route navigation). ---
let browsing = false;
root.querySelector('#toggleBrowseBtn')!.addEventListener('click', () => {
browsing = !browsing;
browsePanel.classList.toggle('hide', !browsing);
if (browsing) loadMusicLibraries();
});
async function loadMusicLibraries() {
browsePanel.innerHTML = '<p class="hint">Chargement...</p>';
try {
const libraries = (await getLibraries()).filter((l) => l.collectionType === 'music');
if (!libraries.length) {
browsePanel.innerHTML = '<p class="hint">Aucune bibliothèque musicale.</p>';
return;
}
// Almost always exactly one - skip straight to its artists rather
// than making the common case click through a one-item list.
if (libraries.length === 1) {
loadArtists(libraries[0]!.id);
} else {
browsePanel.innerHTML = renderBrowseList(libraries.map((l) => ({ id: l.id, name: l.name })), false);
wireBrowseList((id) => loadArtists(id));
}
} catch {
browsePanel.innerHTML = '<p class="error">Impossible de charger les bibliothèques.</p>';
}
}
async function loadArtists(libraryId: string) {
browsePanel.innerHTML = '<p class="hint">Chargement...</p>';
try {
const artists = await getArtists(libraryId);
browsePanel.innerHTML = renderBrowseList(artists.map((a) => ({ id: a.id, name: a.name })), false);
wireBrowseList((id) => loadAlbums(id));
} catch {
browsePanel.innerHTML = '<p class="error">Impossible de charger les artistes.</p>';
}
}
async function loadAlbums(artistId: string) {
browsePanel.innerHTML = '<p class="hint">Chargement...</p>';
try {
const albums = await getArtistAlbums(artistId);
browsePanel.innerHTML = renderBrowseList(albums.map((a) => ({ id: a.id, name: a.year ? `${a.name} (${a.year})` : a.name })), true);
wireBrowseList((id) => loadTracks(id));
} catch {
browsePanel.innerHTML = '<p class="error">Impossible de charger les albums.</p>';
}
}
async function loadTracks(albumId: string) {
browsePanel.innerHTML = '<p class="hint">Chargement...</p>';
try {
const tracks = await getAlbumTracks(albumId);
browsePanel.innerHTML = `
<button class="linklike browseBack">&larr; Retour</button>
${tracks.map((t) => {
const input: MusicTrackInput = {
itemId: t.id, itemName: t.name, artistName: t.artistName, albumName: t.albumName,
itemImageUrl: t.imageUrl, durationTicks: t.durationTicks
};
return `
<div class="trackRow">
<span class="trackIndex">${t.indexNumber != null ? t.indexNumber : ''}</span>
<span class="trackName">${escapeHtml(t.name)}</span>
<span class="trackDuration">${t.durationTicks ? formatDuration(t.durationTicks / 10000000) : ''}</span>
<button class="addTrackBtn" data-track="${encodeURIComponent(JSON.stringify(input))}">Ajouter</button>
</div>`;
}).join('')}`;
browsePanel.querySelector('.browseBack')!.addEventListener('click', () => loadMusicLibraries());
browsePanel.querySelectorAll<HTMLButtonElement>('.addTrackBtn').forEach((btn) => {
btn.addEventListener('click', async () => {
btn.disabled = true;
const originalLabel = btn.textContent as string;
try {
await addTrackToQueue(partyId, JSON.parse(decodeURIComponent(btn.dataset.track as string)));
btn.textContent = 'Ajouté !';
setTimeout(() => { btn.textContent = originalLabel; btn.disabled = false; }, 1500);
} catch (err) {
alert((err as Error).message);
btn.disabled = false;
}
});
});
} catch {
browsePanel.innerHTML = '<p class="error">Impossible de charger les morceaux.</p>';
}
}
function renderBrowseList(items: { id: string; name: string }[], withBack: boolean): string {
const back = withBack ? '<button class="linklike browseBack">&larr; Retour</button>' : '';
const body = items.length
? items.map((i) => `<button class="linklike browseItem" data-id="${escapeHtml(i.id)}">${escapeHtml(i.name)}</button>`).join('')
: '<p class="hint">Rien à afficher.</p>';
return `${back}<div class="browseList">${body}</div>`;
}
function wireBrowseList(onPick: (id: string) => void) {
const back = browsePanel.querySelector('.browseBack');
if (back) back.addEventListener('click', () => loadMusicLibraries());
browsePanel.querySelectorAll<HTMLButtonElement>('.browseItem').forEach((btn) => {
btn.addEventListener('click', () => onPick(btn.dataset.id as string));
});
}
// --- Init ---
if (party.music) applyMusicState(party.music);
conn = connectParty(partyId, {
onMembers: renderMembers,
onQueueUpdated: applyMusicState,
onError: (message) => alert(message),
onClose: () => {}
});
(root.querySelector('#leaveBtn') as HTMLButtonElement).addEventListener('click', () => navigate('/home'));
return function cleanup() {
if (positionTicker) clearInterval(positionTicker);
if (hls) hls.destroy();
audio.pause();
audio.removeAttribute('src');
conn?.leave();
leaveParty(partyId).catch(() => {});
};
}

View file

@ -8,7 +8,7 @@ import { navigate } from '../../router';
import type { CleanupFn, RouteParams } from '../../router'; import type { CleanupFn, RouteParams } from '../../router';
import { colorForName, escapeHtml, initialsFor } from '../../tiles'; import { colorForName, escapeHtml, initialsFor } from '../../tiles';
import type { ItemChangedMsg, PartyPublicState, PauseRequestedMsg, SocketMember } from '../../types'; import type { ItemChangedMsg, PartyPublicState, PauseRequestedMsg, SocketMember } from '../../types';
import { renderMusicPlayer } from '../musicPlayer/MusicPlayer'; import { joinMusicParty, leaveMusicParty } from '../../musicBar/MusicBar';
const TICKS_PER_SECOND = 10000000; // Emby/.NET convention: 1 tick = 100ns const TICKS_PER_SECOND = 10000000; // Emby/.NET convention: 1 tick = 100ns
const DRIFT_TOLERANCE_SECONDS = 1.5; const DRIFT_TOLERANCE_SECONDS = 1.5;
@ -37,8 +37,15 @@ const PAUSE_ICON_SVG = `
/** /**
* Route `/party/:id` - dispatches on the party's mediaType, fetched here * Route `/party/:id` - dispatches on the party's mediaType, fetched here
* once, since both renderVideoPlayer and renderMusicPlayer need to know it * once. A video party still gets the full-page theater-mode takeover
* before they can render anything at all. * (renderVideoPlayer below); a music party does NOT render a page here at
* all - it attaches the persistent bottom bar (musicBar/MusicBar.ts) and
* bounces back to the page the user was already effectively on, so a
* "Rejoindre"/"Écouter ensemble" click never yanks them into a dedicated
* player screen the way starting a video does. Joining/creating a video
* party also always leaves any active listen party first (leaveMusicParty
* is a no-op if there isn't one) - you can't stay in a shared audio queue
* while also taking over the screen for a video watch party.
* *
* Has to return its cleanup function *synchronously* (route()'s contract - * Has to return its cleanup function *synchronously* (route()'s contract -
* see router.ts), but which cleanup function that even is depends on a * see router.ts), but which cleanup function that even is depends on a
@ -47,7 +54,8 @@ const PAUSE_ICON_SVG = `
* calls whatever the real cleanup turns out to be once the fetch settles - * calls whatever the real cleanup turns out to be once the fetch settles -
* or nothing at all, correctly, if the fetch is still pending or failed * or nothing at all, correctly, if the fetch is still pending or failed
* when it runs (nothing was ever set up in that case - no socket, no * when it runs (nothing was ever set up in that case - no socket, no
* timer, nothing to leak). * timer, nothing to leak). The music branch never sets `cleanup` at all -
* musicBar/MusicBar.ts manages its own lifetime independently of this route.
*/ */
export function renderPartyPlayer(root: HTMLElement, params: RouteParams): CleanupFn { export function renderPartyPlayer(root: HTMLElement, params: RouteParams): CleanupFn {
const partyId = params.id as string; const partyId = params.id as string;
@ -58,12 +66,18 @@ export function renderPartyPlayer(root: HTMLElement, params: RouteParams): Clean
getParty(partyId).then((party) => { getParty(partyId).then((party) => {
if (cancelled) return; if (cancelled) return;
cleanup = (party.mediaType === 'music' ? renderMusicPlayer : renderVideoPlayer)(root, partyId, party) || (() => {}); if (party.mediaType === 'music') {
joinMusicParty(partyId, party);
navigate('/home');
return;
}
leaveMusicParty();
cleanup = renderVideoPlayer(root, partyId, party) || (() => {});
}).catch((err) => { }).catch((err) => {
if (cancelled) return; if (cancelled) return;
// Full error (not just .message) to the console - this also catches a // Full error (not just .message) to the console - this also catches a
// rendering bug in renderVideoPlayer/renderMusicPlayer itself, not just // rendering bug in renderVideoPlayer itself, not just a genuine "party
// a genuine "party not found", so the stack trace matters here. // not found", so the stack trace matters here.
console.error('[watchparty player] failed to open party', err); console.error('[watchparty player] failed to open party', err);
alert(err.message); alert(err.message);
navigate('/home'); navigate('/home');

View file

@ -1112,6 +1112,121 @@ mismatch) rather than the build commands themselves being wrong - still,
a real build is the first thing to try after this lands, before assuming a real build is the first thing to try after this lands, before assuming
the Dockerfile is right. the Dockerfile is right.
## A `.hide` specificity bug: one CSS rule silently beating another
The player's settings gear (audio/subtitle/quality) stayed permanently
open regardless of the `hide` class ever being toggled on it. Root cause:
`.hide { display: none; }` (`styles/base.css`) and `.settingsPanel {
display: flex; ... }` (`views/player/Player.css`) are both single-class
selectors - **equal specificity** - so when an element carries both
classes, the cascade falls back to source order for whichever property
they both set, and `esbuild`'s CSS-from-JS bundling had `Player.css`
(imported inside a view module) land after `base.css` (imported once,
globally, at the top of `app.ts`) in the final `app.css`. The *later* rule
wins a specificity tie, so `.settingsPanel`'s own `display: flex` always
overrode `.hide`'s `display: none` - not a bug in the toggle logic at all,
the class was being added and removed correctly the whole time, it just
had no effect on this one element.
Fixed at the source of the utility class, not the one call site:
`.hide { display: none !important; }`. `!important` beats a same-
specificity non-`!important` rule regardless of bundling/source order, so
this closes off the entire bug *class* - any future component CSS that
sets `display` for its own visible state (several already do - tiles,
the shell, every carousel) is safe to combine with `.hide` without
silently losing to bundling order the way `.settingsPanel` did. Verified
by rebuilding and driving the real compiled bundle (mocked
`fetch`/`window.io`/`window.Hls`, same methodology as the TypeScript
rewrite above): before the fix, `getComputedStyle(settingsPanel).display`
read `"flex"` even with the `hide` class present; after, it correctly
reads `"none"` until the gear button is clicked and `"none"` again once
dismissed.
## Music bar: an overlay you keep browsing under, not a page
The music player was originally its own route (`/party/:id`, the
now-deleted `views/musicPlayer/`) - deliberately not theater-mode, but
still a full page swapped into `#mainContent` like any other view (see
"Music: participative playlist" above). That was fine for playback and
for a small embedded "browse and add" panel, but it meant navigating
anywhere else - the sidebar, an album to queue up more tracks, home -
tore the whole thing down: `router.ts` calls a route's cleanup and
replaces `#mainContent` on every hash change, and this view's cleanup
called `leaveParty`. There was no way to be "in" a listen party and also
browsing the rest of the app at the same time.
**Fix: move the player out of the router entirely.** `musicBar/MusicBar.ts`
is a Shell-level singleton - mounted once by `Shell.ts` into a `#musicBar`
element that's a sibling of `#mainContent`, never touched by route
changes - rendered as a fixed bottom bar (`position: fixed; bottom: 0`,
below the video player's `.playerWrap` z-index so a video takeover still
correctly covers it, though that's now moot in practice - see below).
Because it's a singleton rather than something re-rendered per visit, its
lifecycle isn't the render()-returns-a-CleanupFn shape every route uses -
it never registers as a route at all. Instead it exposes three plain
functions: `initMusicBar(root)` (wire the static DOM once, at boot),
`joinMusicParty(partyId, party)` and `leaveMusicParty()` (attach/detach a
live party on top of that already-wired DOM, called from wherever a party
is actually joined/left instead of from a route's own init/cleanup).
`views/player/Player.ts`'s dispatcher (`renderPartyPlayer`) is the one
place every "start/join a party" call site already funnels through
(home's live list, a tile's "Lancer une watch party", an album's "Écouter
ensemble" all just `navigate('/party/:id')` and let this route sort out
what kind it is), which made it the natural single choke point for two
new rules instead of touching every call site individually:
- **Music**: don't render a page at all - call `joinMusicParty` and
`navigate('/home')` right back (a no-op bounce when the user was
already on home, which is true for every reachable call site except
Album's - see below). No cleanup function is ever set for this branch;
`musicBar/MusicBar.ts` manages its own lifetime from here on.
- **Video**: call `leaveMusicParty()` unconditionally (a harmless no-op
if nothing's active) before `renderVideoPlayer`. This is the "playing
something else exits the listen party" rule, asked for directly - one
call, covers every entry point that can lead to a video party, since
they all pass through this same dispatcher first.
`views/album/Album.ts` is the one place that needed its own change beyond
the dispatcher: it's the only per-track "start a party" UI left now that
the music player's own inline browse panel is gone (browsing happens by
just navigating there normally, which is the whole point). Each track's
button does double duty - start a new party, or add to whichever one is
already active - decided by `getActiveMusicPartyId()` **at click time**,
not just when the row was rendered. That distinction matters: the button
*label* is only ever as fresh as the last render (harmless staleness - it
might still say "Écouter ensemble" for a few seconds after a party
started from elsewhere), but the actual *behavior* on click is always
correct because it re-checks live state at the moment it matters, not the
snapshot from render time. After a successful click, every button in the
list is relabeled in place from current state (starting a party from
track 3 means tracks 1, 2 and 4 can now be queued too) - a plain function
call from the click handler, not a subscription to musicBar's state, which
was deliberately not built: it would need the same mutable-box
synchronous-cleanup pattern `Player.ts` uses (Album's render is `async`,
and a subscription would need unsubscribing via a route cleanup to avoid
leaking a closure over detached DOM nodes) for a live-update that only
matters if someone else's action changes the bar's state while this exact
page happens to be open - a real but narrow case, and the click-time
re-check already makes the *only* thing that has to be correct safe
either way.
Also starting a party from Album no longer navigates to `/party/:id` at
all - it calls `joinMusicParty` directly and stays put, so queuing up
several tracks from the same album is just clicking their buttons too,
with no route round-trip in between.
Verified the same way as the rest of this app's client-side testing: the
real compiled bundle, `fetch`/`window.io`/`window.Hls` mocked, driven
through actual clicks and hash navigation. Confirmed: starting a party
from an album track leaves the sidebar and page in place and correctly
relabels the other tracks; adding a track posts to the already-active
party's id, not a new one; navigating into a video party hides the bar,
disconnects its socket, and posts a real `leave` for the music party
before the video player ever renders; a party's host still sees the
settings gear open/close correctly inside that same run (see the `.hide`
fix above).
## Known limitations ## Known limitations
- Playback always goes through `hls.js` against whatever Emby's - Playback always goes through `hls.js` against whatever Emby's