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