diff --git a/README.md b/README.md
index 8fc417c..89e0b89 100644
--- a/README.md
+++ b/README.md
@@ -31,25 +31,32 @@ including why this replaced an earlier Emby-plugin attempt (still in
initials, on the player itself). If you're not the host, clicking your
own avatar asks the host to pause — everyone sees the request, including
the host, without needing chat.
+6. Music works differently, on purpose: pick a track (browsing Artist →
+ 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.
-Everything — catalog, images, video, sync — flows through this app's own
-backend, which proxies whatever it needs from Emby server-side. The browser
-never talks to Emby directly and never holds an Emby access token; it only
-ever has a session cookie for this app.
-
-Music isn't part of this yet — it needs its own player and a collaborative
-playlist, different enough to be its own pass. The sidebar already lists a
-Music library if you have one; the page just says so for now.
+Everything — catalog, images, video, audio, sync — flows through this
+app's own backend, which proxies whatever it needs from Emby server-side.
+The browser never talks to Emby directly and never holds an Emby access
+token; it only ever has a session cookie for this app.
## Status
-Login, and the rendering/navigation logic for the sidebar/home/library/series
-pages, have been exercised end to end (login against the real Emby server
-with intentionally-wrong credentials; the pages against mocked API
-responses). See [docs/architecture.md](docs/architecture.md#assumptions-to-verify)
-for exactly what that does and doesn't confirm — the real Emby response
-shapes this now depends on (library `CollectionType`, seasons/episodes,
-`PlaybackInfo`'s media streams) and actual HLS playback are still unverified.
+Login, and the rendering/navigation logic for every page (sidebar, home,
+library, series, artist/album, both players), have been exercised end to
+end (login against the real Emby server with intentionally-wrong
+credentials; the pages against mocked API responses; the music queue's
+timer/vote/pause logic against the real server modules directly - no Emby
+involved there at all). See
+[docs/architecture.md](docs/architecture.md#assumptions-to-verify) for
+exactly what that does and doesn't confirm — the real Emby response shapes
+this now depends on (library `CollectionType`, seasons/episodes, artists/
+albums/tracks, `PlaybackInfo`'s media streams) and actual HLS playback
+(video or audio) are still unverified.
## Project layout
@@ -62,6 +69,7 @@ server/ Node.js backend: proxies ALL Emby access (auth, catalog,
src/embyProxy.js generic Emby request/response proxying (incl. HLS)
src/socketHandler.js Socket.IO protocol (see comments in the file)
src/partyManager.js in-memory room state
+ src/musicQueueService.js participative music queue - see docs/architecture.md
src/logger.js party lifecycle logging (docker logs/journalctl-friendly)
client/ Static frontend (plain JS, ES modules, no build step).
@@ -70,7 +78,8 @@ client/ Static frontend (plain JS, ES modules, no build step).
js/tiles.js poster-tile rendering shared by home.js and library.js
js/embyApi.js calls our own /api/* routes (auth, catalog, playback options)
js/party.js our own backend's party REST + Socket.IO client
- js/views/ one file per screen: login, home, library, series, player
+ js/views/ one file per screen: login, home, library, series, player,
+ artist, album, musicPlayer
src/WatchParty/ The earlier Emby-plugin attempt. Builds fine and installs,
but Emby has no supported extension point for a player
@@ -120,8 +129,9 @@ Then put it behind your existing reverse proxy like everything else
- [x] Presence overlay (avatars) + "request pause" in the player
- [x] Server-side logging of the party lifecycle (create/join/leave/destroy,
host handoff, play/pause, pause requests) — see docs/architecture.md
+- [x] Music: dedicated player, Artist/Album/Track browsing, participative
+ queue (free add, vote-to-skip, no host) — see docs/architecture.md
- [ ] Try a full party end-to-end with two real accounts (still the main gap)
-- [ ] Music: dedicated player, artist/album browsing, collaborative playlist
- [ ] Tune transcoding target / support direct play when possible
No chat, no OIDC — deliberately out of scope (Emby-only auth was the
diff --git a/client/css/app.css b/client/css/app.css
index fe25155..62b600c 100644
--- a/client/css/app.css
+++ b/client/css/app.css
@@ -476,6 +476,117 @@ button[disabled] { opacity: .5; cursor: default; }
.episodeInfo { order: 3; flex-basis: 100%; }
}
+/* ---- Music: album page, track lists (shared with the music player's own
+ queue/browse panel), now-playing header ---- */
+
+.albumHeader {
+ display: flex;
+ gap: 20px;
+ margin-bottom: 24px;
+}
+
+.albumCover {
+ width: 160px;
+ aspect-ratio: 1 / 1;
+ object-fit: cover;
+ border-radius: 8px;
+ background: var(--panel);
+ flex-shrink: 0;
+}
+
+.albumCover.noImage { visibility: hidden; }
+
+.trackList {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ margin-bottom: 20px;
+}
+
+.trackRow {
+ display: grid;
+ grid-template-columns: 28px 1fr auto auto;
+ align-items: center;
+ gap: 12px;
+ background: var(--bg-header);
+ border-radius: 8px;
+ padding: 8px 12px;
+}
+
+.trackIndex { color: var(--muted); font-size: 13px; text-align: center; }
+.trackName { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.trackDuration { color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums; }
+.trackRow button { flex-shrink: 0; padding: 6px 10px; font-size: 12px; }
+
+@media (max-width: 600px) {
+ .albumHeader { flex-direction: column; }
+ .albumCover { width: 120px; }
+ .trackRow { grid-template-columns: 24px 1fr; }
+ .trackRow .trackDuration, .trackRow button { grid-column: 2; justify-self: start; }
+}
+
+/* Music player's "now playing" header - see musicPlayer.js. 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. */
+.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 reused from the video player (same .avatar component -
+ see player.js) but in normal page flow here, not absolutely positioned
+ over a video frame. */
+.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;
+}
+
/* ---- Player ---- */
.playerWrap {
diff --git a/client/js/app.js b/client/js/app.js
index 3bdc63d..109dd55 100644
--- a/client/js/app.js
+++ b/client/js/app.js
@@ -5,6 +5,8 @@ import { renderLogin } from './views/login.js';
import { renderHome } from './views/home.js';
import { renderLibrary } from './views/library.js';
import { renderSeries } from './views/series.js';
+import { renderArtist } from './views/artist.js';
+import { renderAlbum } from './views/album.js';
import { renderPartyPlayer } from './views/player.js';
route('/', () => navigate('/home'));
@@ -12,6 +14,8 @@ route('/login', renderLogin);
route('/home', renderHome);
route('/library/:id', renderLibrary);
route('/series/:id', renderSeries);
+route('/artist/:id', renderArtist);
+route('/album/:id', renderAlbum);
route('/party/:id', renderPartyPlayer);
/**
diff --git a/client/js/embyApi.js b/client/js/embyApi.js
index 62b73f9..73492f3 100644
--- a/client/js/embyApi.js
+++ b/client/js/embyApi.js
@@ -83,14 +83,36 @@ export async function getSeriesEpisodes(seriesId, seasonId) {
return data.episodes || [];
}
+export async function getArtists(libraryId) {
+ const data = await apiFetch(`/api/catalog/libraries/${libraryId}/artists`);
+ return data.artists || [];
+}
+
+export async function getArtistAlbums(artistId) {
+ const data = await apiFetch(`/api/catalog/artists/${artistId}/albums`);
+ return data.albums || [];
+}
+
+export async function getAlbumTracks(albumId) {
+ const data = await apiFetch(`/api/catalog/albums/${albumId}/tracks`);
+ return data.tracks || [];
+}
+
export async function getPlaybackOptions(itemId) {
return apiFetch(`/api/catalog/item/${itemId}/playback-options`);
}
-/** `options` may carry AudioStreamIndex/SubtitleStreamIndex/MaxStreamingBitrate. */
+/** `options` may carry AudioStreamIndex/SubtitleStreamIndex/MaxStreamingBitrate/mediaType. */
export function streamUrl(itemId, options = {}) {
const qs = new URLSearchParams(
Object.fromEntries(Object.entries(options).filter(([, v]) => v !== '' && v != null))
).toString();
return `/api/catalog/stream/${itemId}/master.m3u8${qs ? '?' + qs : ''}`;
}
+
+/** Same stream, tagged for a music track - see server/src/index.js's
+ * /master.m3u8 route for why mediaType is the one thing that matters here
+ * (its hand-built-fallback path is genuinely different for audio). */
+export function audioStreamUrl(itemId) {
+ return streamUrl(itemId, { mediaType: 'audio' });
+}
diff --git a/client/js/party.js b/client/js/party.js
index 4207328..099d81f 100644
--- a/client/js/party.js
+++ b/client/js/party.js
@@ -36,6 +36,19 @@ export function getParty(partyId) {
return apiFetch(`/api/parties/${partyId}`);
}
+/**
+ * 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 (album.js), which has no live
+ * connection to the party it might be adding into, only the player view
+ * (musicPlayer.js) does. `track` is whatever toItemDto shape the catalog
+ * endpoints already returned (itemId/itemName/artistName/albumName/
+ * itemImageUrl/durationTicks) - see server/src/musicQueueService.js.
+ */
+export function addTrackToQueue(partyId, track) {
+ return apiFetch(`/api/parties/${partyId}/tracks`, { method: 'POST', body: JSON.stringify(track) });
+}
+
/**
* Live "watch parties en cours" feed for the home page. `onParties` gets
* called with the full current list on subscribe, and again every time any
@@ -61,7 +74,7 @@ export function connectLobby(onParties) {
* keeping alive via closures, never gets released.
*/
export function connectParty(partyId, {
- onMembers, onPlaystate, onItemChanged, onHostChanged, onPauseRequested, onError, onClose
+ onMembers, onPlaystate, onItemChanged, onHostChanged, onPauseRequested, onQueueUpdated, onError, onClose
} = {}) {
const socket = window.io();
@@ -77,6 +90,7 @@ export function connectParty(partyId, {
if (onItemChanged) socket.on('itemChanged', onItemChanged);
if (onHostChanged) socket.on('hostChanged', onHostChanged);
if (onPauseRequested) socket.on('pauseRequested', onPauseRequested);
+ if (onQueueUpdated) socket.on('queueUpdated', onQueueUpdated); // music parties only - see musicPlayer.js
if (onError) socket.on('errorMessage', (msg) => onError(msg.message));
if (onClose) socket.on('disconnect', onClose);
@@ -88,10 +102,20 @@ export function connectParty(partyId, {
next() {
socket.emit('next');
},
- /** Asks the host (and tells everyone else) "please pause". */
+ /** Asks the host (and tells everyone else) "please pause". Video only -
+ * a music party has no host to ask, see requestSkip/setMusicPaused. */
requestPause() {
socket.emit('requestPause');
},
+ /** Music only: vote to skip the current track - see musicQueueService.js
+ * on the server for the majority threshold. */
+ voteSkip() {
+ socket.emit('voteSkip');
+ },
+ /** Music only: no host check server-side, unlike sendPlaystate above. */
+ setMusicPaused(isPaused) {
+ socket.emit('musicPlaystate', { isPaused });
+ },
leave() {
socket.emit('leave');
socket.disconnect();
diff --git a/client/js/tiles.js b/client/js/tiles.js
index c989e02..c1448da 100644
--- a/client/js/tiles.js
+++ b/client/js/tiles.js
@@ -7,15 +7,55 @@ export function escapeHtml(str) {
return div.innerHTML;
}
+/** "m:ss" (or "h:mm:ss" past an hour) - shared by the album track list and
+ * the music player, so a track's listed duration and its playing position
+ * always read the same way. */
+export function formatDuration(totalSeconds) {
+ const s = Math.max(0, Math.round(totalSeconds || 0));
+ const h = Math.floor(s / 3600);
+ const m = Math.floor((s % 3600) / 60);
+ const sec = s % 60;
+ const pad = (n) => String(n).padStart(2, '0');
+ return h > 0 ? `${h}:${pad(m)}:${pad(sec)}` : `${m}:${pad(sec)}`;
+}
+
+// Small deterministic palette so the same person always gets the same
+// avatar color across a session, without needing the server to assign one
+// - shared by the video player's presence overlay and the music player's
+// listener list, so the same person reads as the same color in either.
+const AVATAR_COLORS = ['#52b54b', '#4c8bf5', '#f5a623', '#e05d5d', '#9b59b6', '#1abc9c', '#e67e22', '#3498db'];
+
+export function colorForName(name) {
+ let hash = 0;
+ for (let i = 0; i < name.length; i++) hash = (hash * 31 + name.charCodeAt(i)) >>> 0;
+ return AVATAR_COLORS[hash % AVATAR_COLORS.length];
+}
+
+export function initialsFor(name) {
+ return (name || '').trim().slice(0, 2).toUpperCase() || '?';
+}
+
+// Drill-down targets: a tile for a type with no direct playback (Series,
+// MusicArtist, MusicAlbum) links into its own browsing page instead of
+// getting a "start a party" button - canWatchParty is already false for
+// all three server-side (see WATCH_PARTY_TYPES in server/src/index.js).
+const DRILL_DOWN = {
+ Series: { route: 'series', label: 'Voir les épisodes' },
+ MusicArtist: { route: 'artist', label: 'Voir les albums' },
+ MusicAlbum: { route: 'album', label: 'Voir les morceaux' }
+};
+
export function tileHtml(item) {
- const seriesHref = item.type === 'Series' ? `#/series/${item.id}` : null;
+ const drill = DRILL_DOWN[item.type];
+ const drillHref = drill ? `#/${drill.route}/${item.id}` : null;
+ const partyLabel = item.type === 'Audio' ? 'Écouter ensemble' : 'Lancer une watch party';
return `
${escapeHtml(item.name)}${item.year ? ` (${item.year})` : ''}
diff --git a/client/js/views/album.js b/client/js/views/album.js
new file mode 100644
index 0000000..cd52d43
--- /dev/null
+++ b/client/js/views/album.js
@@ -0,0 +1,68 @@
+import { getItem, getAlbumTracks } from '../embyApi.js';
+import { createParty } from '../party.js';
+import { navigate } from '../router.js';
+import { escapeHtml, formatDuration } from '../tiles.js';
+
+const TICKS_PER_SECOND = 10000000; // Emby/.NET convention: 1 tick = 100ns, same as everywhere else this app touches durations/positions
+
+/** Route `/album/:id` - the last level of the music hierarchy: one album's
+ * tracks, each startable as its own listen party (mirrors series.js's
+ * episode list, but flat - an album has no further sub-level the way a
+ * series' seasons do). */
+export async function renderAlbum(root, params) {
+ const albumId = params.id;
+ root.innerHTML = '
+ `).join('');
+
+ listEl.querySelectorAll('.startPartyBtn').forEach((btn) => {
+ btn.addEventListener('click', async () => {
+ btn.disabled = true;
+ btn.textContent = 'Démarrage...';
+ try {
+ const party = await createParty(btn.dataset.itemId);
+ navigate(`/party/${party.partyId}`);
+ } catch (err) {
+ alert(err.message);
+ btn.disabled = false;
+ btn.textContent = 'Écouter ensemble';
+ }
+ });
+ });
+}
diff --git a/client/js/views/artist.js b/client/js/views/artist.js
new file mode 100644
index 0000000..7b6e032
--- /dev/null
+++ b/client/js/views/artist.js
@@ -0,0 +1,35 @@
+import { getItem, getArtistAlbums } from '../embyApi.js';
+import { tileHtml, escapeHtml } from '../tiles.js';
+
+/** Route `/artist/:id` - one artist's albums, reached from a music
+ * library's top-level grid (see library.js) via a MusicArtist tile's
+ * "Voir les albums" link (tiles.js's DRILL_DOWN table). Structurally the
+ * same "detail header + grid" shape as series.js, just one level up the
+ * music hierarchy (Artist -> Album, not Series -> Season/Episode) and
+ * with no per-item action beyond drilling further into an album (an
+ * artist has nothing itself to start a party from). */
+export async function renderArtist(root, params) {
+ const artistId = params.id;
+ root.innerHTML = '
';
+}
diff --git a/client/js/views/home.js b/client/js/views/home.js
index 7410c64..fc46515 100644
--- a/client/js/views/home.js
+++ b/client/js/views/home.js
@@ -109,8 +109,6 @@ async function loadRecentSections(root) {
}
for (const lib of libraries) {
- if (lib.collectionType === 'music') continue; // music gets its own experience later, not a poster row
-
const section = document.createElement('section');
section.className = 'section';
section.innerHTML = `
Ajouts récents - ${escapeHtml(lib.name)}
Chargement...
`;
diff --git a/client/js/views/library.js b/client/js/views/library.js
index 2c302de..70e5301 100644
--- a/client/js/views/library.js
+++ b/client/js/views/library.js
@@ -1,4 +1,4 @@
-import { getLibraries, getLibraryItems } from '../embyApi.js';
+import { getLibraries, getLibraryItems, getArtists } from '../embyApi.js';
import { createParty } from '../party.js';
import { navigate } from '../router.js';
import { tileHtml, wireTileButtons, escapeHtml } from '../tiles.js';
@@ -6,12 +6,14 @@ import { tileHtml, wireTileButtons, escapeHtml } from '../tiles.js';
/**
* Generic "one library" page, reached from the sidebar. What it renders
* depends entirely on the library's Emby CollectionType:
- * - movies/tvshows (or anything else playable): a tile grid, same as the
- * home page's carousels (Series tiles link into /series/:id instead of
- * starting a party directly - see tiles.js).
- * - music: a placeholder for now - see docs/architecture.md, deliberately
- * out of scope for this pass (needs its own player + collaborative
- * playlist, not just a grid).
+ * - movies/tvshows/musicvideos (or anything else playable): a tile grid,
+ * same as the home page's carousels (Series/MusicArtist/MusicAlbum
+ * tiles drill further in instead of starting a party directly - see
+ * tiles.js's DRILL_DOWN table).
+ * - music: a tile grid too, just of artists (Emby's dedicated /Artists
+ * endpoint, not a generic library listing - see
+ * GET /api/catalog/libraries/:id/artists) - Album -> Tracks is one
+ * level further in (artist.js, then album.js).
*/
export async function renderLibrary(root, params) {
root.innerHTML = '
- La musique arrive dans une prochaine version : lecteur dédié, navigation
- artiste / album, et une playlist partagée où chaque membre de la party
- peut ajouter des morceaux. Pas encore disponible ici.
-
';
return;
diff --git a/client/js/views/musicPlayer.js b/client/js/views/musicPlayer.js
new file mode 100644
index 0000000..8213a24
--- /dev/null
+++ b/client/js/views/musicPlayer.js
@@ -0,0 +1,338 @@
+import { getLibraries, getArtists, getArtistAlbums, getAlbumTracks, audioStreamUrl } from '../embyApi.js';
+import { leaveParty, connectParty, addTrackToQueue } from '../party.js';
+import { navigate } from '../router.js';
+import { escapeHtml, formatDuration, colorForName, initialsFor } from '../tiles.js';
+
+/**
+ * Music half of the party route (see player.js'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/musicQueueService.js 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, partyId, party) {
+ root.innerHTML = `
+
+
Écoute partagée
+
+
+
+
+
+
+
+
+ -
+
+
+
+ 0:00 / 0:00
+
+
+
+
+
+
+
+
File d'attente
+
+
+
Rien en attente.
+
+ `;
+
+ const audio = root.querySelector('#audio');
+ const presenceOverlay = root.querySelector('#presenceOverlay');
+ const trackImage = root.querySelector('#trackImage');
+ const trackName = root.querySelector('#trackName');
+ const trackArtist = root.querySelector('#trackArtist');
+ const playPauseBtn = root.querySelector('#playPauseBtn');
+ const trackPosition = root.querySelector('#trackPosition');
+ const voteSkipBtn = root.querySelector('#voteSkipBtn');
+ const queueList = root.querySelector('#queueList');
+ const browsePanel = root.querySelector('#browsePanel');
+
+ let conn = null;
+ let hls = null;
+ let currentItemId = null;
+ let currentDurationSeconds = 0;
+ let suppressLocalEvents = false; // avoid feeding our own correction back as a new event - same pattern as player.js
+ let positionTicker = null;
+ let latestMembers = [];
+
+ // Same HLS pipeline as the video player (player.js'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