Add music: Artist/Album/Track browsing + participative listen-party queue
All checks were successful
Publier l'image Docker / publish (push) Successful in 7s
All checks were successful
Publier l'image Docker / publish (push) Successful in 7s
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 <noreply@anthropic.com>
This commit is contained in:
parent
841975e408
commit
efaa7f55f0
17 changed files with 1379 additions and 106 deletions
44
README.md
44
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
|
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
|
own avatar asks the host to pause — everyone sees the request, including
|
||||||
the host, without needing chat.
|
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
|
Everything — catalog, images, video, audio, sync — flows through this
|
||||||
backend, which proxies whatever it needs from Emby server-side. The browser
|
app's own backend, which proxies whatever it needs from Emby server-side.
|
||||||
never talks to Emby directly and never holds an Emby access token; it only
|
The browser never talks to Emby directly and never holds an Emby access
|
||||||
ever has a session cookie for this app.
|
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.
|
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Login, and the rendering/navigation logic for the sidebar/home/library/series
|
Login, and the rendering/navigation logic for every page (sidebar, home,
|
||||||
pages, have been exercised end to end (login against the real Emby server
|
library, series, artist/album, both players), have been exercised end to
|
||||||
with intentionally-wrong credentials; the pages against mocked API
|
end (login against the real Emby server with intentionally-wrong
|
||||||
responses). See [docs/architecture.md](docs/architecture.md#assumptions-to-verify)
|
credentials; the pages against mocked API responses; the music queue's
|
||||||
for exactly what that does and doesn't confirm — the real Emby response
|
timer/vote/pause logic against the real server modules directly - no Emby
|
||||||
shapes this now depends on (library `CollectionType`, seasons/episodes,
|
involved there at all). See
|
||||||
`PlaybackInfo`'s media streams) and actual HLS playback are still unverified.
|
[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
|
## 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/embyProxy.js generic Emby request/response proxying (incl. HLS)
|
||||||
src/socketHandler.js Socket.IO protocol (see comments in the file)
|
src/socketHandler.js Socket.IO protocol (see comments in the file)
|
||||||
src/partyManager.js in-memory room state
|
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)
|
src/logger.js party lifecycle logging (docker logs/journalctl-friendly)
|
||||||
|
|
||||||
client/ Static frontend (plain JS, ES modules, no build step).
|
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/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/embyApi.js calls our own /api/* routes (auth, catalog, playback options)
|
||||||
js/party.js our own backend's party REST + Socket.IO client
|
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,
|
src/WatchParty/ The earlier Emby-plugin attempt. Builds fine and installs,
|
||||||
but Emby has no supported extension point for a player
|
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] Presence overlay (avatars) + "request pause" in the player
|
||||||
- [x] Server-side logging of the party lifecycle (create/join/leave/destroy,
|
- [x] Server-side logging of the party lifecycle (create/join/leave/destroy,
|
||||||
host handoff, play/pause, pause requests) — see docs/architecture.md
|
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)
|
- [ ] 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
|
- [ ] Tune transcoding target / support direct play when possible
|
||||||
|
|
||||||
No chat, no OIDC — deliberately out of scope (Emby-only auth was the
|
No chat, no OIDC — deliberately out of scope (Emby-only auth was the
|
||||||
|
|
|
||||||
|
|
@ -476,6 +476,117 @@ button[disabled] { opacity: .5; cursor: default; }
|
||||||
.episodeInfo { order: 3; flex-basis: 100%; }
|
.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 ---- */
|
/* ---- Player ---- */
|
||||||
|
|
||||||
.playerWrap {
|
.playerWrap {
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ import { renderLogin } from './views/login.js';
|
||||||
import { renderHome } from './views/home.js';
|
import { renderHome } from './views/home.js';
|
||||||
import { renderLibrary } from './views/library.js';
|
import { renderLibrary } from './views/library.js';
|
||||||
import { renderSeries } from './views/series.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';
|
import { renderPartyPlayer } from './views/player.js';
|
||||||
|
|
||||||
route('/', () => navigate('/home'));
|
route('/', () => navigate('/home'));
|
||||||
|
|
@ -12,6 +14,8 @@ route('/login', renderLogin);
|
||||||
route('/home', renderHome);
|
route('/home', renderHome);
|
||||||
route('/library/:id', renderLibrary);
|
route('/library/:id', renderLibrary);
|
||||||
route('/series/:id', renderSeries);
|
route('/series/:id', renderSeries);
|
||||||
|
route('/artist/:id', renderArtist);
|
||||||
|
route('/album/:id', renderAlbum);
|
||||||
route('/party/:id', renderPartyPlayer);
|
route('/party/:id', renderPartyPlayer);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -83,14 +83,36 @@ export async function getSeriesEpisodes(seriesId, seasonId) {
|
||||||
return data.episodes || [];
|
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) {
|
export async function getPlaybackOptions(itemId) {
|
||||||
return apiFetch(`/api/catalog/item/${itemId}/playback-options`);
|
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 = {}) {
|
export function streamUrl(itemId, options = {}) {
|
||||||
const qs = new URLSearchParams(
|
const qs = new URLSearchParams(
|
||||||
Object.fromEntries(Object.entries(options).filter(([, v]) => v !== '' && v != null))
|
Object.fromEntries(Object.entries(options).filter(([, v]) => v !== '' && v != null))
|
||||||
).toString();
|
).toString();
|
||||||
return `/api/catalog/stream/${itemId}/master.m3u8${qs ? '?' + qs : ''}`;
|
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' });
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,19 @@ export function getParty(partyId) {
|
||||||
return apiFetch(`/api/parties/${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
|
* 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
|
* 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.
|
* keeping alive via closures, never gets released.
|
||||||
*/
|
*/
|
||||||
export function connectParty(partyId, {
|
export function connectParty(partyId, {
|
||||||
onMembers, onPlaystate, onItemChanged, onHostChanged, onPauseRequested, onError, onClose
|
onMembers, onPlaystate, onItemChanged, onHostChanged, onPauseRequested, onQueueUpdated, onError, onClose
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const socket = window.io();
|
const socket = window.io();
|
||||||
|
|
||||||
|
|
@ -77,6 +90,7 @@ export function connectParty(partyId, {
|
||||||
if (onItemChanged) socket.on('itemChanged', onItemChanged);
|
if (onItemChanged) socket.on('itemChanged', onItemChanged);
|
||||||
if (onHostChanged) socket.on('hostChanged', onHostChanged);
|
if (onHostChanged) socket.on('hostChanged', onHostChanged);
|
||||||
if (onPauseRequested) socket.on('pauseRequested', onPauseRequested);
|
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 (onError) socket.on('errorMessage', (msg) => onError(msg.message));
|
||||||
if (onClose) socket.on('disconnect', onClose);
|
if (onClose) socket.on('disconnect', onClose);
|
||||||
|
|
||||||
|
|
@ -88,10 +102,20 @@ export function connectParty(partyId, {
|
||||||
next() {
|
next() {
|
||||||
socket.emit('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() {
|
requestPause() {
|
||||||
socket.emit('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() {
|
leave() {
|
||||||
socket.emit('leave');
|
socket.emit('leave');
|
||||||
socket.disconnect();
|
socket.disconnect();
|
||||||
|
|
|
||||||
|
|
@ -7,15 +7,55 @@ export function escapeHtml(str) {
|
||||||
return div.innerHTML;
|
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) {
|
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 `
|
return `
|
||||||
<div class="tile">
|
<div class="tile">
|
||||||
${seriesHref ? `<a href="${seriesHref}" class="tileLink" aria-label="${escapeHtml(item.name)}"></a>` : ''}
|
${drillHref ? `<a href="${drillHref}" class="tileLink" aria-label="${escapeHtml(item.name)}"></a>` : ''}
|
||||||
<img src="${item.imageUrl}" alt="" loading="lazy" onerror="this.classList.add('noImage')" />
|
<img src="${item.imageUrl}" alt="" loading="lazy" onerror="this.classList.add('noImage')" />
|
||||||
<div class="tileOverlay">
|
<div class="tileOverlay">
|
||||||
${item.canWatchParty ? `<button class="tileBtn startPartyBtn" data-item-id="${item.id}">Lancer une watch party</button>` : ''}
|
${item.canWatchParty ? `<button class="tileBtn startPartyBtn" data-item-id="${item.id}">${partyLabel}</button>` : ''}
|
||||||
${seriesHref ? `<a class="tileBtn" href="${seriesHref}">Voir les épisodes</a>` : ''}
|
${drillHref ? `<a class="tileBtn" href="${drillHref}">${drill.label}</a>` : ''}
|
||||||
<a class="tileBtn tileBtnLink" href="${item.embyUrl}" target="_blank" rel="noopener">Voir sur Emby</a>
|
<a class="tileBtn tileBtnLink" href="${item.embyUrl}" target="_blank" rel="noopener">Voir sur Emby</a>
|
||||||
</div>
|
</div>
|
||||||
<span class="tileLabel">${escapeHtml(item.name)}${item.year ? ` (${item.year})` : ''}</span>
|
<span class="tileLabel">${escapeHtml(item.name)}${item.year ? ` (${item.year})` : ''}</span>
|
||||||
|
|
|
||||||
68
client/js/views/album.js
Normal file
68
client/js/views/album.js
Normal file
|
|
@ -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 = '<p class="hint">Chargement...</p>';
|
||||||
|
|
||||||
|
let album;
|
||||||
|
let tracks;
|
||||||
|
try {
|
||||||
|
[album, tracks] = await Promise.all([getItem(albumId), getAlbumTracks(albumId)]);
|
||||||
|
} catch (err) {
|
||||||
|
root.innerHTML = '<p class="error">Impossible de charger cet album.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
root.innerHTML = `
|
||||||
|
<button class="linklike" id="back">← Retour</button>
|
||||||
|
<div class="albumHeader">
|
||||||
|
<img class="albumCover" src="${album.imageUrl}" alt="" loading="lazy" onerror="this.classList.add('noImage')" />
|
||||||
|
<div class="seriesInfo">
|
||||||
|
<h1>${escapeHtml(album.name)}${album.year ? ` <span class="seriesYear">(${album.year})</span>` : ''}</h1>
|
||||||
|
${album.artistName ? `<p class="hint">${escapeHtml(album.artistName)}</p>` : ''}
|
||||||
|
<a class="linklike" href="${album.embyUrl}" target="_blank" rel="noopener">Voir sur Emby</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="trackList" class="trackList"></div>`;
|
||||||
|
|
||||||
|
root.querySelector('#back').addEventListener('click', () => history.back());
|
||||||
|
|
||||||
|
const listEl = root.querySelector('#trackList');
|
||||||
|
if (!tracks.length) {
|
||||||
|
listEl.innerHTML = '<p class="hint">Aucun morceau dans cet album.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
listEl.innerHTML = tracks.map((t) => `
|
||||||
|
<div class="trackRow">
|
||||||
|
<span class="trackIndex">${t.indexNumber != null ? t.indexNumber : ''}</span>
|
||||||
|
<span class="trackName">${escapeHtml(t.name)}</span>
|
||||||
|
<span class="trackDuration">${t.durationTicks ? formatDuration(t.durationTicks / TICKS_PER_SECOND) : ''}</span>
|
||||||
|
<button class="startPartyBtn" data-item-id="${t.id}">Écouter ensemble</button>
|
||||||
|
</div>
|
||||||
|
`).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';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
35
client/js/views/artist.js
Normal file
35
client/js/views/artist.js
Normal file
|
|
@ -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 = '<p class="hint">Chargement...</p>';
|
||||||
|
|
||||||
|
let artist;
|
||||||
|
let albums;
|
||||||
|
try {
|
||||||
|
[artist, albums] = await Promise.all([getItem(artistId), getArtistAlbums(artistId)]);
|
||||||
|
} catch (err) {
|
||||||
|
root.innerHTML = '<p class="error">Impossible de charger cet artiste.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
root.innerHTML = `
|
||||||
|
<button class="linklike" id="back">← Retour</button>
|
||||||
|
<div class="pageHeader"><h1>${escapeHtml(artist.name)}</h1></div>
|
||||||
|
<div id="grid" class="grid"></div>`;
|
||||||
|
|
||||||
|
root.querySelector('#back').addEventListener('click', () => history.back());
|
||||||
|
|
||||||
|
const grid = root.querySelector('#grid');
|
||||||
|
grid.innerHTML = albums.length
|
||||||
|
? albums.map(tileHtml).join('')
|
||||||
|
: '<p class="hint">Aucun album trouvé pour cet artiste.</p>';
|
||||||
|
}
|
||||||
|
|
@ -109,8 +109,6 @@ async function loadRecentSections(root) {
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const lib of libraries) {
|
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');
|
const section = document.createElement('section');
|
||||||
section.className = 'section';
|
section.className = 'section';
|
||||||
section.innerHTML = `<h2>Ajouts récents - ${escapeHtml(lib.name)}</h2><div class="carousel"><p class="hint">Chargement...</p></div>`;
|
section.innerHTML = `<h2>Ajouts récents - ${escapeHtml(lib.name)}</h2><div class="carousel"><p class="hint">Chargement...</p></div>`;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { getLibraries, getLibraryItems } from '../embyApi.js';
|
import { getLibraries, getLibraryItems, getArtists } from '../embyApi.js';
|
||||||
import { createParty } from '../party.js';
|
import { createParty } from '../party.js';
|
||||||
import { navigate } from '../router.js';
|
import { navigate } from '../router.js';
|
||||||
import { tileHtml, wireTileButtons, escapeHtml } from '../tiles.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
|
* Generic "one library" page, reached from the sidebar. What it renders
|
||||||
* depends entirely on the library's Emby CollectionType:
|
* depends entirely on the library's Emby CollectionType:
|
||||||
* - movies/tvshows (or anything else playable): a tile grid, same as the
|
* - movies/tvshows/musicvideos (or anything else playable): a tile grid,
|
||||||
* home page's carousels (Series tiles link into /series/:id instead of
|
* same as the home page's carousels (Series/MusicArtist/MusicAlbum
|
||||||
* starting a party directly - see tiles.js).
|
* tiles drill further in instead of starting a party directly - see
|
||||||
* - music: a placeholder for now - see docs/architecture.md, deliberately
|
* tiles.js's DRILL_DOWN table).
|
||||||
* out of scope for this pass (needs its own player + collaborative
|
* - music: a tile grid too, just of artists (Emby's dedicated /Artists
|
||||||
* playlist, not just a grid).
|
* 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) {
|
export async function renderLibrary(root, params) {
|
||||||
root.innerHTML = '<p class="hint">Chargement...</p>';
|
root.innerHTML = '<p class="hint">Chargement...</p>';
|
||||||
|
|
@ -30,17 +32,6 @@ export async function renderLibrary(root, params) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (library.collectionType === 'music') {
|
|
||||||
root.innerHTML = `
|
|
||||||
<div class="pageHeader"><h1>${escapeHtml(library.name)}</h1></div>
|
|
||||||
<p class="hint">
|
|
||||||
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.
|
|
||||||
</p>`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
root.innerHTML = `
|
root.innerHTML = `
|
||||||
<div class="pageHeader"><h1>${escapeHtml(library.name)}</h1></div>
|
<div class="pageHeader"><h1>${escapeHtml(library.name)}</h1></div>
|
||||||
<div id="grid" class="grid"><p class="hint">Chargement...</p></div>`;
|
<div id="grid" class="grid"><p class="hint">Chargement...</p></div>`;
|
||||||
|
|
@ -48,7 +39,9 @@ export async function renderLibrary(root, params) {
|
||||||
const grid = root.querySelector('#grid');
|
const grid = root.querySelector('#grid');
|
||||||
let items;
|
let items;
|
||||||
try {
|
try {
|
||||||
items = await getLibraryItems(library.id);
|
items = library.collectionType === 'music'
|
||||||
|
? await getArtists(library.id)
|
||||||
|
: await getLibraryItems(library.id);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
grid.innerHTML = '<p class="error">Impossible de charger cette bibliothèque.</p>';
|
grid.innerHTML = '<p class="error">Impossible de charger cette bibliothèque.</p>';
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
338
client/js/views/musicPlayer.js
Normal file
338
client/js/views/musicPlayer.js
Normal file
|
|
@ -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 = `
|
||||||
|
<div class="pageHeader">
|
||||||
|
<h1>Écoute partagée</h1>
|
||||||
|
<button class="linklike" id="leaveBtn">Quitter</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="presenceOverlay" class="presenceOverlay musicPresence"></div>
|
||||||
|
|
||||||
|
<div class="nowPlaying" id="nowPlaying">
|
||||||
|
<img id="trackImage" class="nowPlayingImage" alt="" onerror="this.classList.add('noImage')" />
|
||||||
|
<div class="nowPlayingInfo">
|
||||||
|
<strong id="trackName">-</strong>
|
||||||
|
<span class="hint" id="trackArtist"></span>
|
||||||
|
<div class="nowPlayingControls">
|
||||||
|
<button class="iconBtn" id="playPauseBtn" title="Lecture / pause" aria-label="Lecture / pause">▶</button>
|
||||||
|
<span id="trackPosition" class="hint">0:00 / 0:00</span>
|
||||||
|
<button class="linklike" id="voteSkipBtn">Passer (vote)</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<audio id="audio"></audio>
|
||||||
|
|
||||||
|
<div class="pageHeader">
|
||||||
|
<h2>File d'attente</h2>
|
||||||
|
<button class="linklike" id="toggleBrowseBtn">Ajouter des morceaux</button>
|
||||||
|
</div>
|
||||||
|
<div id="queueList" class="trackList"><p class="hint">Rien en attente.</p></div>
|
||||||
|
|
||||||
|
<div id="browsePanel" class="browsePanel hide"></div>`;
|
||||||
|
|
||||||
|
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 <audio src="...m3u8"> alone would silently just not play anywhere
|
||||||
|
// but Safari. `elapsedSeconds`/`isPaused` are applied once the manifest
|
||||||
|
// is actually parsed, not immediately after setting src - setting
|
||||||
|
// currentTime before a fresh source has loaded metadata is unreliable
|
||||||
|
// and gets silently reset by most browsers.
|
||||||
|
function loadTrack(track, elapsedSeconds, isPaused) {
|
||||||
|
currentItemId = track.itemId;
|
||||||
|
currentDurationSeconds = track.durationSeconds || 0;
|
||||||
|
trackImage.src = track.itemImageUrl || '';
|
||||||
|
trackImage.classList.remove('noImage');
|
||||||
|
trackName.textContent = track.itemName || '';
|
||||||
|
trackArtist.textContent = [track.artistName, track.albumName].filter(Boolean).join(' - ');
|
||||||
|
|
||||||
|
if (hls) {
|
||||||
|
hls.destroy();
|
||||||
|
hls = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const src = audioStreamUrl(track.itemId);
|
||||||
|
const resume = () => {
|
||||||
|
suppressLocalEvents = true;
|
||||||
|
audio.currentTime = elapsedSeconds || 0;
|
||||||
|
if (!isPaused) audio.play().catch(() => {});
|
||||||
|
setTimeout(() => { suppressLocalEvents = false; }, 250);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Caught, not left to propagate: this runs from inside applyMusicState,
|
||||||
|
// which every "queueUpdated" push calls - an uncaught throw here would
|
||||||
|
// abort that entire handler (and, the first time it happens, the
|
||||||
|
// player's whole synchronous init - see player.js's dispatcher), taking
|
||||||
|
// the queue list and vote button down with a problem that's really
|
||||||
|
// just "this one track's audio failed to load". A skip vote is still
|
||||||
|
// this feature's actual recovery path (see musicQueueService.js) - this
|
||||||
|
// just needs to not crash on the way there.
|
||||||
|
try {
|
||||||
|
if (window.Hls && window.Hls.isSupported()) {
|
||||||
|
hls = new window.Hls();
|
||||||
|
hls.on(window.Hls.Events.MANIFEST_PARSED, resume);
|
||||||
|
hls.on(window.Hls.Events.ERROR, (event, data) => {
|
||||||
|
if (data.fatal) console.error('[watchparty musicPlayer] HLS error', data);
|
||||||
|
});
|
||||||
|
hls.loadSource(src);
|
||||||
|
hls.attachMedia(audio);
|
||||||
|
} else if (audio.canPlayType('application/vnd.apple.mpegurl')) {
|
||||||
|
audio.src = src;
|
||||||
|
audio.addEventListener('loadedmetadata', resume, { once: true });
|
||||||
|
} else {
|
||||||
|
console.error('[watchparty musicPlayer] no HLS support and hls.js unavailable');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[watchparty musicPlayer] failed to load track audio', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyMusicState(music) {
|
||||||
|
// Queue/vote-button/play-icon updates happen first, unconditionally -
|
||||||
|
// deliberately BEFORE the track-loading branch below, which touches
|
||||||
|
// hls.js and could throw (a bad manifest, a network hiccup). Same
|
||||||
|
// ordering the video player already uses (see onItemChanged in
|
||||||
|
// player.js, which updates the title/next-button before calling
|
||||||
|
// loadVideo): a media-loading failure should never also take the
|
||||||
|
// queue list and vote tally down with it, since they're not actually
|
||||||
|
// related to whether THIS particular track's audio loaded.
|
||||||
|
playPauseBtn.textContent = music.current && !music.current.isPaused ? '⏸' : '▶';
|
||||||
|
playPauseBtn.disabled = !music.current;
|
||||||
|
voteSkipBtn.disabled = !music.current;
|
||||||
|
// "needed" mirrors the exact math musicQueueService.js uses server-side
|
||||||
|
// (strict majority of currently connected members) - computed here from
|
||||||
|
// the member list the "members" event already provides, rather than
|
||||||
|
// MusicQueueService's own broadcast repeating that count on every
|
||||||
|
// single queue change (see its getState()'s own comment on this).
|
||||||
|
const connectedCount = latestMembers.filter((m) => m.connected).length || 1;
|
||||||
|
const needed = Math.floor(connectedCount / 2) + 1;
|
||||||
|
voteSkipBtn.textContent = music.current ? `Passer (vote) - ${music.skipVotes}/${needed}` : 'Passer (vote)';
|
||||||
|
|
||||||
|
queueList.innerHTML = music.queue.length
|
||||||
|
? music.queue.map((t) => `
|
||||||
|
<div class="trackRow">
|
||||||
|
<span class="trackName">${escapeHtml(t.itemName)}</span>
|
||||||
|
<span class="hint">${escapeHtml([t.artistName, t.albumName].filter(Boolean).join(' - '))}</span>
|
||||||
|
<span class="trackDuration">${t.durationSeconds ? formatDuration(t.durationSeconds) : ''}</span>
|
||||||
|
<span class="hint">Ajouté par ${escapeHtml(t.addedByUserName)}</span>
|
||||||
|
</div>`).join('')
|
||||||
|
: '<p class="hint">Rien en attente.</p>';
|
||||||
|
|
||||||
|
if (!music.current) {
|
||||||
|
currentItemId = null;
|
||||||
|
trackName.textContent = 'Rien ne joue pour le moment.';
|
||||||
|
trackArtist.textContent = '';
|
||||||
|
trackImage.src = '';
|
||||||
|
if (hls) { hls.destroy(); hls = null; }
|
||||||
|
audio.pause();
|
||||||
|
audio.removeAttribute('src');
|
||||||
|
} else if (music.current.itemId !== currentItemId) {
|
||||||
|
loadTrack(music.current, music.current.elapsedSeconds, music.current.isPaused);
|
||||||
|
} else {
|
||||||
|
// Same track still playing - just resync pause state/position, don't reload the source.
|
||||||
|
suppressLocalEvents = true;
|
||||||
|
if (Math.abs(audio.currentTime - music.current.elapsedSeconds) > 2) {
|
||||||
|
audio.currentTime = music.current.elapsedSeconds;
|
||||||
|
}
|
||||||
|
if (music.current.isPaused && !audio.paused) audio.pause();
|
||||||
|
if (!music.current.isPaused && audio.paused) audio.play().catch(() => {});
|
||||||
|
setTimeout(() => { suppressLocalEvents = false; }, 250);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendLocalPlaystate() {
|
||||||
|
if (suppressLocalEvents || !conn) return;
|
||||||
|
conn.setMusicPaused(audio.paused);
|
||||||
|
}
|
||||||
|
audio.addEventListener('play', sendLocalPlaystate);
|
||||||
|
audio.addEventListener('pause', sendLocalPlaystate);
|
||||||
|
|
||||||
|
playPauseBtn.addEventListener('click', () => {
|
||||||
|
if (!conn) return;
|
||||||
|
conn.setMusicPaused(!audio.paused);
|
||||||
|
});
|
||||||
|
|
||||||
|
voteSkipBtn.addEventListener('click', () => { if (conn) conn.voteSkip(); });
|
||||||
|
|
||||||
|
// Ticks the visible position label between server updates - the same
|
||||||
|
// "derive it client-side from a snapshot" approach as the home page's
|
||||||
|
// party-elapsed-time labels, rather than the server pushing this every
|
||||||
|
// second for every listener.
|
||||||
|
positionTicker = setInterval(() => {
|
||||||
|
if (!currentItemId) return;
|
||||||
|
trackPosition.textContent = `${formatDuration(audio.currentTime)} / ${formatDuration(currentDurationSeconds)}`;
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
function renderMembers(members) {
|
||||||
|
latestMembers = members;
|
||||||
|
presenceOverlay.innerHTML = members.map((m) => `
|
||||||
|
<div class="avatar" style="background:${colorForName(m.userName)}" title="${escapeHtml(m.userName)}">
|
||||||
|
${escapeHtml(initialsFor(m.userName))}
|
||||||
|
</div>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Inline "add tracks" browser: artist -> album -> tracks, entirely
|
||||||
|
// within this page (see class comment for why not a route navigation). ---
|
||||||
|
let browsing = false;
|
||||||
|
|
||||||
|
root.querySelector('#toggleBrowseBtn').addEventListener('click', () => {
|
||||||
|
browsing = !browsing;
|
||||||
|
browsePanel.classList.toggle('hide', !browsing);
|
||||||
|
if (browsing) loadMusicLibraries();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadMusicLibraries() {
|
||||||
|
browsePanel.innerHTML = '<p class="hint">Chargement...</p>';
|
||||||
|
try {
|
||||||
|
const libraries = (await getLibraries()).filter((l) => l.collectionType === 'music');
|
||||||
|
if (!libraries.length) {
|
||||||
|
browsePanel.innerHTML = '<p class="hint">Aucune bibliothèque musicale.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Almost always exactly one - skip straight to its artists rather
|
||||||
|
// than making the common case click through a one-item list.
|
||||||
|
if (libraries.length === 1) {
|
||||||
|
loadArtists(libraries[0].id);
|
||||||
|
} else {
|
||||||
|
browsePanel.innerHTML = renderBrowseList(libraries.map((l) => ({ id: l.id, name: l.name })), null);
|
||||||
|
wireBrowseList((id) => loadArtists(id));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
browsePanel.innerHTML = '<p class="error">Impossible de charger les bibliothèques.</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadArtists(libraryId) {
|
||||||
|
browsePanel.innerHTML = '<p class="hint">Chargement...</p>';
|
||||||
|
try {
|
||||||
|
const artists = await getArtists(libraryId);
|
||||||
|
browsePanel.innerHTML = renderBrowseList(artists.map((a) => ({ id: a.id, name: a.name })), null);
|
||||||
|
wireBrowseList((id) => loadAlbums(id));
|
||||||
|
} catch (err) {
|
||||||
|
browsePanel.innerHTML = '<p class="error">Impossible de charger les artistes.</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAlbums(artistId) {
|
||||||
|
browsePanel.innerHTML = '<p class="hint">Chargement...</p>';
|
||||||
|
try {
|
||||||
|
const albums = await getArtistAlbums(artistId);
|
||||||
|
browsePanel.innerHTML = renderBrowseList(albums.map((a) => ({ id: a.id, name: a.year ? `${a.name} (${a.year})` : a.name })), () => loadMusicLibraries());
|
||||||
|
wireBrowseList((id) => loadTracks(id));
|
||||||
|
} catch (err) {
|
||||||
|
browsePanel.innerHTML = '<p class="error">Impossible de charger les albums.</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTracks(albumId) {
|
||||||
|
browsePanel.innerHTML = '<p class="hint">Chargement...</p>';
|
||||||
|
try {
|
||||||
|
const tracks = await getAlbumTracks(albumId);
|
||||||
|
browsePanel.innerHTML = `
|
||||||
|
<button class="linklike browseBack">← Retour</button>
|
||||||
|
${tracks.map((t) => `
|
||||||
|
<div class="trackRow">
|
||||||
|
<span class="trackIndex">${t.indexNumber != null ? t.indexNumber : ''}</span>
|
||||||
|
<span class="trackName">${escapeHtml(t.name)}</span>
|
||||||
|
<span class="trackDuration">${t.durationTicks ? formatDuration(t.durationTicks / 10000000) : ''}</span>
|
||||||
|
<button class="addTrackBtn" data-track="${encodeURIComponent(JSON.stringify({
|
||||||
|
itemId: t.id, itemName: t.name, artistName: t.artistName, albumName: t.albumName,
|
||||||
|
itemImageUrl: t.imageUrl, durationTicks: t.durationTicks
|
||||||
|
}))}">Ajouter</button>
|
||||||
|
</div>`).join('')}`;
|
||||||
|
browsePanel.querySelector('.browseBack').addEventListener('click', () => loadMusicLibraries());
|
||||||
|
browsePanel.querySelectorAll('.addTrackBtn').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
btn.disabled = true;
|
||||||
|
const originalLabel = btn.textContent;
|
||||||
|
try {
|
||||||
|
await addTrackToQueue(partyId, JSON.parse(decodeURIComponent(btn.dataset.track)));
|
||||||
|
btn.textContent = 'Ajouté !';
|
||||||
|
setTimeout(() => { btn.textContent = originalLabel; btn.disabled = false; }, 1500);
|
||||||
|
} catch (err) {
|
||||||
|
alert(err.message);
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
browsePanel.innerHTML = '<p class="error">Impossible de charger les morceaux.</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBrowseList(items, onBack) {
|
||||||
|
const back = onBack ? '<button class="linklike browseBack">← Retour</button>' : '';
|
||||||
|
const body = items.length
|
||||||
|
? items.map((i) => `<button class="linklike browseItem" data-id="${escapeHtml(i.id)}">${escapeHtml(i.name)}</button>`).join('')
|
||||||
|
: '<p class="hint">Rien à afficher.</p>';
|
||||||
|
return `${back}<div class="browseList">${body}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function wireBrowseList(onPick) {
|
||||||
|
const back = browsePanel.querySelector('.browseBack');
|
||||||
|
if (back) back.addEventListener('click', () => loadMusicLibraries());
|
||||||
|
browsePanel.querySelectorAll('.browseItem').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', () => onPick(btn.dataset.id));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Init ---
|
||||||
|
applyMusicState(party.music);
|
||||||
|
|
||||||
|
conn = connectParty(partyId, {
|
||||||
|
onMembers: renderMembers,
|
||||||
|
onQueueUpdated: applyMusicState,
|
||||||
|
onError: (message) => alert(message),
|
||||||
|
onClose: () => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
root.querySelector('#leaveBtn').addEventListener('click', () => navigate('/home'));
|
||||||
|
|
||||||
|
return function cleanup() {
|
||||||
|
if (positionTicker) clearInterval(positionTicker);
|
||||||
|
if (hls) hls.destroy();
|
||||||
|
audio.pause();
|
||||||
|
audio.removeAttribute('src');
|
||||||
|
if (conn) conn.leave();
|
||||||
|
leaveParty(partyId).catch(() => {});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import { streamUrl, getPlaybackOptions, auth } from '../embyApi.js';
|
import { streamUrl, getPlaybackOptions, auth } from '../embyApi.js';
|
||||||
import { getParty, leaveParty, connectParty } from '../party.js';
|
import { getParty, leaveParty, connectParty } from '../party.js';
|
||||||
import { navigate } from '../router.js';
|
import { navigate } from '../router.js';
|
||||||
import { escapeHtml } from '../tiles.js';
|
import { escapeHtml, colorForName, initialsFor } from '../tiles.js';
|
||||||
|
import { renderMusicPlayer } from './musicPlayer.js';
|
||||||
|
|
||||||
const TICKS_PER_SECOND = 10000000; // Emby/.NET convention: 1 tick = 100ns
|
const TICKS_PER_SECOND = 10000000; // Emby/.NET convention: 1 tick = 100ns
|
||||||
const DRIFT_TOLERANCE_SECONDS = 1.5;
|
const DRIFT_TOLERANCE_SECONDS = 1.5;
|
||||||
|
|
@ -18,20 +19,6 @@ const QUALITY_PRESETS = [
|
||||||
{ label: '480p (~1.5 Mbps)', value: '1500000' }
|
{ label: '480p (~1.5 Mbps)', value: '1500000' }
|
||||||
];
|
];
|
||||||
|
|
||||||
// Small deterministic palette so the same person always gets the same
|
|
||||||
// avatar color across a session, without needing the server to assign one.
|
|
||||||
const AVATAR_COLORS = ['#52b54b', '#4c8bf5', '#f5a623', '#e05d5d', '#9b59b6', '#1abc9c', '#e67e22', '#3498db'];
|
|
||||||
|
|
||||||
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];
|
|
||||||
}
|
|
||||||
|
|
||||||
function initialsFor(name) {
|
|
||||||
return (name || '').trim().slice(0, 2).toUpperCase() || '?';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Plain inline SVG rather than a Unicode glyph (unlike the settings gear)
|
// Plain inline SVG rather than a Unicode glyph (unlike the settings gear)
|
||||||
// specifically so this small a badge stays a crisp two-bar shape regardless
|
// specifically so this small a badge stays a crisp two-bar shape regardless
|
||||||
// of the platform's emoji font - pause/play glyphs default to emoji
|
// of the platform's emoji font - pause/play glyphs default to emoji
|
||||||
|
|
@ -42,9 +29,58 @@ const PAUSE_ICON_SVG = `
|
||||||
<rect x="6.1" y="1" width="2.4" height="8" fill="#fff"></rect>
|
<rect x="6.1" y="1" width="2.4" height="8" fill="#fff"></rect>
|
||||||
</svg>`;
|
</svg>`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Route `/party/:id` - dispatches on the party's mediaType, fetched here
|
||||||
|
* once, since both renderVideoPlayer and renderMusicPlayer need to know it
|
||||||
|
* before they can render anything at all.
|
||||||
|
*
|
||||||
|
* Has to return its cleanup function *synchronously* (route()'s contract -
|
||||||
|
* see router.js), but which cleanup function that even is depends on a
|
||||||
|
* fetch that hasn't resolved yet at the time this returns. Solved with a
|
||||||
|
* mutable box: an outer cleanup wrapper is handed back immediately, and it
|
||||||
|
* calls whatever the real cleanup turns out to be once the fetch settles -
|
||||||
|
* or nothing at all, correctly, if the fetch is still pending or failed
|
||||||
|
* when it runs (nothing was ever set up in that case - no socket, no
|
||||||
|
* timer, nothing to leak).
|
||||||
|
*/
|
||||||
export function renderPartyPlayer(root, params) {
|
export function renderPartyPlayer(root, params) {
|
||||||
const partyId = params.id;
|
const partyId = params.id;
|
||||||
|
root.innerHTML = '<p class="hint">Chargement...</p>';
|
||||||
|
|
||||||
|
let cleanup = () => {};
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
getParty(partyId).then((party) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
cleanup = (party.mediaType === 'music' ? renderMusicPlayer : renderVideoPlayer)(root, partyId, party) || (() => {});
|
||||||
|
}).catch((err) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
// Full error (not just .message) to the console - this also catches a
|
||||||
|
// rendering bug in renderVideoPlayer/renderMusicPlayer itself, not just
|
||||||
|
// a genuine "party not found", so the stack trace matters here.
|
||||||
|
console.error('[watchparty player] failed to open party', err);
|
||||||
|
alert(err.message);
|
||||||
|
navigate('/home');
|
||||||
|
});
|
||||||
|
|
||||||
|
return function outerCleanup() {
|
||||||
|
cancelled = true;
|
||||||
|
cleanup();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the video half of a party - called by the dispatcher above once
|
||||||
|
* it already knows (from a fetched `party`) that mediaType isn't 'music'.
|
||||||
|
* Takes the already-fetched party instead of fetching it itself: the
|
||||||
|
* dispatcher needs to fetch it anyway just to read mediaType, and
|
||||||
|
* route()'s contract needs its cleanup function back *synchronously* (see
|
||||||
|
* router.js) - an async renderVideoPlayer awaiting its own fetch would
|
||||||
|
* return a Promise instead, which router.js's `typeof cleanup ===
|
||||||
|
* 'function'` check would silently skip, reintroducing the exact leak
|
||||||
|
* class this app already spent real effort fixing once.
|
||||||
|
*/
|
||||||
|
function renderVideoPlayer(root, partyId, party) {
|
||||||
root.innerHTML = `
|
root.innerHTML = `
|
||||||
<div class="playerWrap">
|
<div class="playerWrap">
|
||||||
<video id="video" playsinline></video>
|
<video id="video" playsinline></video>
|
||||||
|
|
@ -322,15 +358,6 @@ export function renderPartyPlayer(root, params) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function init() {
|
async function init() {
|
||||||
let party;
|
|
||||||
try {
|
|
||||||
party = await getParty(partyId);
|
|
||||||
} catch (err) {
|
|
||||||
alert(err.message);
|
|
||||||
navigate('/home');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
itemTitleEl.textContent = party.itemName || '';
|
itemTitleEl.textContent = party.itemName || '';
|
||||||
hasNextQueued = !!party.hasNext;
|
hasNextQueued = !!party.hasNext;
|
||||||
followerNotice.classList.toggle('hide', party.youAreHost);
|
followerNotice.classList.toggle('hide', party.youAreHost);
|
||||||
|
|
|
||||||
|
|
@ -517,9 +517,9 @@ generic grid:
|
||||||
- **`views/library.js`** (route `/library/:id`): dispatches purely on the
|
- **`views/library.js`** (route `/library/:id`): dispatches purely on the
|
||||||
library's `collectionType`. `movies`/`tvshows`/`musicvideos` all get a tile
|
library's `collectionType`. `movies`/`tvshows`/`musicvideos` all get a tile
|
||||||
grid (`GET /api/catalog/libraries/:id/items`, `Recursive=false` — Movies
|
grid (`GET /api/catalog/libraries/:id/items`, `Recursive=false` — Movies
|
||||||
and music videos as themselves, TV as Series tiles); `music` gets an
|
and music videos as themselves, TV as Series tiles); `music` gets a tile
|
||||||
honest "not built yet" message instead of a half-working page (see
|
grid too, just of artists (`GET /api/catalog/libraries/:id/artists`) —
|
||||||
Non-goals).
|
see "Music: participative playlist" below for the rest of that hierarchy.
|
||||||
- **Tiles** (`client/js/tiles.js`, shared by the home carousels and the
|
- **Tiles** (`client/js/tiles.js`, shared by the home carousels and the
|
||||||
library grid so they can't drift apart): poster + name, hover-revealed
|
library grid so they can't drift apart): poster + name, hover-revealed
|
||||||
overlay with "Lancer une watch party" (only for directly-playable types —
|
overlay with "Lancer une watch party" (only for directly-playable types —
|
||||||
|
|
@ -617,19 +617,11 @@ track/quality reload are new protocol surface with **no test coverage at
|
||||||
all yet** (no way to exercise real HLS playback without a live session) —
|
all yet** (no way to exercise real HLS playback without a live session) —
|
||||||
the first things to check once a real party actually plays something.
|
the first things to check once a real party actually plays something.
|
||||||
|
|
||||||
## Non-goals (updated)
|
## Non-goals
|
||||||
|
|
||||||
**Music** was requested (dedicated audio player, artist/album browsing, a
|
Chat/reactions and OIDC/SSO remain out of scope - see Goals/Non-goals at
|
||||||
shared/collaborative playlist party members can add to together) but is
|
the top. Music was in this list for a while (see git history) - it's
|
||||||
deliberately not in this pass: it's a genuinely different subsystem — a
|
implemented now, see its own section below.
|
||||||
different player paradigm, full library-depth browsing down to the track,
|
|
||||||
and real-time collaborative queue state that doesn't fit the existing
|
|
||||||
"host drives, everyone follows" model at all (multiple people adding to one
|
|
||||||
shared list is a different kind of shared state than one host's playback
|
|
||||||
position). The sidebar and library routing already treat `music` as a first
|
|
||||||
-class `collectionType` specifically so slotting in a real implementation
|
|
||||||
later doesn't need another structural pass — see `library.js`'s early
|
|
||||||
return. Right now it just shows an honest placeholder.
|
|
||||||
|
|
||||||
## Server-side logging
|
## Server-side logging
|
||||||
|
|
||||||
|
|
@ -678,14 +670,227 @@ throttled repeat `"requestPause"` produced no second `"pauseRequested"`
|
||||||
line - all assertions passed, counted programmatically rather than
|
line - all assertions passed, counted programmatically rather than
|
||||||
eyeballed.
|
eyeballed.
|
||||||
|
|
||||||
|
## Music: participative playlist
|
||||||
|
|
||||||
|
The last deferred piece from the original UI spec, built once asked for
|
||||||
|
directly. Three things needed designing before any code: the permission
|
||||||
|
model for the shared queue (asked, not assumed - see session history), how
|
||||||
|
audio playback fits the existing party/socket infrastructure, and how
|
||||||
|
"browse and add a track" works without leaving the player.
|
||||||
|
|
||||||
|
**Chosen model: free add, vote-to-skip, no host for playback.** Anyone in
|
||||||
|
the party can add a track to the shared queue at any time. Anyone can
|
||||||
|
pause/resume directly (reversible, low-stakes - unlike video's host-only
|
||||||
|
`"playstate"`). Skipping the *current* track needs a strict majority vote
|
||||||
|
of currently connected members - the one action asked to be gated, since
|
||||||
|
unlike adding (purely additive) or pausing (trivially undone), skipping
|
||||||
|
throws away where the party is now. This is a genuinely different shape
|
||||||
|
of shared state than video's "one host drives, everyone follows" - not a
|
||||||
|
size difference, a different model - which is exactly why it's a separate
|
||||||
|
service (`server/src/musicQueueService.js`, `MusicQueueService`) rather
|
||||||
|
than more branches inside `partyManager.js`/`socketHandler.js`'s existing
|
||||||
|
video-shaped logic.
|
||||||
|
|
||||||
|
### Data model and server-authoritative timing
|
||||||
|
|
||||||
|
`Party` gained a `mediaType: 'video' | 'music'` field
|
||||||
|
(`PartyManager.create`'s new last argument, inferred server-side in
|
||||||
|
`POST /api/parties` from the clicked item's Emby `Type` - `'Audio'` means
|
||||||
|
music, everything else already handled means video - the client never has
|
||||||
|
to know or say which). A music party's `party.music` object
|
||||||
|
(`{queue, current, skipVotes, advanceTimer}`) is entirely separate from
|
||||||
|
the fixed pre-built `party.queue`/`queueIndex` video parties use - a music
|
||||||
|
queue is live and mutable, not something computed once at creation.
|
||||||
|
|
||||||
|
No single client is "the position" for music the way a video host's
|
||||||
|
heartbeat is. Instead, `party.music.current.startedUtc` (a wall-clock
|
||||||
|
timestamp) plus the track's known duration is the only state that exists -
|
||||||
|
`getElapsedSeconds()` computes position on demand
|
||||||
|
(`Date.now() - startedUtc`, or a frozen `pausedElapsedMs` while paused),
|
||||||
|
never ticked or stored. This is also what makes auto-advance possible
|
||||||
|
without any client "reporting" a track ended (which, with several clients'
|
||||||
|
players all finishing within milliseconds of each other and no single
|
||||||
|
host, could race): `_scheduleAdvance` sets one `setTimeout` for exactly
|
||||||
|
when the current track's duration runs out, computed from the same
|
||||||
|
`startedUtc`. A track with no known duration just has nothing scheduled -
|
||||||
|
it relies on a manual vote-skip instead, same as it would if the timer
|
||||||
|
fired.
|
||||||
|
|
||||||
|
That timer is the one genuinely new leak surface this feature adds, given
|
||||||
|
how much this project has already had to fix real memory leaks (see
|
||||||
|
"Realtime transport" above) - `_clearTimer` runs before every reschedule
|
||||||
|
and from a new `destroy(party)` hook wired into **both** of
|
||||||
|
`partyManager.js`'s existing party-destruction paths (`leave`'s last-member
|
||||||
|
case and `removeIdleParties`' sweep), so a torn-down party can never leave
|
||||||
|
a timer firing against state that no longer exists in `PartyManager`'s map.
|
||||||
|
|
||||||
|
`MusicQueueService` needs to broadcast from inside that timer's callback,
|
||||||
|
which runs with no socket/request in scope at all - solved with
|
||||||
|
`attachIo(io, onTrackChanged)`, called once at server startup
|
||||||
|
(`index.js`, right next to where `attachSocketServer` itself is wired up),
|
||||||
|
the same "give the singleton what it needs once, up front" shape
|
||||||
|
`PartyManager`'s own singleton already has. `onTrackChanged` is wired to
|
||||||
|
`broadcastLobby(io)` specifically so a party's home-page card (preview
|
||||||
|
image/name) stays current even when the *timer*, not a person clicking
|
||||||
|
anything, is what changed what's playing.
|
||||||
|
|
||||||
|
### Catalog: Artists → Albums → Tracks
|
||||||
|
|
||||||
|
Three more dedicated Emby endpoints, same reasoning as `Shows → Seasons →
|
||||||
|
Episodes`: guessing folder structure via generic recursive `/Items` calls
|
||||||
|
is fragile, a real endpoint per level isn't. `GET /Artists?ParentId=...`
|
||||||
|
for a library's artists, `GET /Users/{id}/Items?ArtistIds=...
|
||||||
|
&IncludeItemTypes=MusicAlbum` for an artist's albums, `GET
|
||||||
|
/Users/{id}/Items?ParentId=<albumId>&IncludeItemTypes=Audio` for an
|
||||||
|
album's tracks. `toItemDto` grew `durationTicks`/`indexNumber`/
|
||||||
|
`artistName`/`albumName` - additive, `undefined` for every other item type,
|
||||||
|
so nothing existing had to change shape. `WATCH_PARTY_TYPES` grew
|
||||||
|
`'Audio'` (a track can start a party) - `MusicArtist`/`MusicAlbum` stay
|
||||||
|
un-playable, drill-down only, joining `Series` in `tiles.js`'s
|
||||||
|
`DRILL_DOWN` table (generalized from a single `seriesHref` special case to
|
||||||
|
a small per-type lookup once there were three, not two, drill-down
|
||||||
|
targets - `/artist/:id` → `artist.js`, `/album/:id` → `album.js`, each a
|
||||||
|
smaller version of the library-grid/episode-list shape already used
|
||||||
|
elsewhere).
|
||||||
|
|
||||||
|
### Streaming: the same route, one hint
|
||||||
|
|
||||||
|
Audio goes through the *exact same* `/api/catalog/stream/:itemId/master.m3u8`
|
||||||
|
route as video, not a parallel endpoint - Emby's `PlaybackInfo` negotiation
|
||||||
|
isn't video-specific, so the whole `TranscodingUrl`-first,
|
||||||
|
hand-built-fallback dance (see "Player track selection" above) applies
|
||||||
|
identically. The one real difference is the hand-built fallback's path
|
||||||
|
prefix (`/Videos/` vs `/Audio/`), which the response doesn't reliably
|
||||||
|
reveal on its own and looking up would cost an extra Emby round trip for
|
||||||
|
something the client already knows - so the client tags the request
|
||||||
|
(`streamUrl(itemId, {mediaType:'audio'})`, `audioStreamUrl()` in
|
||||||
|
`embyApi.js`) and the route reads it back
|
||||||
|
(`req.query.mediaType === 'audio'`). The music player uses hls.js for the
|
||||||
|
same reason the video player does: most browsers have no native `.m3u8`
|
||||||
|
support at all, and there's no reason to assume Emby's negotiation
|
||||||
|
produces anything *other* than HLS just because the media is audio.
|
||||||
|
|
||||||
|
### Player: not theater-mode, and why adding a track doesn't navigate away
|
||||||
|
|
||||||
|
`renderPartyPlayer` (route `/party/:id`, `player.js`) is now a small
|
||||||
|
dispatcher: it fetches the party once (both the video and music renderers
|
||||||
|
need `mediaType` before they can render anything at all) and delegates to
|
||||||
|
`renderVideoPlayer` or the new `renderMusicPlayer`
|
||||||
|
(`views/musicPlayer.js`). The dispatch itself can't be `async` despite
|
||||||
|
needing to `await` that fetch - `route()`'s contract needs its cleanup
|
||||||
|
function back *synchronously* (see `router.js`), and an `async` handler
|
||||||
|
would hand back a `Promise` instead, which `router.js`'s `typeof cleanup
|
||||||
|
=== 'function'` check silently skips - reintroducing the exact leak class
|
||||||
|
this project already spent real effort fixing once. Solved with a mutable
|
||||||
|
box: `renderPartyPlayer` returns a wrapper immediately, which calls
|
||||||
|
whichever real cleanup function the fetch eventually produces - or nothing
|
||||||
|
at all, correctly, if the party navigates away before that fetch even
|
||||||
|
resolves (nothing was set up yet in that case - no socket, no timer,
|
||||||
|
nothing to leak).
|
||||||
|
|
||||||
|
Unlike the video player, the music player is **not** full-viewport/theater
|
||||||
|
-mode (no `.playerWrap`-style `position:fixed`) - there's no video image to
|
||||||
|
declutter around, so it renders as a normal page and the sidebar stays up.
|
||||||
|
That's also what makes "add tracks while listening" simple: rather than
|
||||||
|
navigating to a separate browsing page and losing the player's own state
|
||||||
|
(and having to thread "which party am I adding to" through a chain of
|
||||||
|
query params across three route levels - considered, rejected as needless
|
||||||
|
complexity for what browsing-with-context would have bought), the player
|
||||||
|
has its own small embedded Artist → Album → Track browser
|
||||||
|
(`musicPlayer.js`'s `loadMusicLibraries`/`loadArtists`/`loadAlbums`/
|
||||||
|
`loadTracks`), reusing the exact same catalog endpoints, rendered inline
|
||||||
|
in a toggleable panel. Adding a track is REST
|
||||||
|
(`POST /api/parties/:partyId/tracks`, `addTrackToQueue()` in `party.js`),
|
||||||
|
not a socket event like `voteSkip`/`musicPlaystate` - deliberately, since
|
||||||
|
it needs to work from this same panel regardless of realtime-connection
|
||||||
|
state, and `MusicQueueService.addTrack`'s own `"queueUpdated"` broadcast
|
||||||
|
(via the `attachIo` wiring above) reaches everyone actually connected
|
||||||
|
either way, so nothing about the realtime side needed a second code path.
|
||||||
|
|
||||||
|
### A real bug found while testing, not just the mock's fault
|
||||||
|
|
||||||
|
`applyMusicState` (the "queueUpdated"/initial-render handler) used to
|
||||||
|
update the queue list and vote button *after* the current-track branch,
|
||||||
|
which calls `loadTrack()` - and `loadTrack()`'s hls.js setup can throw (a
|
||||||
|
bad manifest, hls.js failing to load from the CDN, any of it). Because
|
||||||
|
JavaScript doesn't roll back a function's earlier statements when a later
|
||||||
|
one throws, but *does* abort everything after the throw in the same call,
|
||||||
|
an exception there took the queue list and vote tally down with it even
|
||||||
|
though they have nothing to do with whether one specific track's audio
|
||||||
|
loaded - and worse, on the very first render, it escaped `renderMusicPlayer`
|
||||||
|
entirely into the dispatcher's `.catch()`, which alerts and navigates back
|
||||||
|
to `/home`, discarding the whole view. Fixed two ways: reordered
|
||||||
|
`applyMusicState` so the queue/vote/icon updates - which can't fail - run
|
||||||
|
*before* the current-track branch that can (mirroring `player.js`'s
|
||||||
|
`onItemChanged`, which already updates the title/next-button before
|
||||||
|
calling `loadVideo` for exactly this reason), and wrapped `loadTrack`'s
|
||||||
|
hls.js setup in its own `try/catch` so a failure there logs and stops,
|
||||||
|
rather than propagating out of the handler at all. Found by a test whose
|
||||||
|
own mock happened to be missing `Hls.Events` - which pointed at a real
|
||||||
|
fragility in the shipped code, not just an incomplete mock, so it's fixed
|
||||||
|
in `musicPlayer.js` itself, not worked around in the test.
|
||||||
|
|
||||||
|
### Verified
|
||||||
|
|
||||||
|
Server-side, directly against the real shipped modules, not reimplemented
|
||||||
|
mocks (see session history): a scripted sequence against the real
|
||||||
|
`musicQueueService.js` alone - init auto-starts the first track, a second
|
||||||
|
add while one plays queues rather than jumping the queue, a two-vote
|
||||||
|
majority skips and clears the tally, pause/resume freezes and correctly
|
||||||
|
resumes elapsed time, a short real track auto-advances on its own via a
|
||||||
|
genuine `setTimeout` after its duration, and `destroy()` clears a pending
|
||||||
|
timer (18 checks, instrumented `setTimeout`/`clearTimeout` counting to
|
||||||
|
catch anything left dangling - same technique used for the client-side
|
||||||
|
router-cleanup leak earlier in this project). A second pass through the
|
||||||
|
real `socketHandler.js` + `partyManager.js` together (fake `io`/sockets)
|
||||||
|
confirmed `voteSkip`/`musicPlaystate` are no-ops on a video party, a
|
||||||
|
non-host member can pause and resume a music party's track directly, a
|
||||||
|
two-connected-member vote-skip needs exactly two votes, and the resulting
|
||||||
|
`"queueUpdated"` really reaches the party's socket room (7 checks).
|
||||||
|
|
||||||
|
Client-side, against the real running local server with mocked
|
||||||
|
`fetch`/`Hls`/`io` (see session history): the full library → artist →
|
||||||
|
album browsing chain renders correct tiles and drill-down links at each
|
||||||
|
level; starting a party from a track correctly creates one and navigates;
|
||||||
|
the dispatcher correctly renders the music player (not the video one) for
|
||||||
|
a `mediaType:'music'` party and still renders the video player when
|
||||||
|
`mediaType` is absent (back-compat); the now-playing header, vote tally
|
||||||
|
(computed against the live member list, matching `musicQueueService.js`'s
|
||||||
|
own majority math), and queue list all render correctly and update
|
||||||
|
correctly on a `"queueUpdated"` push including a live track change (new
|
||||||
|
audio source loaded); play/pause and vote-skip clicks emit the right
|
||||||
|
socket events; the inline browse-and-add panel correctly walks
|
||||||
|
artist → album → tracks and POSTs the exact right payload shape to
|
||||||
|
`/api/parties/:partyId/tracks`; and the crash fixed above was confirmed
|
||||||
|
fixed by re-running the same broken-`Hls.Events` scenario through the
|
||||||
|
full dispatcher afterward - no alert, no navigation away, the queue/track
|
||||||
|
info render correctly regardless.
|
||||||
|
|
||||||
|
**Not verified** - the usual limit of testing without real Emby access:
|
||||||
|
whether `/Artists`/the album-tracks query really return what's assumed
|
||||||
|
here, whether a music item's `PlaybackInfo`/`TranscodingUrl` behaves the
|
||||||
|
same way a video's does (reasoned to, per "Streaming" above, not
|
||||||
|
observed), and the real audio quality/gapless-ness of playback through a
|
||||||
|
transcode. First things to check once this gets a real retest.
|
||||||
|
|
||||||
## Known limitations
|
## Known limitations
|
||||||
|
|
||||||
- Playback always goes through `hls.js` against whatever Emby's
|
- Playback always goes through `hls.js` against whatever Emby's
|
||||||
`PlaybackInfo` suggests. If that ever comes back as a direct-play source
|
`PlaybackInfo` suggests. If that ever comes back as a direct-play source
|
||||||
with no `TranscodingUrl` (no transcoding needed/possible), the client has
|
with no `TranscodingUrl` (no transcoding needed/possible), the client has
|
||||||
no fallback path yet - it'll try to hand hls.js something that isn't HLS.
|
no fallback path yet - it'll try to hand hls.js something that isn't HLS.
|
||||||
- One item per party; no shared queue.
|
- One fixed item/episode-queue per **video** party (a music party's queue
|
||||||
- A party member needs a browser tab open and connected for sync to apply —
|
is the live, participative one - see "Music" above).
|
||||||
there's no "catch up automatically when you come back" beyond whatever
|
- A video party member needs a browser tab open and connected for sync to
|
||||||
the next heartbeat corrects.
|
apply - there's no "catch up automatically when you come back" beyond
|
||||||
|
whatever the next heartbeat corrects. Music has no equivalent heartbeat
|
||||||
|
at all (a deliberate simplification, not an oversight - see "Music"
|
||||||
|
above): position only resyncs on a discrete event (add/skip/pause/
|
||||||
|
resume), so two listeners' `<audio>` elements could drift apart slightly
|
||||||
|
during a long stretch with no such event - accepted for v1 given music's
|
||||||
|
more forgiving tolerance for exact sync compared to video+subtitles.
|
||||||
- No persistence: server restart or long idle time ends every party.
|
- No persistence: server restart or long idle time ends every party.
|
||||||
|
- No free seeking within a music track (only vote-skip to the *next* one)
|
||||||
|
- not asked for, and would need its own protocol addition
|
||||||
|
(`MusicQueueService` has no notion of "jump to position X" today, only
|
||||||
|
play/pause and advance).
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ const {
|
||||||
} = require('./embyAuth');
|
} = require('./embyAuth');
|
||||||
const { embyFetch, proxyEmbyResponse } = require('./embyProxy');
|
const { embyFetch, proxyEmbyResponse } = require('./embyProxy');
|
||||||
const { PartyManager } = require('./partyManager');
|
const { PartyManager } = require('./partyManager');
|
||||||
|
const { MusicQueueService } = require('./musicQueueService');
|
||||||
const { attachSocketServer, broadcastLobby, applyLeave } = require('./socketHandler');
|
const { attachSocketServer, broadcastLobby, applyLeave } = require('./socketHandler');
|
||||||
const { streamLog } = require('./logger');
|
const { streamLog } = require('./logger');
|
||||||
|
|
||||||
|
|
@ -183,6 +184,56 @@ app.get('/api/catalog/series/:seriesId/episodes', requireSession, async (req, re
|
||||||
res.json({ episodes: (data.Items || []).map((e) => toItemDto(e, req.embySession.serverId)) });
|
res.json({ episodes: (data.Items || []).map((e) => toItemDto(e, req.embySession.serverId)) });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Library -> Artists -> Albums -> Tracks, for the "Musique" browsing pages
|
||||||
|
// (client/js/views/library.js, artist.js, album.js). Each its own Emby
|
||||||
|
// endpoint/query rather than guessing folder structure, same reasoning as
|
||||||
|
// Shows -> Seasons -> Episodes above.
|
||||||
|
app.get('/api/catalog/libraries/:libraryId/artists', requireSession, async (req, res) => {
|
||||||
|
const params = new URLSearchParams({ ParentId: req.params.libraryId, UserId: req.embySession.userId, SortBy: 'SortName' });
|
||||||
|
const embyRes = await embyFetch(`/Artists?${params}`, req.embySession.embyToken);
|
||||||
|
if (!embyRes.ok) {
|
||||||
|
res.status(502).json({ error: 'Impossible de récupérer les artistes.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await embyRes.json();
|
||||||
|
res.json({ artists: (data.Items || []).map((a) => toItemDto(a, req.embySession.serverId)) });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/catalog/artists/:artistId/albums', requireSession, async (req, res) => {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
ArtistIds: req.params.artistId,
|
||||||
|
UserId: req.embySession.userId,
|
||||||
|
IncludeItemTypes: 'MusicAlbum',
|
||||||
|
Recursive: 'true',
|
||||||
|
SortBy: 'ProductionYear,SortName',
|
||||||
|
Fields: 'ProductionYear'
|
||||||
|
});
|
||||||
|
const embyRes = await embyFetch(`/Users/${req.embySession.userId}/Items?${params}`, req.embySession.embyToken);
|
||||||
|
if (!embyRes.ok) {
|
||||||
|
res.status(502).json({ error: 'Impossible de récupérer les albums.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await embyRes.json();
|
||||||
|
res.json({ albums: (data.Items || []).map((a) => toItemDto(a, req.embySession.serverId)) });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/catalog/albums/:albumId/tracks', requireSession, async (req, res) => {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
ParentId: req.params.albumId,
|
||||||
|
UserId: req.embySession.userId,
|
||||||
|
IncludeItemTypes: 'Audio',
|
||||||
|
SortBy: 'IndexNumber',
|
||||||
|
Fields: 'RunTimeTicks,IndexNumber,Album,AlbumArtist'
|
||||||
|
});
|
||||||
|
const embyRes = await embyFetch(`/Users/${req.embySession.userId}/Items?${params}`, req.embySession.embyToken);
|
||||||
|
if (!embyRes.ok) {
|
||||||
|
res.status(502).json({ error: 'Impossible de récupérer les morceaux.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await embyRes.json();
|
||||||
|
res.json({ tracks: (data.Items || []).map((t) => toItemDto(t, req.embySession.serverId)) });
|
||||||
|
});
|
||||||
|
|
||||||
// Audio/subtitle tracks available for an item, read from Emby's own playback
|
// Audio/subtitle tracks available for an item, read from Emby's own playback
|
||||||
// negotiation (PlaybackInfo) - powers the player's language/subtitle pickers.
|
// negotiation (PlaybackInfo) - powers the player's language/subtitle pickers.
|
||||||
app.get('/api/catalog/item/:itemId/playback-options', requireSession, async (req, res) => {
|
app.get('/api/catalog/item/:itemId/playback-options', requireSession, async (req, res) => {
|
||||||
|
|
@ -280,6 +331,17 @@ app.get('/api/catalog/stream/:itemId/master.m3u8', async (req, res) => {
|
||||||
// have one at all) - the hand-built fallback below now at least supplies
|
// have one at all) - the hand-built fallback below now at least supplies
|
||||||
// the parameters PlaybackInfo reveals (MediaSourceId, PlaySessionId,
|
// the parameters PlaybackInfo reveals (MediaSourceId, PlaySessionId,
|
||||||
// UserId) instead of guessing, but is still a fallback, not the primary path.
|
// UserId) instead of guessing, but is still a fallback, not the primary path.
|
||||||
|
// Music tracks stream through this exact route too (same PlaybackInfo ->
|
||||||
|
// TranscodingUrl dance - Emby's negotiation isn't video-specific) - the
|
||||||
|
// only place that actually differs is the hand-built fallback path,
|
||||||
|
// which is genuinely different between the two (/Videos/ vs /Audio/) and
|
||||||
|
// has no way to know which without being told: PlaybackInfo's own
|
||||||
|
// response doesn't reliably carry the item's Type back, and looking it
|
||||||
|
// up would be an extra Emby round trip for what the client already
|
||||||
|
// knows. So the client passes mediaType=audio (see streamUrl() in
|
||||||
|
// embyApi.js) - cosmetic in every other sense, this is the one place it
|
||||||
|
// actually changes what gets requested.
|
||||||
|
const isAudio = req.query.mediaType === 'audio';
|
||||||
let target;
|
let target;
|
||||||
if (source.TranscodingUrl) {
|
if (source.TranscodingUrl) {
|
||||||
const [urlPath, urlQuery] = source.TranscodingUrl.split('?');
|
const [urlPath, urlQuery] = source.TranscodingUrl.split('?');
|
||||||
|
|
@ -292,11 +354,11 @@ app.get('/api/catalog/stream/:itemId/master.m3u8', async (req, res) => {
|
||||||
UserId: session.userId,
|
UserId: session.userId,
|
||||||
MediaSourceId: source.Id || '',
|
MediaSourceId: source.Id || '',
|
||||||
PlaySessionId: info.PlaySessionId || '',
|
PlaySessionId: info.PlaySessionId || '',
|
||||||
VideoCodec: 'h264',
|
AudioCodec: 'aac',
|
||||||
AudioCodec: 'aac'
|
...(isAudio ? {} : { VideoCodec: 'h264' })
|
||||||
});
|
});
|
||||||
applyPlaybackOverrides(params, req.query);
|
applyPlaybackOverrides(params, req.query);
|
||||||
target = `/Videos/${itemId}/master.m3u8?${params}`;
|
target = `/${isAudio ? 'Audio' : 'Videos'}/${itemId}/master.m3u8?${params}`;
|
||||||
logStreamRequest(itemId, 'fallback', params);
|
logStreamRequest(itemId, 'fallback', params);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -376,8 +438,8 @@ app.get('/api/catalog/stream/:itemId/raw', async (req, res) => {
|
||||||
// contents (distinct from the generic 'Video', which is home videos) -
|
// contents (distinct from the generic 'Video', which is home videos) -
|
||||||
// needed now that musicvideos is one of the visible library types (see
|
// needed now that musicvideos is one of the visible library types (see
|
||||||
// VISIBLE_COLLECTION_TYPES above), otherwise every tile in it would render
|
// VISIBLE_COLLECTION_TYPES above), otherwise every tile in it would render
|
||||||
// with no "Lancer une watch party" button at all.
|
// with no "Lancer une watch party" button at all. 'Audio' is a music track.
|
||||||
const WATCH_PARTY_TYPES = new Set(['Movie', 'Episode', 'Video', 'MusicVideo']);
|
const WATCH_PARTY_TYPES = new Set(['Movie', 'Episode', 'Video', 'MusicVideo', 'Audio']);
|
||||||
|
|
||||||
function toItemDto(item, serverId) {
|
function toItemDto(item, serverId) {
|
||||||
return {
|
return {
|
||||||
|
|
@ -386,11 +448,18 @@ function toItemDto(item, serverId) {
|
||||||
overview: item.Overview,
|
overview: item.Overview,
|
||||||
year: item.ProductionYear,
|
year: item.ProductionYear,
|
||||||
type: item.Type,
|
type: item.Type,
|
||||||
// Series/Season aren't directly playable - only show "start a party" for
|
// Series/Season/MusicArtist/MusicAlbum aren't directly playable - only
|
||||||
// things Emby can actually stream. "Voir sur Emby" always works though.
|
// show "start a party" for things Emby can actually stream. "Voir sur
|
||||||
|
// Emby" always works though.
|
||||||
canWatchParty: WATCH_PARTY_TYPES.has(item.Type),
|
canWatchParty: WATCH_PARTY_TYPES.has(item.Type),
|
||||||
imageUrl: `/api/catalog/image/${item.Id}`,
|
imageUrl: `/api/catalog/image/${item.Id}`,
|
||||||
embyUrl: `${EMBY_SERVER_URL}/web/index.html#!/item?id=${item.Id}&serverId=${encodeURIComponent(serverId || '')}`
|
embyUrl: `${EMBY_SERVER_URL}/web/index.html#!/item?id=${item.Id}&serverId=${encodeURIComponent(serverId || '')}`,
|
||||||
|
// Track-specific (undefined/null for anything else - harmless, every
|
||||||
|
// consumer that cares about these only reads them for an Audio item).
|
||||||
|
durationTicks: item.RunTimeTicks || null,
|
||||||
|
indexNumber: item.IndexNumber != null ? item.IndexNumber : null,
|
||||||
|
artistName: item.AlbumArtist || (item.Artists && item.Artists[0]) || null,
|
||||||
|
albumName: item.Album || null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -413,12 +482,15 @@ app.post('/api/parties', requireSession, async (req, res) => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const item = await embyRes.json();
|
const item = await embyRes.json();
|
||||||
|
const mediaType = item.Type === 'Audio' ? 'music' : 'video';
|
||||||
|
|
||||||
// Starting a party from an episode queues the rest of the series from
|
// Starting a party from an episode queues the rest of the series from
|
||||||
// there on, so the host can hit "next" (see socketHandler.js) instead of
|
// there on, so the host can hit "next" (see socketHandler.js) instead of
|
||||||
// everyone having to separately start a new party per episode.
|
// everyone having to separately start a new party per episode. Doesn't
|
||||||
|
// apply to music - that party's queue is the live, participative one
|
||||||
|
// instead (see MusicQueueService.init below).
|
||||||
let queue = null;
|
let queue = null;
|
||||||
if (item.Type === 'Episode' && item.SeriesId) {
|
if (mediaType === 'video' && item.Type === 'Episode' && item.SeriesId) {
|
||||||
const epsRes = await embyFetch(
|
const epsRes = await embyFetch(
|
||||||
`/Shows/${item.SeriesId}/Episodes?userId=${req.embySession.userId}&Fields=Overview`,
|
`/Shows/${item.SeriesId}/Episodes?userId=${req.embySession.userId}&Fields=Overview`,
|
||||||
req.embySession.embyToken
|
req.embySession.embyToken
|
||||||
|
|
@ -442,8 +514,21 @@ app.post('/api/parties', requireSession, async (req, res) => {
|
||||||
itemId,
|
itemId,
|
||||||
queue ? queue[0].itemName : item.Name,
|
queue ? queue[0].itemName : item.Name,
|
||||||
`/api/catalog/image/${itemId}`,
|
`/api/catalog/image/${itemId}`,
|
||||||
queue
|
queue,
|
||||||
|
mediaType
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (mediaType === 'music') {
|
||||||
|
MusicQueueService.init(party, {
|
||||||
|
itemId,
|
||||||
|
itemName: item.Name,
|
||||||
|
artistName: item.AlbumArtist || (item.Artists && item.Artists[0]) || null,
|
||||||
|
albumName: item.Album || null,
|
||||||
|
itemImageUrl: `/api/catalog/image/${itemId}`,
|
||||||
|
durationTicks: item.RunTimeTicks || null
|
||||||
|
}, req.embySession.userId, req.embySession.userName);
|
||||||
|
}
|
||||||
|
|
||||||
broadcastLobby(io); // the new party shows up on everyone's home page immediately
|
broadcastLobby(io); // the new party shows up on everyone's home page immediately
|
||||||
res.json(PartyManager.toPublicState(party, req.embySession.userId));
|
res.json(PartyManager.toPublicState(party, req.embySession.userId));
|
||||||
});
|
});
|
||||||
|
|
@ -456,6 +541,33 @@ function formatEpisodeName(episode) {
|
||||||
return episode.Name;
|
return episode.Name;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Adding a track to a music party's shared queue is REST, not a socket
|
||||||
|
// event (see socketHandler.js's top comment): the album-browsing page
|
||||||
|
// (client/js/views/album.js) that this is actually called from has no live
|
||||||
|
// connection to the party it's adding into - only the player view
|
||||||
|
// (musicPlayer.js) does. MusicQueueService's own "queueUpdated" broadcast
|
||||||
|
// (wired to the real io instance via attachIo, below) reaches anyone
|
||||||
|
// actually connected regardless of which page made this request.
|
||||||
|
app.post('/api/parties/:partyId/tracks', requireSession, (req, res) => {
|
||||||
|
const party = PartyManager.getById(req.params.partyId);
|
||||||
|
if (!party || party.mediaType !== 'music') {
|
||||||
|
res.status(404).json({ error: 'Watch party musique introuvable.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!party.members.has(req.embySession.userId)) {
|
||||||
|
res.status(403).json({ error: "Tu n'es pas membre de cette watch party." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const track = req.body || {};
|
||||||
|
if (!track.itemId) {
|
||||||
|
res.status(400).json({ error: 'itemId is required.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
MusicQueueService.addTrack(party, track, req.embySession.userId, req.embySession.userName);
|
||||||
|
broadcastLobby(io); // the queue/now-playing preview may have just changed
|
||||||
|
res.json(PartyManager.toPublicState(party, req.embySession.userId));
|
||||||
|
});
|
||||||
|
|
||||||
app.post('/api/parties/:partyId/join', requireSession, (req, res) => {
|
app.post('/api/parties/:partyId/join', requireSession, (req, res) => {
|
||||||
const party = PartyManager.getById(req.params.partyId);
|
const party = PartyManager.getById(req.params.partyId);
|
||||||
if (!party) {
|
if (!party) {
|
||||||
|
|
@ -495,6 +607,14 @@ const server = http.createServer(app);
|
||||||
const io = new SocketIoServer(server);
|
const io = new SocketIoServer(server);
|
||||||
attachSocketServer(io, { parseCookies, getSession });
|
attachSocketServer(io, { parseCookies, getSession });
|
||||||
|
|
||||||
|
// Gives MusicQueueService a way to broadcast from its own internal
|
||||||
|
// auto-advance timer (no socket/request in scope there at all) and to
|
||||||
|
// refresh the home page's lobby card when the timer - not a person
|
||||||
|
// clicking anything - changes what's playing. Passed in rather than
|
||||||
|
// required directly from socketHandler.js/musicQueueService.js to avoid a
|
||||||
|
// circular require between the two.
|
||||||
|
MusicQueueService.attachIo(io, () => broadcastLobby(io));
|
||||||
|
|
||||||
setInterval(() => PartyManager.removeIdleParties(4 * 60 * 60 * 1000), 10 * 60 * 1000);
|
setInterval(() => PartyManager.removeIdleParties(4 * 60 * 60 * 1000), 10 * 60 * 1000);
|
||||||
|
|
||||||
const port = process.env.PORT || 3000;
|
const port = process.env.PORT || 3000;
|
||||||
|
|
|
||||||
228
server/src/musicQueueService.js
Normal file
228
server/src/musicQueueService.js
Normal file
|
|
@ -0,0 +1,228 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { partyLog, who } = require('./logger');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owns the "participative playlist" behavior for a music party - kept out
|
||||||
|
* of partyManager.js because it's genuinely a different kind of shared
|
||||||
|
* state than the video model's single host-driven position (see
|
||||||
|
* docs/architecture.md's "Music" section): a live, mutable queue anyone
|
||||||
|
* can add to, server-authoritative timing (nobody is "the host" driving
|
||||||
|
* playback - a track just plays until its known duration runs out or a
|
||||||
|
* vote skips it), and skip-by-vote instead of a single controller.
|
||||||
|
*
|
||||||
|
* Chosen permission model (asked, not assumed - see session history):
|
||||||
|
* - Anyone can add a track to the queue, any time.
|
||||||
|
* - Anyone can pause/resume - it's reversible and low-stakes, unlike
|
||||||
|
* skipping to different content entirely.
|
||||||
|
* - Skipping the current track needs a strict majority vote of
|
||||||
|
* currently CONNECTED members ("membres présents"), not a simple click.
|
||||||
|
*
|
||||||
|
* One singleton instance (same shape as PartyManager below) - the state
|
||||||
|
* itself lives on each `party.music` object; this class is just the
|
||||||
|
* behavior around it, so it slots into the "one service class per
|
||||||
|
* feature" architecture the app is moving toward without a separate
|
||||||
|
* rewrite pass just for this file.
|
||||||
|
*/
|
||||||
|
class MusicQueueService {
|
||||||
|
constructor() {
|
||||||
|
this._io = null;
|
||||||
|
this._onTrackChanged = () => {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called once at server startup (index.js), after the Socket.IO server
|
||||||
|
* exists - nothing here can broadcast before this runs. `onTrackChanged`
|
||||||
|
* is how a party's home-page lobby card (host/preview image/name) gets
|
||||||
|
* refreshed when the *timer* advances the queue with nobody clicking
|
||||||
|
* anything - index.js wires it to broadcastLobby(io). Passed in rather
|
||||||
|
* than required directly from socketHandler.js to avoid a circular
|
||||||
|
* require (socketHandler.js already requires this module).
|
||||||
|
*/
|
||||||
|
attachIo(io, onTrackChanged) {
|
||||||
|
this._io = io;
|
||||||
|
this._onTrackChanged = onTrackChanged || (() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Called once, when a party is created from a music track (index.js). */
|
||||||
|
init(party, firstTrack, addedByUserId, addedByUserName) {
|
||||||
|
party.music = { queue: [], current: null, skipVotes: new Set(), advanceTimer: null };
|
||||||
|
if (firstTrack) {
|
||||||
|
this.addTrack(party, firstTrack, addedByUserId, addedByUserName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addTrack(party, track, addedByUserId, addedByUserName) {
|
||||||
|
const entry = {
|
||||||
|
itemId: track.itemId,
|
||||||
|
itemName: track.itemName,
|
||||||
|
artistName: track.artistName || null,
|
||||||
|
albumName: track.albumName || null,
|
||||||
|
itemImageUrl: track.itemImageUrl,
|
||||||
|
// Emby ticks (100ns, same convention as video position) - converted
|
||||||
|
// once here so the rest of this class only ever deals in seconds.
|
||||||
|
durationSeconds: track.durationTicks ? Math.round(track.durationTicks / 10000000) : null,
|
||||||
|
addedByUserId,
|
||||||
|
addedByUserName
|
||||||
|
};
|
||||||
|
party.music.queue.push(entry);
|
||||||
|
party.lastActivityUtc = Date.now();
|
||||||
|
partyLog(`musicQueueAdd partyId=${party.id} user=${who(addedByUserId, addedByUserName)} track="${entry.itemName}" queueLength=${party.music.queue.length}`);
|
||||||
|
if (!party.music.current) {
|
||||||
|
this._playNext(party); // queue was empty - this track starts immediately
|
||||||
|
}
|
||||||
|
this._broadcast(party);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** No host check - anyone can toggle this (see class comment). */
|
||||||
|
setPaused(party, isPaused) {
|
||||||
|
const current = party.music.current;
|
||||||
|
if (!current || current.isPaused === isPaused) return;
|
||||||
|
if (isPaused) {
|
||||||
|
current.isPaused = true;
|
||||||
|
current.pausedElapsedMs = Date.now() - current.startedUtc;
|
||||||
|
this._clearTimer(party);
|
||||||
|
} else {
|
||||||
|
current.isPaused = false;
|
||||||
|
current.startedUtc = Date.now() - current.pausedElapsedMs;
|
||||||
|
current.pausedElapsedMs = null;
|
||||||
|
this._scheduleAdvance(party);
|
||||||
|
}
|
||||||
|
party.lastActivityUtc = Date.now();
|
||||||
|
partyLog(`musicPlaystate partyId=${party.id} -> ${isPaused ? 'paused' : 'playing'}`);
|
||||||
|
this._broadcast(party);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers `userId`'s vote to skip the current track - a strict
|
||||||
|
* majority of `connectedMemberCount` (computed by the caller, which has
|
||||||
|
* the member list - see socketHandler.js) needed to actually skip, so a
|
||||||
|
* party of one skips on its own first vote. Broadcasts either way, so
|
||||||
|
* everyone sees the live tally even before it passes.
|
||||||
|
*/
|
||||||
|
voteSkip(party, userId, userName, connectedMemberCount) {
|
||||||
|
if (!party.music.current) return false;
|
||||||
|
party.music.skipVotes.add(userId);
|
||||||
|
const needed = Math.floor(connectedMemberCount / 2) + 1;
|
||||||
|
const votes = party.music.skipVotes.size;
|
||||||
|
partyLog(`musicVoteSkip partyId=${party.id} user=${who(userId, userName)} votes=${votes}/${connectedMemberCount} (needs ${needed})`);
|
||||||
|
const passed = votes >= needed;
|
||||||
|
if (passed) {
|
||||||
|
this._playNext(party);
|
||||||
|
}
|
||||||
|
this._broadcast(party);
|
||||||
|
return passed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when a member leaves (socketHandler.js/partyManager.js) so a
|
||||||
|
* stale vote from someone no longer around can't help a future skip
|
||||||
|
* clear a bar computed against a smaller, more-accurate connected count.
|
||||||
|
*/
|
||||||
|
removeVote(party, userId) {
|
||||||
|
if (party.music) party.music.skipVotes.delete(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Live position, in seconds - computed on demand from wall-clock time,
|
||||||
|
* never ticked/stored, so there's nothing to drift or leak. */
|
||||||
|
getElapsedSeconds(party) {
|
||||||
|
const current = party.music.current;
|
||||||
|
if (!current) return 0;
|
||||||
|
const elapsedMs = current.isPaused ? current.pausedElapsedMs : (Date.now() - current.startedUtc);
|
||||||
|
return Math.max(0, Math.floor(elapsedMs / 1000));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Public snapshot for toPublicState()/the "queueUpdated" broadcast. */
|
||||||
|
getState(party) {
|
||||||
|
const current = party.music.current;
|
||||||
|
return {
|
||||||
|
queue: party.music.queue.map(publicTrack),
|
||||||
|
current: current ? { ...publicTrack(current.track), isPaused: current.isPaused, elapsedSeconds: this.getElapsedSeconds(party) } : null,
|
||||||
|
skipVotes: party.music.skipVotes.size
|
||||||
|
// Deliberately no "votes needed" here - the client already gets the
|
||||||
|
// member list (with each one's connected flag) from the existing
|
||||||
|
// "members" broadcast and can compute the same
|
||||||
|
// Math.floor(connected/2)+1 this class uses, without this payload
|
||||||
|
// needing to duplicate that count on every single queue change.
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called from PartyManager.leave()'s destroy path. This is the actual
|
||||||
|
* leak fix: an uncleared advanceTimer would fire against a party that no
|
||||||
|
* longer exists in PartyManager's map, forever holding a reference to
|
||||||
|
* it (and everything it closes over) - the exact class of bug the
|
||||||
|
* Socket.IO/router-cleanup work earlier in this project was about
|
||||||
|
* catching (see docs/architecture.md).
|
||||||
|
*/
|
||||||
|
destroy(party) {
|
||||||
|
if (party.music) this._clearTimer(party);
|
||||||
|
}
|
||||||
|
|
||||||
|
_playNext(party) {
|
||||||
|
this._clearTimer(party);
|
||||||
|
party.music.skipVotes.clear();
|
||||||
|
const next = party.music.queue.shift();
|
||||||
|
if (!next) {
|
||||||
|
party.music.current = null;
|
||||||
|
partyLog(`musicQueueEmpty partyId=${party.id}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
party.music.current = { track: next, startedUtc: Date.now(), isPaused: false, pausedElapsedMs: null };
|
||||||
|
// Keeps the lobby/home-page party card's preview (name + image)
|
||||||
|
// current even though this is a queue-driven change, not a host
|
||||||
|
// action - see PartyManager.toPublicState, which reads these same
|
||||||
|
// top-level fields for both video and music parties alike.
|
||||||
|
party.itemName = next.itemName;
|
||||||
|
party.itemImageUrl = next.itemImageUrl;
|
||||||
|
partyLog(`musicNowPlaying partyId=${party.id} track="${next.itemName}"`);
|
||||||
|
this._scheduleAdvance(party);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server-authoritative auto-advance: schedules the move to the next
|
||||||
|
* track for exactly when this one's known duration runs out, rather
|
||||||
|
* than waiting for any client to report "it ended" (which, with no
|
||||||
|
* single host for music, could race across several clients' players all
|
||||||
|
* finishing within milliseconds of each other and all trying to
|
||||||
|
* advance). A track with no known duration just relies on a manual
|
||||||
|
* vote-skip instead - nothing scheduled, nothing to leak.
|
||||||
|
*/
|
||||||
|
_scheduleAdvance(party) {
|
||||||
|
const current = party.music.current;
|
||||||
|
if (!current || !current.track.durationSeconds) return;
|
||||||
|
const elapsedMs = Date.now() - current.startedUtc;
|
||||||
|
const remainingMs = current.track.durationSeconds * 1000 - elapsedMs;
|
||||||
|
party.music.advanceTimer = setTimeout(() => {
|
||||||
|
this._playNext(party);
|
||||||
|
this._broadcast(party);
|
||||||
|
this._onTrackChanged(party);
|
||||||
|
}, Math.max(0, remainingMs));
|
||||||
|
}
|
||||||
|
|
||||||
|
_clearTimer(party) {
|
||||||
|
if (party.music && party.music.advanceTimer) {
|
||||||
|
clearTimeout(party.music.advanceTimer);
|
||||||
|
party.music.advanceTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_broadcast(party) {
|
||||||
|
if (this._io) this._io.to(party.id).emit('queueUpdated', this.getState(party));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function publicTrack(t) {
|
||||||
|
return {
|
||||||
|
itemId: t.itemId,
|
||||||
|
itemName: t.itemName,
|
||||||
|
artistName: t.artistName,
|
||||||
|
albumName: t.albumName,
|
||||||
|
itemImageUrl: t.itemImageUrl,
|
||||||
|
durationSeconds: t.durationSeconds,
|
||||||
|
addedByUserId: t.addedByUserId,
|
||||||
|
addedByUserName: t.addedByUserName
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { MusicQueueService: new MusicQueueService() };
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const { partyLog, who } = require('./logger');
|
const { partyLog, who } = require('./logger');
|
||||||
|
const { MusicQueueService } = require('./musicQueueService');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* In-memory party store - mirrors the Party/PartyMember/PartyManager design
|
* In-memory party store - mirrors the Party/PartyMember/PartyManager design
|
||||||
|
|
@ -27,11 +28,16 @@ class PartyManager {
|
||||||
* one item; for "start watching this series from here", index.js builds
|
* one item; for "start watching this series from here", index.js builds
|
||||||
* the rest of the season/series into it. `itemId`/`itemName`/`itemImageUrl`
|
* the rest of the season/series into it. `itemId`/`itemName`/`itemImageUrl`
|
||||||
* always mirror queue[queueIndex] - kept as top-level fields too since
|
* always mirror queue[queueIndex] - kept as top-level fields too since
|
||||||
* that's what most of the UI reads.
|
* that's what most of the UI reads. Unused for `mediaType: 'music'`
|
||||||
|
* parties (still populated with a one-item fallback so those same
|
||||||
|
* top-level fields have *something* sane in them for the lobby/home-page
|
||||||
|
* card) - a music party's real, mutable queue is `party.music`, owned by
|
||||||
|
* musicQueueService.js, not this fixed pre-built one.
|
||||||
*/
|
*/
|
||||||
create(hostUserId, hostUserName, itemId, itemName, itemImageUrl, queue) {
|
create(hostUserId, hostUserName, itemId, itemName, itemImageUrl, queue, mediaType) {
|
||||||
const party = {
|
const party = {
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
|
mediaType: mediaType || 'video',
|
||||||
itemId,
|
itemId,
|
||||||
itemName,
|
itemName,
|
||||||
itemImageUrl,
|
itemImageUrl,
|
||||||
|
|
@ -46,7 +52,7 @@ class PartyManager {
|
||||||
|
|
||||||
party.members.set(hostUserId, this._newMember(hostUserId, hostUserName));
|
party.members.set(hostUserId, this._newMember(hostUserId, hostUserName));
|
||||||
this.partiesById.set(party.id, party);
|
this.partiesById.set(party.id, party);
|
||||||
partyLog(`created partyId=${party.id} host=${who(hostUserId, hostUserName)} item="${itemName}" queueLength=${party.queue.length}`);
|
partyLog(`created partyId=${party.id} mediaType=${party.mediaType} host=${who(hostUserId, hostUserName)} item="${itemName}" queueLength=${party.queue.length}`);
|
||||||
return party;
|
return party;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -74,6 +80,7 @@ class PartyManager {
|
||||||
const wasHost = party.hostUserId === userId;
|
const wasHost = party.hostUserId === userId;
|
||||||
party.members.delete(userId);
|
party.members.delete(userId);
|
||||||
party.lastActivityUtc = Date.now();
|
party.lastActivityUtc = Date.now();
|
||||||
|
MusicQueueService.removeVote(party, userId); // no-op for a video party (party.music is unset)
|
||||||
|
|
||||||
let newHostUserId = party.hostUserId;
|
let newHostUserId = party.hostUserId;
|
||||||
if (wasHost) {
|
if (wasHost) {
|
||||||
|
|
@ -86,6 +93,7 @@ class PartyManager {
|
||||||
const whoLeft = leavingMember ? who(userId, leavingMember.userName) : userId;
|
const whoLeft = leavingMember ? who(userId, leavingMember.userName) : userId;
|
||||||
if (destroyed) {
|
if (destroyed) {
|
||||||
this.partiesById.delete(party.id);
|
this.partiesById.delete(party.id);
|
||||||
|
MusicQueueService.destroy(party); // clears the auto-advance timer, if any - see its own comment
|
||||||
partyLog(`destroyed partyId=${party.id} (last member ${whoLeft} left)`);
|
partyLog(`destroyed partyId=${party.id} (last member ${whoLeft} left)`);
|
||||||
} else if (wasHost && newHostUserId != null) {
|
} else if (wasHost && newHostUserId != null) {
|
||||||
const newHost = party.members.get(newHostUserId);
|
const newHost = party.members.get(newHostUserId);
|
||||||
|
|
@ -131,6 +139,7 @@ class PartyManager {
|
||||||
for (const party of this.partiesById.values()) {
|
for (const party of this.partiesById.values()) {
|
||||||
if (party.lastActivityUtc < cutoff) {
|
if (party.lastActivityUtc < cutoff) {
|
||||||
this.partiesById.delete(party.id);
|
this.partiesById.delete(party.id);
|
||||||
|
MusicQueueService.destroy(party); // same leak this class's own destroy path guards against
|
||||||
const idleMinutes = Math.round((Date.now() - party.lastActivityUtc) / 60000);
|
const idleMinutes = Math.round((Date.now() - party.lastActivityUtc) / 60000);
|
||||||
partyLog(`destroyed partyId=${party.id} (idle ${idleMinutes}min, ${party.members.size} member(s) still connected)`);
|
partyLog(`destroyed partyId=${party.id} (idle ${idleMinutes}min, ${party.members.size} member(s) still connected)`);
|
||||||
}
|
}
|
||||||
|
|
@ -147,10 +156,13 @@ class PartyManager {
|
||||||
const host = party.members.get(party.hostUserId);
|
const host = party.members.get(party.hostUserId);
|
||||||
return {
|
return {
|
||||||
partyId: party.id,
|
partyId: party.id,
|
||||||
|
mediaType: party.mediaType,
|
||||||
itemId: party.itemId,
|
itemId: party.itemId,
|
||||||
itemName: party.itemName,
|
itemName: party.itemName,
|
||||||
itemImageUrl: party.itemImageUrl,
|
itemImageUrl: party.itemImageUrl,
|
||||||
createdUtc: party.createdUtc,
|
createdUtc: party.createdUtc,
|
||||||
|
// hasNext/queueLength/queueIndex describe the fixed video episode
|
||||||
|
// queue - meaningless for a music party (see `music` below instead).
|
||||||
hasNext: party.queueIndex < party.queue.length - 1,
|
hasNext: party.queueIndex < party.queue.length - 1,
|
||||||
queueLength: party.queue.length,
|
queueLength: party.queue.length,
|
||||||
queueIndex: party.queueIndex,
|
queueIndex: party.queueIndex,
|
||||||
|
|
@ -164,7 +176,8 @@ class PartyManager {
|
||||||
isPaused: m.isPaused,
|
isPaused: m.isPaused,
|
||||||
positionTicks: m.positionTicks,
|
positionTicks: m.positionTicks,
|
||||||
connected: !!m.connection
|
connected: !!m.connection
|
||||||
}))
|
})),
|
||||||
|
music: party.mediaType === 'music' ? MusicQueueService.getState(party) : null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const { PartyManager } = require('./partyManager');
|
const { PartyManager } = require('./partyManager');
|
||||||
|
const { MusicQueueService } = require('./musicQueueService');
|
||||||
const { partyLog, who } = require('./logger');
|
const { partyLog, who } = require('./logger');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -16,12 +17,29 @@ const { partyLog, who } = require('./logger');
|
||||||
* and again whenever any party changes
|
* and again whenever any party changes
|
||||||
* "join" {partyId} joins that party's own room
|
* "join" {partyId} joins that party's own room
|
||||||
* "members" (server -> client) [...] that party's member list
|
* "members" (server -> client) [...] that party's member list
|
||||||
|
*
|
||||||
|
* -- mediaType: 'video' parties --
|
||||||
* "playstate" {isPaused, positionTicks} (host only - ignored otherwise)
|
* "playstate" {isPaused, positionTicks} (host only - ignored otherwise)
|
||||||
* "playstate" (server -> client, to everyone in the party but the sender)
|
* "playstate" (server -> client, to everyone in the party but the sender)
|
||||||
* "next" (host only - advance the queue)
|
* "next" (host only - advance the queue)
|
||||||
* "itemChanged" (server -> client) {itemId, itemName, itemImageUrl, queueIndex, queueLength, hasNext}
|
* "itemChanged" (server -> client) {itemId, itemName, itemImageUrl, queueIndex, queueLength, hasNext}
|
||||||
|
*
|
||||||
|
* -- mediaType: 'music' parties (see musicQueueService.js for the model) --
|
||||||
|
* "voteSkip" (anyone - see musicQueueService.js for the threshold)
|
||||||
|
* "musicPlaystate" {isPaused} (anyone - no host check, unlike video's "playstate")
|
||||||
|
* "queueUpdated" (server -> client) - see MusicQueueService.getState() for the shape
|
||||||
|
* (adding a track is REST, not a socket event - POST
|
||||||
|
* /api/parties/:partyId/tracks in index.js - since it needs to work
|
||||||
|
* from the album-browsing page too, which has no live connection to
|
||||||
|
* the party it might be adding into; MusicQueueService's own
|
||||||
|
* "queueUpdated" broadcast fires regardless of which path called it)
|
||||||
|
*
|
||||||
|
* -- both --
|
||||||
* "hostChanged" (server -> client, to the new host only, no payload)
|
* "hostChanged" (server -> client, to the new host only, no payload)
|
||||||
* "requestPause" (throttled per member - see below)
|
* "requestPause" (throttled per member - see below;
|
||||||
|
* video only - a music party has no
|
||||||
|
* host to ask, everyone already has
|
||||||
|
* direct pause/vote-skip access)
|
||||||
* "pauseRequested" (server -> client, everyone in the party) {userId, userName}
|
* "pauseRequested" (server -> client, everyone in the party) {userId, userName}
|
||||||
* "leave"
|
* "leave"
|
||||||
* "errorMessage" (server -> client) {message}
|
* "errorMessage" (server -> client) {message}
|
||||||
|
|
@ -101,14 +119,32 @@ function attachSocketServer(io, { parseCookies, getSession }) {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Majority of currently CONNECTED members, not everyone who's ever
|
||||||
|
// joined - see musicQueueService.js.
|
||||||
|
socket.on('voteSkip', () => {
|
||||||
|
const member = party && party.mediaType === 'music' && party.members.get(userId);
|
||||||
|
if (!member) return;
|
||||||
|
const connectedCount = Array.from(party.members.values()).filter((m) => m.connection).length;
|
||||||
|
MusicQueueService.voteSkip(party, userId, member.userName, connectedCount);
|
||||||
|
broadcastLobby(io);
|
||||||
|
});
|
||||||
|
|
||||||
|
// No host check, unlike video's "playstate" - see musicQueueService.js.
|
||||||
|
socket.on('musicPlaystate', (msg) => {
|
||||||
|
if (!party || party.mediaType !== 'music' || !party.members.get(userId)) return;
|
||||||
|
MusicQueueService.setPaused(party, !!(msg && msg.isPaused));
|
||||||
|
});
|
||||||
|
|
||||||
// Anyone (not just followers - harmless either way) can ask the host to
|
// Anyone (not just followers - harmless either way) can ask the host to
|
||||||
// pause; the presence overlay (client/js/views/player.js) is what
|
// pause; the presence overlay (client/js/views/player.js) is what
|
||||||
// triggers this. Broadcast to the whole party, not just the host, so
|
// triggers this. Broadcast to the whole party, not just the host, so
|
||||||
// everyone sees why playback is about to pause. Throttled per member
|
// everyone sees why playback is about to pause. Throttled per member
|
||||||
// server-side (not just the client's own cooldown, which a stray extra
|
// server-side (not just the client's own cooldown, which a stray extra
|
||||||
// click - or anyone poking the socket directly - would bypass).
|
// click - or anyone poking the socket directly - would bypass). Video
|
||||||
|
// only: a music party has no host to ask - everyone already has direct
|
||||||
|
// pause access via "musicPlaystate".
|
||||||
socket.on('requestPause', () => {
|
socket.on('requestPause', () => {
|
||||||
if (!party) return;
|
if (!party || party.mediaType === 'music') return;
|
||||||
const member = party.members.get(userId);
|
const member = party.members.get(userId);
|
||||||
if (!member) return;
|
if (!member) return;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
@ -173,6 +209,7 @@ function lobbySnapshot() {
|
||||||
const host = party.members.get(party.hostUserId);
|
const host = party.members.get(party.hostUserId);
|
||||||
return {
|
return {
|
||||||
partyId: party.id,
|
partyId: party.id,
|
||||||
|
mediaType: party.mediaType,
|
||||||
itemId: party.itemId,
|
itemId: party.itemId,
|
||||||
itemName: party.itemName,
|
itemName: party.itemName,
|
||||||
itemImageUrl: party.itemImageUrl,
|
itemImageUrl: party.itemImageUrl,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue