From efaa7f55f0d59c1948631ca6c3e38e61fb0035b2 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Mon, 14 Sep 2026 01:49:22 +0200 Subject: [PATCH] Add music: Artist/Album/Track browsing + participative listen-party queue The last deferred piece from the original spec. Chosen permission model (asked, not assumed): anyone can add a track to the shared queue any time; anyone can pause/resume directly; skipping the current track needs a strict majority vote of currently connected members. No host for playback - a genuinely different shape of shared state than video's "host drives, everyone follows", which is why it's a separate service (server/src/musicQueueService.js, MusicQueueService) rather than more branches in partyManager.js/socketHandler.js's existing video-shaped logic. Server: - musicQueueService.js: owns a music party's queue/current-track/pause/ vote-skip state. Server-authoritative timing (no client is "the host" reporting position) - a wall-clock startedUtc plus the track's known duration is the only state; auto-advance is one setTimeout scheduled for exactly when that runs out, not any client reporting "it ended" (which could race across several players finishing near-simultaneously with no host to arbitrate). attachIo(io, onTrackChanged), called once at startup like PartyManager's own wiring, lets it broadcast "queueUpdated" from inside that timer's callback, which has no socket/request in scope at all. - The timer is the one new leak surface this adds, given how much this project has already had to fix real leaks - destroy(party) clears it, wired into both of partyManager.js's existing destruction paths (leave's last-member case, removeIdleParties' sweep). - Party gained mediaType: 'video'|'music' (inferred server-side from the clicked item's Emby Type - the client never has to know or say which). - Three new catalog endpoints (Artists -> Albums -> Tracks), same reasoning as the existing Shows -> Seasons -> Episodes: a real Emby endpoint per level, not generic recursive /Items guessing. - Audio streams through the *same* /master.m3u8 route as video (Emby's PlaybackInfo negotiation isn't video-specific) - the one difference is the hand-built-fallback path prefix (/Audio/ vs /Videos/), which the client tags via a mediaType=audio query hint rather than costing an extra Emby round trip to detect server-side. - Adding a track is REST (POST /api/parties/:partyId/tracks), not a socket event like voteSkip/musicPlaystate - it needs to work from the player's inline browse-and-add panel regardless of realtime-connection specifics, and MusicQueueService's own broadcast reaches everyone connected either way. Client: - player.js's renderPartyPlayer is now a small dispatcher: fetches the party once, then delegates to renderVideoPlayer or the new renderMusicPlayer (views/musicPlayer.js) based on mediaType. Can't be async despite awaiting that fetch - route()'s contract needs its cleanup function back synchronously (router.js's `typeof cleanup === 'function'` check would silently skip a Promise, reintroducing the exact leak class already fixed once) - solved with a mutable box returned immediately, filled in once the fetch resolves. - musicPlayer.js: not full-viewport/theater-mode like the video player (no image to declutter around, sidebar stays up) - which is what makes its own inline Artist -> Album -> Track browser work for "add tracks while listening" without navigating away and losing player state or threading party context through query params across three route levels. - New views/artist.js, views/album.js (library.js's music branch renders an artist grid instead of the old placeholder). tiles.js's DRILL_DOWN table generalizes the old single seriesHref special-case now that there are three drill-down targets, not two. - party.js: addTrackToQueue (REST) alongside voteSkip/setMusicPaused (socket) on the existing connectParty() connection object. Real bug found while testing, not just the mock's fault: applyMusicState used to update the queue list/vote button *after* the current-track branch, which calls loadTrack() - and loadTrack()'s hls.js setup can throw. Since JS doesn't roll back earlier statements when a later one throws but does abort everything after it, an exception there took the unrelated queue list and vote tally down with it, and on the first render escaped all the way to the dispatcher's catch, which alerts and bounces back to /home - discarding the whole view over what's really just "this one track's audio failed to load". Fixed by reordering (queue/vote/icon updates - which can't fail - before the track-loading branch, mirroring how player.js's onItemChanged already updates title/next-button before calling loadVideo) and wrapping loadTrack's hls.js setup in its own try/catch so a failure there logs and stops instead of propagating. Verified server-side directly against the real shipped modules (not reimplemented mocks): 18 checks against musicQueueService.js alone (auto -start, queueing, vote-skip majority, pause/resume elapsed-time math, a real setTimeout auto-advance, timer cleanup on destroy - instrumented setTimeout/clearTimeout counting, same technique as the earlier client -side leak tests) and 7 more through socketHandler.js + partyManager.js together (no-ops on a video party, a non-host pausing/resuming, a two-vote majority skip, the resulting broadcast really reaching the room). Client-side against the real local server with mocked fetch/Hls/ io: the full browsing chain, party creation, the dispatcher choosing the right player (and still choosing video when mediaType is absent - back -compat), now-playing/vote-tally/queue rendering and updates including a live track change, play/pause and vote-skip emitting the right events, the inline browse-and-add panel POSTing the right payload, and the crash fix re-confirmed fixed via the same broken scenario that first found it. Not verified: the real Emby round trip for any of this (Artists/albums -tracks response shapes, audio PlaybackInfo/TranscodingUrl behavior, actual playback quality) - same limit as everything else in this project tested without live Emby access. docs/architecture.md has the full writeup. Co-Authored-By: Claude Sonnet 5 --- README.md | 44 +++-- client/css/app.css | 111 +++++++++++ client/js/app.js | 4 + client/js/embyApi.js | 24 ++- client/js/party.js | 28 ++- client/js/tiles.js | 48 ++++- client/js/views/album.js | 68 +++++++ client/js/views/artist.js | 35 ++++ client/js/views/home.js | 2 - client/js/views/library.js | 31 ++- client/js/views/musicPlayer.js | 338 ++++++++++++++++++++++++++++++++ client/js/views/player.js | 75 ++++--- docs/architecture.md | 243 +++++++++++++++++++++-- server/src/index.js | 142 ++++++++++++-- server/src/musicQueueService.js | 228 +++++++++++++++++++++ server/src/partyManager.js | 21 +- server/src/socketHandler.js | 43 +++- 17 files changed, 1379 insertions(+), 106 deletions(-) create mode 100644 client/js/views/album.js create mode 100644 client/js/views/artist.js create mode 100644 client/js/views/musicPlayer.js create mode 100644 server/src/musicQueueService.js 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 `
- ${seriesHref ? `` : ''} + ${drillHref ? `` : ''}
- ${item.canWatchParty ? `` : ''} - ${seriesHref ? `Voir les épisodes` : ''} + ${item.canWatchParty ? `` : ''} + ${drillHref ? `${drill.label}` : ''} Voir sur Emby
${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 = '

Chargement...

'; + + let album; + let tracks; + try { + [album, tracks] = await Promise.all([getItem(albumId), getAlbumTracks(albumId)]); + } catch (err) { + root.innerHTML = '

Impossible de charger cet album.

'; + return; + } + + root.innerHTML = ` + +
+ +
+

${escapeHtml(album.name)}${album.year ? ` (${album.year})` : ''}

+ ${album.artistName ? `

${escapeHtml(album.artistName)}

` : ''} + Voir sur Emby +
+
+
`; + + root.querySelector('#back').addEventListener('click', () => history.back()); + + const listEl = root.querySelector('#trackList'); + if (!tracks.length) { + listEl.innerHTML = '

Aucun morceau dans cet album.

'; + return; + } + + 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) => { + 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 = '

Chargement...

'; + + let artist; + let albums; + try { + [artist, albums] = await Promise.all([getItem(artistId), getArtistAlbums(artistId)]); + } catch (err) { + root.innerHTML = '

Impossible de charger cet artiste.

'; + return; + } + + root.innerHTML = ` + + +
`; + + root.querySelector('#back').addEventListener('click', () => history.back()); + + const grid = root.querySelector('#grid'); + grid.innerHTML = albums.length + ? albums.map(tileHtml).join('') + : '

Aucun album trouvé pour cet artiste.

'; +} 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)}

`; 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 = '

Chargement...

'; @@ -30,17 +32,6 @@ export async function renderLibrary(root, params) { return; } - if (library.collectionType === 'music') { - 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; - } - root.innerHTML = `

Chargement...

`; @@ -48,7 +39,9 @@ export async function renderLibrary(root, params) { const grid = root.querySelector('#grid'); let items; try { - items = await getLibraryItems(library.id); + items = library.collectionType === 'music' + ? await getArtists(library.id) + : await getLibraryItems(library.id); } catch (err) { grid.innerHTML = '

Impossible de charger cette bibliothèque.

'; 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 = ` + + +
+ +
+ +
+ - + +
+ + 0:00 / 0:00 + +
+
+
+ + + +

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