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 <noreply@anthropic.com>
This commit is contained in:
parent
fc678b313c
commit
b2310216ce
18 changed files with 590 additions and 444 deletions
23
README.md
23
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
* 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<PartyPublicState> {
|
||||
|
|
@ -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());
|
||||
|
||||
|
|
|
|||
2
client/src/global.d.ts
vendored
2
client/src/global.d.ts
vendored
|
|
@ -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 {
|
||||
|
|
|
|||
85
client/src/musicBar/MusicBar.css
Normal file
85
client/src/musicBar/MusicBar.css
Normal 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; }
|
||||
}
|
||||
23
client/src/musicBar/MusicBar.html
Normal file
23
client/src/musicBar/MusicBar.html
Normal 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">▶</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>
|
||||
247
client/src/musicBar/MusicBar.ts
Normal file
247
client/src/musicBar/MusicBar.ts
Normal 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('');
|
||||
}
|
||||
|
|
@ -9,4 +9,5 @@
|
|||
<button class="linklike sidebarLogout" id="logout"></button>
|
||||
</aside>
|
||||
<main id="mainContent" class="mainContent"></main>
|
||||
<div id="musicBar" class="musicBar"></div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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<HTMLElement> {
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) => `
|
||||
<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 / 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>
|
||||
`).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.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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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">▶</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>
|
||||
|
|
@ -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">← 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">← 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(() => {});
|
||||
};
|
||||
}
|
||||
|
|
@ -8,7 +8,7 @@ import { navigate } from '../../router';
|
|||
import type { CleanupFn, RouteParams } from '../../router';
|
||||
import { colorForName, escapeHtml, initialsFor } from '../../tiles';
|
||||
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 DRIFT_TOLERANCE_SECONDS = 1.5;
|
||||
|
|
@ -37,8 +37,15 @@ const PAUSE_ICON_SVG = `
|
|||
|
||||
/**
|
||||
* Route `/party/:id` - dispatches on the party's mediaType, fetched here
|
||||
* once, since both renderVideoPlayer and renderMusicPlayer need to know it
|
||||
* before they can render anything at all.
|
||||
* once. A video party still gets the full-page theater-mode takeover
|
||||
* (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 -
|
||||
* 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 -
|
||||
* 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
|
||||
* 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 {
|
||||
const partyId = params.id as string;
|
||||
|
|
@ -58,12 +66,18 @@ export function renderPartyPlayer(root: HTMLElement, params: RouteParams): Clean
|
|||
|
||||
getParty(partyId).then((party) => {
|
||||
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) => {
|
||||
if (cancelled) return;
|
||||
// Full error (not just .message) to the console - this also catches a
|
||||
// rendering bug in renderVideoPlayer/renderMusicPlayer itself, not just
|
||||
// a genuine "party not found", so the stack trace matters here.
|
||||
// rendering bug in renderVideoPlayer itself, not just a genuine "party
|
||||
// not found", so the stack trace matters here.
|
||||
console.error('[watchparty player] failed to open party', err);
|
||||
alert(err.message);
|
||||
navigate('/home');
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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
|
||||
|
||||
- Playback always goes through `hls.js` against whatever Emby's
|
||||
|
|
|
|||
Loading…
Reference in a new issue