diff --git a/README.md b/README.md index 50d3a3c..4580f9a 100644 --- a/README.md +++ b/README.md @@ -35,9 +35,13 @@ including why this replaced an earlier Emby-plugin attempt (still in Album → Track) to start a listening party. There's no host for playback — anyone can add more tracks to the shared queue at any time, anyone can pause/resume, and skipping the current track needs a - majority vote from whoever's currently listening. The player has its - own inline "Ajouter des morceaux" browser, so building the queue - doesn't mean leaving the page. + majority vote from whoever's currently listening. The player itself is + a persistent bar docked at the bottom of the screen, not a page — the + sidebar and the rest of the app stay fully usable while it plays, so + adding more tracks is just browsing to an album and clicking a track + (its button reads "Ajouter à la file" instead of "Écouter ensemble" + once a party's already going). Starting or joining a video watch party + always leaves the listen party first — the two don't run at once. Everything — catalog, images, video, audio, sync — flows through this app's own backend, which proxies whatever it needs from Emby server-side. @@ -117,14 +121,18 @@ client/ Frontend: TypeScript, esbuild-bundled to client/dist small shared component styles (presence, detail header, track list) src/shell/Shell.* persistent sidebar + #mainContent the router swaps + src/musicBar/MusicBar.* persistent bottom-bar listen party - NOT a route, + mounted once by Shell.ts so it survives navigation + (see docs/architecture.md's "Music bar" section) src/views/login/Login.* sign-in form src/views/home/Home.* live parties carousel + per-library "recently added" src/views/library/Library.* one library's tile grid (or artist grid, for music) src/views/series/Series.* Series detail + season tabs + episode list src/views/artist/Artist.* one artist's albums - src/views/album/Album.* one album's tracks - src/views/player/Player.* the /party/:id route's dispatcher + the video player - src/views/musicPlayer/MusicPlayer.* the music half - now-playing, queue, inline browse+add + src/views/album/Album.* one album's tracks - each track starts or queues + onto the active listen party (see musicBar/ above) + src/views/player/Player.* the /party/:id route's dispatcher (video takeover, + or hands off to musicBar/ + bounces back for music) src/WatchParty/ The earlier Emby-plugin attempt (C#, unrelated to the TypeScript above). Builds fine and installs, but Emby @@ -201,6 +209,9 @@ Then put it behind your existing reverse proxy like everything else feature — see docs/architecture.md - [x] Automated server test suite (`node:test`) + CI job gating the Docker publish on typecheck+test+build passing — see docs/architecture.md +- [x] Music player as a persistent bottom bar instead of a page — browse + and queue tracks while listening; starting a video party always + leaves the listen party first — see docs/architecture.md - [ ] Try a full party end-to-end with two real accounts (still the main gap) - [ ] Tune transcoding target / support direct play when possible diff --git a/client/src/api/party.ts b/client/src/api/party.ts index 0afb515..27057e1 100644 --- a/client/src/api/party.ts +++ b/client/src/api/party.ts @@ -41,10 +41,11 @@ export function getParty(partyId: string): Promise { /** * Music only: adds a track to a party's shared queue. REST, not a socket * event, deliberately - unlike voteSkip/setMusicPaused below, this needs - * to work from the album-browsing page and the music player's own inline - * browser, neither of which is guaranteed to be the page with the live - * connection to the party being added into. `track` is whatever ItemDto - * shape the catalog endpoints already returned - see + * to work from the album-browsing page (views/album/Album.ts), which + * holds no live connection to the party at all - only the persistent + * musicBar/MusicBar.ts does (see its header comment for why browsing and + * the live party are two separate things now). `track` is whatever + * ItemDto shape the catalog endpoints already returned - see * server/src/services/MusicQueueService.ts. */ export function addTrackToQueue(partyId: string, track: MusicTrackInput): Promise { @@ -117,7 +118,7 @@ export function connectParty(partyId: string, callbacks: PartyConnectionCallback if (onItemChanged) socket.on('itemChanged', (m) => onItemChanged(m as ItemChangedMsg)); if (onHostChanged) socket.on('hostChanged', () => onHostChanged()); if (onPauseRequested) socket.on('pauseRequested', (m) => onPauseRequested(m as PauseRequestedMsg)); - if (onQueueUpdated) socket.on('queueUpdated', (m) => onQueueUpdated(m as MusicPublicState)); // music parties only - see MusicPlayer + if (onQueueUpdated) socket.on('queueUpdated', (m) => onQueueUpdated(m as MusicPublicState)); // music parties only - see musicBar/MusicBar.ts if (onError) socket.on('errorMessage', (m) => onError((m as { message: string }).message)); if (onClose) socket.on('disconnect', () => onClose()); diff --git a/client/src/global.d.ts b/client/src/global.d.ts index b655f15..2ab3b49 100644 --- a/client/src/global.d.ts +++ b/client/src/global.d.ts @@ -31,7 +31,7 @@ interface AppClientSocket { declare function io(): AppClientSocket; // --- hls.js (CDN global, see client/index.html) ----------------------------- -// Same idea - only the surface client/src/views/player and musicPlayer +// Same idea - only the surface client/src/views/player and musicBar // actually use. interface HlsErrorData { diff --git a/client/src/musicBar/MusicBar.css b/client/src/musicBar/MusicBar.css new file mode 100644 index 0000000..eec558a --- /dev/null +++ b/client/src/musicBar/MusicBar.css @@ -0,0 +1,85 @@ +/* Persistent bottom overlay for a music listen party - see MusicBar.ts's + header comment for why this is a Shell-level component instead of a + route (views/player/Player.css's .playerWrap is the video equivalent, + and stays a full route takeover on purpose - a shared audio queue has + no image to "theater mode" around, and the whole point here is staying + able to browse/add tracks while it plays). Sits above .mainContent, at + a lower z-index than .playerWrap (20) so a video party's takeover still + correctly covers it - moot in practice since joining a video party + calls leaveMusicParty() first (see Player.ts), but cheap to get right. */ +.musicBar { + position: fixed; + left: 0; + right: 0; + bottom: 0; + z-index: 15; + background: var(--bg-header); + border-top: 1px solid rgba(255, 255, 255, .08); + box-shadow: 0 -4px 16px rgba(0, 0, 0, .3); +} + +.musicBarRow { + display: flex; + align-items: center; + gap: 12px; + padding: 8px 16px; + flex-wrap: wrap; +} + +/* Smaller than the video player's presence row (Player.css/presence.css's + 36px default) - this bar is a thin strip, not an overlay on top of a + large video frame. */ +.musicBarPresence .avatar { + width: 26px; + height: 26px; + font-size: 10px; +} + +.musicBarImage { + width: 40px; + height: 40px; + object-fit: cover; + border-radius: 6px; + background: var(--panel); + flex-shrink: 0; +} + +.musicBarImage.noImage { visibility: hidden; } + +.musicBarInfo { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + margin-right: auto; +} + +.musicBarInfo strong { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 13px; +} + +.musicBarPosition { font-variant-numeric: tabular-nums; white-space: nowrap; } + +/* Pops up ABOVE the bar (same pattern as Player.css's .settingsPanel above + its gear button) - collapsed by default, see MusicBar.ts's toggleQueueBtn + handler. */ +.musicBarQueue { + max-width: 480px; + max-height: 50vh; + overflow-y: auto; + margin: 0 16px; + padding: 14px 14px 0; + background: var(--bg-header); + border: 1px solid rgba(255, 255, 255, .08); + border-bottom: none; + border-radius: 10px 10px 0 0; +} + +@media (max-width: 720px) { + .musicBarRow { justify-content: center; } + .musicBarInfo { margin-right: 0; flex-basis: 100%; order: -1; text-align: center; } + .musicBarQueue { max-width: none; margin: 0; border-radius: 0; } +} diff --git a/client/src/musicBar/MusicBar.html b/client/src/musicBar/MusicBar.html new file mode 100644 index 0000000..982cfc3 --- /dev/null +++ b/client/src/musicBar/MusicBar.html @@ -0,0 +1,23 @@ +
+ +

Rien en attente.

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

Rien en attente.

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

Rien en attente.

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