watchparty/server/src/services/MusicQueueService.ts
Nicolas fafee359b9
Some checks failed
Publier l'image Docker / test (pull_request) Failing after 4s
Publier l'image Docker / publish (pull_request) Has been skipped
Sticky catalog search bar, and let anyone seek within a music track
Two small, unrelated requests handled together.

The search bar (tiles.ts's shared .searchBar, used by views/library/
artist/album) scrolled away with the page - asked to stay reachable
while scrolling a long grid. position: sticky; top: 0 on the input
itself; base.css's own generic input rule already gives it an opaque
background, which is what keeps tiles scrolling underneath from
showing through once it's stuck - nothing else needed changing.

Seeking within the current music track was explicit, deliberate scope
this session's own "participative playlist" work had cut - the
progress bar was marked read-only specifically because MusicQueueService
had no seek concept, just play/pause/advance. The permission model was
already decided by that class's own documented reasoning for why
*anyone* can pause/resume (reversible, doesn't change what's playing) -
a seek is exactly that same shape, continuous instead of binary, so it
gets the same treatment, not voteSkip's majority gate.

MusicQueueService.seek mirrors setPaused's own elapsed-time math
(clamped into [0, durationSeconds]), with one real difference: setPaused's
resume branch gets away with calling scheduleAdvance directly only
because pausing already cleared the timer first - scheduleAdvance
itself never clears a prior one. Seeking while still playing has no
such guarantee, so skipping that clear first would leave the OLD timer
(counting down against the old position) running alongside the new
one, eventually firing playNext at the wrong moment. New musicSeek
socket event, no host check (same as musicPlaystate). Client-side,
MusicBar's progress track gained the same pointerdown/move/up
drag-to-seek handlers the video player's seek bar already has - no host
branch to port this time, since a listen party has none - plus a
local, optimistic activeDriver.currentTime write before broadcasting,
for the same instant-feedback reason the video player's own
seekFromPointerEvent does that.

Verified: full typecheck/test/build on both projects (128/128 server
tests - new coverage for seek's clamping, its paused-vs-playing
branches, and a real-timer test proving the old advance timer is
cleared rather than left running alongside a re-armed one - the exact
bug the naive "just call scheduleAdvance again" version would have
shipped). No server needed for the sticky search bar - checked by
reading .mainContent's own layout directly (no nested scroll container
between it and the document, confirming position: sticky resolves
against the page scroll as intended).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 23:27:23 +02:00

276 lines
12 KiB
TypeScript

'use strict';
import { Logger } from '../Logger';
import { PartyStreamService } from './PartyStreamService';
import { PlaystateService } from './PlaystateService';
import type { AppServer, MusicCurrentPublic, MusicPublicState, MusicTrack, MusicTrackInput, Party } from '../types';
/**
* Owns the "participative playlist" behavior for a music party - kept out
* of PartyService 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.
*
* Singleton (exported as an instance below) - the state itself lives on
* each `party.music` object; this class is just the behavior around it.
*/
class MusicQueueServiceClass {
private io: AppServer | null = null;
private onTrackChanged: (party: Party) => void = () => {};
/**
* Called once at server startup (index.ts), 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.ts wires it to broadcastLobby(io). Passed in rather
* than imported directly from SocketGateway to avoid a circular import
* (SocketGateway already imports this module).
*/
attachIo(io: AppServer, onTrackChanged?: (party: Party) => void): void {
this.io = io;
this.onTrackChanged = onTrackChanged || (() => {});
}
/** Called once, when a party is created from a music track (index.ts). */
init(party: Party, firstTrack: MusicTrackInput | null, addedByUserId: string, addedByUserName: string): void {
party.music = { queue: [], current: null, skipVotes: new Set(), advanceTimer: null };
if (firstTrack) {
this.addTrack(party, firstTrack, addedByUserId, addedByUserName);
}
}
addTrack(party: Party, track: MusicTrackInput, addedByUserId: string, addedByUserName: string): void {
const music = party.music!;
const entry: MusicTrack = {
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
};
music.queue.push(entry);
party.lastActivityUtc = Date.now();
Logger.party(`musicQueueAdd partyId=${party.id} user=${Logger.who(addedByUserId, addedByUserName)} track="${entry.itemName}" queueLength=${music.queue.length}`);
if (!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: Party, isPaused: boolean): void {
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 ?? 0);
current.pausedElapsedMs = null;
this.scheduleAdvance(party);
}
party.lastActivityUtc = Date.now();
Logger.party(`musicPlaystate partyId=${party.id} -> ${isPaused ? 'paused' : 'playing'}`);
this.broadcast(party);
}
/**
* No host check - anyone can do this (see class comment): a seek is
* just a continuous version of pause/resume, reversible and not a
* change to what's playing - not the majority-vote model reserved for
* actually skipping to different content. Clamped into
* [0, durationSeconds] when the duration is known (an unknown one -
* never actually seen in practice, see MusicTrack - just trusts the
* caller's own non-negative value, same as the video player's seek
* has no server-side duration to clamp against either).
*/
seek(party: Party, positionSeconds: number): void {
const current = party.music?.current;
if (!current) return;
const duration = current.track.durationSeconds;
const clampedSeconds = Math.max(0, duration != null ? Math.min(positionSeconds, duration) : positionSeconds);
const elapsedMs = clampedSeconds * 1000;
if (current.isPaused) {
current.pausedElapsedMs = elapsedMs;
} else {
// Must clear the existing timer first, not just overwrite it -
// scheduleAdvance only ever sets advanceTimer, it never clears a
// prior one itself (setPaused's own resume branch gets away with
// skipping this only because pausing already cleared it - see its
// own "if (isPaused)" branch). Skipping this here would leave the
// OLD, now-wrong-duration timer running alongside the new one,
// eventually firing playNext at the wrong time.
this.clearTimer(party);
current.startedUtc = Date.now() - elapsedMs;
this.scheduleAdvance(party);
}
party.lastActivityUtc = Date.now();
Logger.party(`musicSeek partyId=${party.id} -> ${clampedSeconds}s`);
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 SocketGateway) 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: Party, userId: string, userName: string, connectedMemberCount: number): boolean {
const music = party.music;
if (!music || !music.current) return false;
music.skipVotes.add(userId);
const needed = Math.floor(connectedMemberCount / 2) + 1;
const votes = music.skipVotes.size;
Logger.party(`musicVoteSkip partyId=${party.id} user=${Logger.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 (SocketGateway/PartyService) 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: Party, userId: string): void {
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: Party): number {
const current = party.music?.current;
if (!current) return 0;
const elapsedMs = current.isPaused ? (current.pausedElapsedMs ?? 0) : (Date.now() - current.startedUtc);
return Math.max(0, Math.floor(elapsedMs / 1000));
}
/** Public snapshot for PartyService.toPublicState()/the "queueUpdated" broadcast. */
getState(party: Party): MusicPublicState {
const music = party.music;
if (!music) return { queue: [], current: null, skipVotes: 0 };
const current: MusicCurrentPublic | null = music.current
? { ...music.current.track, isPaused: music.current.isPaused, elapsedSeconds: this.getElapsedSeconds(party) }
: null;
return {
queue: music.queue,
current,
skipVotes: 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 PartyService.leave()'s destroy path. This is the actual
* leak fix: an uncleared advanceTimer would fire against a party that no
* longer exists in PartyService'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: Party): void {
if (party.music) this.clearTimer(party);
}
private playNext(party: Party): void {
const music = party.music!;
const outgoing = music.current; // the track finishing/being skipped, if any - see the markPlayed loop below
this.clearTimer(party);
music.skipVotes.clear();
// Whatever was previously negotiated (see PartyStreamService) was for
// the outgoing track - never valid for whatever plays next, whether
// that's a new track or nothing at all.
PartyStreamService.endSession(party.id);
// The queue moving past `outgoing` - naturally finishing, or a
// majority vote-skip, either way - is this app's own signal that
// it's "done" with that track, for every member connected right now.
// Fire-and-forget, same reasoning as PartyService.advanceQueue's own
// identical loop for video - see PlaystateService.markPlayed. `if
// (outgoing)` skips this on the very first call for a party (the
// queue was empty, nothing outgoing to mark).
if (outgoing) {
for (const member of party.members.values()) {
if (!member.connection || !member.embyToken) continue;
PlaystateService.markPlayed(member.embyToken, member.userId, outgoing.track.itemId);
}
}
const next = music.queue.shift();
if (!next) {
music.current = null;
Logger.party(`musicQueueEmpty partyId=${party.id}`);
return;
}
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 PartyService.toPublicState, which reads these same
// top-level fields for both video and music parties alike.
party.itemName = next.itemName;
party.itemImageUrl = next.itemImageUrl;
Logger.party(`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.
*/
private scheduleAdvance(party: Party): void {
const music = party.music!;
const current = music.current;
if (!current || !current.track.durationSeconds) return;
const elapsedMs = Date.now() - current.startedUtc;
const remainingMs = current.track.durationSeconds * 1000 - elapsedMs;
music.advanceTimer = setTimeout(() => {
this.playNext(party);
this.broadcast(party);
this.onTrackChanged(party);
}, Math.max(0, remainingMs));
}
private clearTimer(party: Party): void {
if (party.music?.advanceTimer) {
clearTimeout(party.music.advanceTimer);
party.music.advanceTimer = null;
}
}
private broadcast(party: Party): void {
this.io?.to(party.id).emit('queueUpdated', this.getState(party));
}
}
export const MusicQueueService = new MusicQueueServiceClass();