Sync text subtitle choice too, and fix an empty audio option
All checks were successful
Publier l'image Docker / test (push) Successful in 23s
Publier l'image Docker / publish (push) Successful in 14s

Bug report: watch party members still didn't have synced subtitle
settings, and the audio/language selector showed an empty choice on
first load.

Text subtitles were the gap left by the previous "one shared stream
per party" change: that made audio/quality/burn-in host-only and
party-wide, but a plain text subtitle pick (the common case - burn-in
only applies to bitmap PGS/VobSub/DVB tracks) was still each viewer's
own local <track> choice. Once people actually watched together,
"everyone but me changed the subtitle" read as broken sync.

Fixed with a parallel mechanism, not a merge into burnInSubtitleIndex -
new Party.textSubtitleIndex, new setTextSubtitle/textSubtitleChanged
socket pair, mirroring setBurnInSubtitle/burnInChanged's host-only,
request/broadcast/react shape exactly. Kept separate from burn-in
(rather than reusing one field for both) to avoid touching the
already-proven burn-in path - only one subtitle can show at once, so
the two are mutually exclusive, enforced server-side in both
directions: a real pick on either clears the other. Clearing one to
null/"Aucun" is deliberately not treated as a pick for this purpose,
so it doesn't clobber an unrelated choice in the other field.

Mechanically the two still work nothing alike: burn-in changes the
actual encoded stream (ffmpeg), so it reloads for everyone; a text
pick only swaps which native <track> each member already renders
locally - no reload, no transcode, unless it's the one clearing an
active burn-in, which does still need the reload since the stream
itself changes back to the normal shared one.

Player.ts's subtitle <select> gating stopped being conditional on
burn-in - it's simply host-gated now, matching audio/quality, and its
value restores from whichever kind of pick (if any) the party
currently has.

Second, unrelated bug in the same report: audioSelect.value =
streamAudioIndex || '' assigned an empty string whenever no audio
track had been picked yet (the common default) - since a real
audio-track <option> never has value="", this matched nothing and
left the select at selectedIndex=-1, a genuinely empty-looking choice.
Fixed at both call sites (loadTrackOptions, onStreamOptionsChanged) by
only assigning .value when truthy.

Server: SocketGateway tests cover setTextSubtitle's host-only gating,
broadcast, music no-op, and mutual exclusivity in both directions;
PartyService tests cover textSubtitleIndex through create/
advanceQueue/toPublicState. Full typecheck/test/build green on both
projects (91/91 server tests). Real-browser check (mocked-fetch + real
bundle) confirmed: a follower's subtitle select renders disabled
regardless of burn-in state; a host's select restores the party's
current text or burn-in pick on load; and the audio select now lands
on a real first option instead of an empty one.

Not addressed here (raised in the same report, need more diagnostic
detail first): occasional sync losses between clients, and a
suspicion that per-user watch-status (Emby PlaystateService sync)
might be broken.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Nicolas 2026-09-14 20:41:02 +02:00
parent f78b2f1408
commit 4efcd67fb3
11 changed files with 433 additions and 79 deletions

View file

@ -36,12 +36,15 @@ including why this replaced an earlier Emby-plugin attempt (still in
initials, on the player itself). If you're not the host, a small initials, on the player itself). If you're not the host, a small
toolbar lets you ask the host to pause or resume — everyone sees the toolbar lets you ask the host to pause or resume — everyone sees the
request, including the host, without needing chat. request, including the host, without needing chat.
6. Subtitles render natively in the browser for any text-based track. A 6. Subtitles are a host-only, party-wide pick too, like the
Blu-ray/DVD-style image subtitle (PGS/VobSub/DVB) is different — the language/quality pick above — the host picks one (or "Aucun") and
host can pick one to have it burned into the video by this app's own everyone's player follows. Any text-based track just renders
ffmpeg, shared live by everyone in the party (picking one restarts natively in each viewer's own browser, in sync (no reload, no extra
the stream for server load — only *which* one is synced). A Blu-ray/DVD-style image
everyone, so it's a host-only choice, like play/pause already is). subtitle (PGS/VobSub/DVB) is different — picking one has this app's
own ffmpeg burn it into the video, shared live by everyone in the
party, which does restart the stream for everyone since the video
itself changes.
7. Music works differently, on purpose: pick a track (browsing Artist → 7. Music works differently, on purpose: pick a track (browsing Artist →
Album → Track) to start a listening party. There's no host for 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, playback — anyone can add more tracks to the shared queue at any time,
@ -229,9 +232,10 @@ Then put it behind your existing reverse proxy like everything else
- [x] Design + scaffold the standalone app - [x] Design + scaffold the standalone app
- [x] Proxy all Emby access server-side; Emby-themed UI (sidebar, carousels) - [x] Proxy all Emby access server-side; Emby-themed UI (sidebar, carousels)
- [x] TV show navigation (Series → Seasons → Episodes) + episode auto-queue - [x] TV show navigation (Series → Seasons → Episodes) + episode auto-queue
- [x] Player language/subtitle/quality pickers (language/quality are now - [x] Player language/subtitle/quality pickers (all three are now
host-only, party-wide choices — see docs/architecture.md's "One host-only, party-wide choices — see docs/architecture.md's "One
shared Emby stream per party") shared Emby stream per party" and "Text subtitles join burn-in as
a party-wide, host-only pick")
- [x] Socket.IO instead of raw WebSockets; fixed two real memory leaks - [x] Socket.IO instead of raw WebSockets; fixed two real memory leaks
(dead-connection cleanup server-side, view teardown on navigation (dead-connection cleanup server-side, view teardown on navigation
client-side) — see docs/architecture.md client-side) — see docs/architecture.md
@ -263,6 +267,10 @@ Then put it behind your existing reverse proxy like everything else
member's own account — see docs/architecture.md member's own account — see docs/architecture.md
- [x] One shared Emby stream per party instead of one per viewer — see - [x] One shared Emby stream per party instead of one per viewer — see
docs/architecture.md docs/architecture.md
- [x] Text subtitle choice joins burn-in as a host-only, party-wide pick
(was still each viewer's own local choice until now); fixed the
audio picker briefly showing an empty option on first load — 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)
- [ ] Tune transcoding target / support direct play when possible - [ ] Tune transcoding target / support direct play when possible

View file

@ -8,7 +8,7 @@
// No join codes: joining happens straight from the home page's live list // No join codes: joining happens straight from the home page's live list
// (connectLobby below) by party id - see docs/architecture.md. // (connectLobby below) by party id - see docs/architecture.md.
import { getDeviceId } from '../deviceId'; import { getDeviceId } from '../deviceId';
import type { BurnInChangedMsg, ItemChangedMsg, LobbyPartyDto, MusicPublicState, MusicTrackInput, PartyPublicState, PauseRequestedMsg, ResumeRequestedMsg, SocketMember, StreamOptionsChangedMsg } from '../types'; import type { BurnInChangedMsg, ItemChangedMsg, LobbyPartyDto, MusicPublicState, MusicTrackInput, PartyPublicState, PauseRequestedMsg, ResumeRequestedMsg, SocketMember, StreamOptionsChangedMsg, TextSubtitleChangedMsg } from '../types';
async function apiFetch<T>(path: string, options: RequestInit = {}): Promise<T> { async function apiFetch<T>(path: string, options: RequestInit = {}): Promise<T> {
const res = await fetch(path, { const res = await fetch(path, {
@ -88,6 +88,9 @@ export interface PartyConnectionCallbacks {
/** The host picked (or cleared) the party's shared burn-in stream - see /** The host picked (or cleared) the party's shared burn-in stream - see
* setBurnInSubtitle below and Player.ts. */ * setBurnInSubtitle below and Player.ts. */
onBurnInChanged?: (msg: BurnInChangedMsg) => void; onBurnInChanged?: (msg: BurnInChangedMsg) => void;
/** The host picked (or cleared) a *text* subtitle for the whole party -
* see setTextSubtitle below and Player.ts. */
onTextSubtitleChanged?: (msg: TextSubtitleChangedMsg) => void;
/** The host changed the party's shared stream's audio track/quality - /** The host changed the party's shared stream's audio track/quality -
* see setStreamOptions below and Player.ts. */ * see setStreamOptions below and Player.ts. */
onStreamOptionsChanged?: (msg: StreamOptionsChangedMsg) => void; onStreamOptionsChanged?: (msg: StreamOptionsChangedMsg) => void;
@ -117,9 +120,13 @@ export interface PartyConnection {
/** Host-only: picks the party's shared self-hosted-ffmpeg burn-in /** Host-only: picks the party's shared self-hosted-ffmpeg burn-in
* stream for a PGS/image subtitle (null clears it) - see * stream for a PGS/image subtitle (null clears it) - see
* TranscodeService and Player.ts's subtitle <select>. Not for a text * TranscodeService and Player.ts's subtitle <select>. Not for a text
* subtitle - that stays the existing per-viewer client-side <track>, * subtitle - see setTextSubtitle below for those. */
* no socket call at all. */
setBurnInSubtitle(subtitleIndex: string | null, audioIndex?: string): void; setBurnInSubtitle(subtitleIndex: string | null, audioIndex?: string): void;
/** Host-only: picks (or clears) a *text* subtitle for the whole party -
* every member still renders it themselves as a native <track>, this
* only syncs *which* one. Mutually exclusive with setBurnInSubtitle -
* the server clears one when the other is set for real. */
setTextSubtitle(subtitleIndex: string | null): void;
/** Host-only, video-only: picks the audio track/quality of the party's /** Host-only, video-only: picks the audio track/quality of the party's
* shared normal stream (PartyStreamService) - there's only ever one * shared normal stream (PartyStreamService) - there's only ever one
* stream now, so this is necessarily party-wide, not each viewer's * stream now, so this is necessarily party-wide, not each viewer's
@ -136,7 +143,7 @@ export interface PartyConnection {
* alive via closures, never gets released. * alive via closures, never gets released.
*/ */
export function connectParty(partyId: string, callbacks: PartyConnectionCallbacks = {}): PartyConnection { export function connectParty(partyId: string, callbacks: PartyConnectionCallbacks = {}): PartyConnection {
const { onMembers, onPlaystate, onItemChanged, onHostChanged, onPauseRequested, onResumeRequested, onQueueUpdated, onDeviceConflict, onBurnInChanged, onStreamOptionsChanged, onError, onClose } = callbacks; const { onMembers, onPlaystate, onItemChanged, onHostChanged, onPauseRequested, onResumeRequested, onQueueUpdated, onDeviceConflict, onBurnInChanged, onTextSubtitleChanged, onStreamOptionsChanged, onError, onClose } = callbacks;
const socket = io(); const socket = io();
const deviceId = getDeviceId(); const deviceId = getDeviceId();
@ -159,6 +166,7 @@ export function connectParty(partyId: string, callbacks: PartyConnectionCallback
if (onQueueUpdated) socket.on('queueUpdated', (m) => onQueueUpdated(m as MusicPublicState)); // music parties only - see musicBar/MusicBar.ts if (onQueueUpdated) socket.on('queueUpdated', (m) => onQueueUpdated(m as MusicPublicState)); // music parties only - see musicBar/MusicBar.ts
if (onDeviceConflict) socket.on('deviceConflict', () => onDeviceConflict()); if (onDeviceConflict) socket.on('deviceConflict', () => onDeviceConflict());
if (onBurnInChanged) socket.on('burnInChanged', (m) => onBurnInChanged(m as BurnInChangedMsg)); if (onBurnInChanged) socket.on('burnInChanged', (m) => onBurnInChanged(m as BurnInChangedMsg));
if (onTextSubtitleChanged) socket.on('textSubtitleChanged', (m) => onTextSubtitleChanged(m as TextSubtitleChangedMsg));
if (onStreamOptionsChanged) socket.on('streamOptionsChanged', (m) => onStreamOptionsChanged(m as StreamOptionsChangedMsg)); if (onStreamOptionsChanged) socket.on('streamOptionsChanged', (m) => onStreamOptionsChanged(m as StreamOptionsChangedMsg));
if (onError) socket.on('errorMessage', (m) => onError((m as { message: string }).message)); if (onError) socket.on('errorMessage', (m) => onError((m as { message: string }).message));
if (onClose) socket.on('disconnect', () => onClose()); if (onClose) socket.on('disconnect', () => onClose());
@ -188,6 +196,9 @@ export function connectParty(partyId: string, callbacks: PartyConnectionCallback
setBurnInSubtitle(subtitleIndex, audioIndex) { setBurnInSubtitle(subtitleIndex, audioIndex) {
socket.emit('setBurnInSubtitle', { subtitleIndex, audioIndex }); socket.emit('setBurnInSubtitle', { subtitleIndex, audioIndex });
}, },
setTextSubtitle(subtitleIndex) {
socket.emit('setTextSubtitle', { subtitleIndex });
},
setStreamOptions(audioStreamIndex, quality) { setStreamOptions(audioStreamIndex, quality) {
socket.emit('setStreamOptions', { audioStreamIndex, quality }); socket.emit('setStreamOptions', { audioStreamIndex, quality });
}, },

View file

@ -117,6 +117,12 @@ export interface PartyPublicState {
// SocketGateway's own comment. // SocketGateway's own comment.
burnInSubtitleIndex: string | null; burnInSubtitleIndex: string | null;
burnInAudioIndex: string | null; burnInAudioIndex: string | null;
// Video only - the host's current pick for a *text* subtitle, party-
// wide now (not each viewer's own local pick any more - that turned
// out to be the wrong call once people actually watched together, see
// docs/architecture.md). Mutually exclusive with burnInSubtitleIndex -
// see SocketGateway's "setTextSubtitle" handler.
textSubtitleIndex: string | null;
// Video only - the host's current pick for the party's normal shared // Video only - the host's current pick for the party's normal shared
// stream (PartyStreamService) - null means "Emby's own default". // stream (PartyStreamService) - null means "Emby's own default".
// There's only ever one stream now, so this is party-wide, host-set // There's only ever one stream now, so this is party-wide, host-set
@ -184,6 +190,13 @@ export interface BurnInChangedMsg {
audioIndex: string | null; audioIndex: string | null;
} }
/** "textSubtitleChanged" - see api/party.ts's setTextSubtitle/
* onTextSubtitleChanged and PartyPublicState.textSubtitleIndex above
* (this is the live-push half; that's the on-load/rejoin half). */
export interface TextSubtitleChangedMsg {
subtitleIndex: string | null;
}
/** "streamOptionsChanged" - see api/party.ts's setStreamOptions/ /** "streamOptionsChanged" - see api/party.ts's setStreamOptions/
* onStreamOptionsChanged and PartyPublicState.streamAudioIndex/ * onStreamOptionsChanged and PartyPublicState.streamAudioIndex/
* streamQuality above (this is the live-push half; that's the * streamQuality above (this is the live-push half; that's the

View file

@ -7,7 +7,7 @@ import type { PartyConnection } from '../../api/party';
import { navigate } from '../../router'; import { navigate } from '../../router';
import type { CleanupFn, RouteParams } from '../../router'; import type { CleanupFn, RouteParams } from '../../router';
import { colorForName, escapeHtml, initialsFor } from '../../tiles'; import { colorForName, escapeHtml, initialsFor } from '../../tiles';
import type { BurnInChangedMsg, ItemChangedMsg, PartyPublicState, PauseRequestedMsg, PlaybackOptionsDto, ResumeRequestedMsg, SocketMember, StreamOptionsChangedMsg } from '../../types'; import type { BurnInChangedMsg, ItemChangedMsg, PartyPublicState, PauseRequestedMsg, PlaybackOptionsDto, ResumeRequestedMsg, SocketMember, StreamOptionsChangedMsg, TextSubtitleChangedMsg } from '../../types';
import { joinMusicParty, leaveMusicParty } from '../../musicBar/MusicBar'; import { joinMusicParty, leaveMusicParty } from '../../musicBar/MusicBar';
const TICKS_PER_SECOND = 10000000; // Emby/.NET convention: 1 tick = 100ns const TICKS_PER_SECOND = 10000000; // Emby/.NET convention: 1 tick = 100ns
@ -182,6 +182,14 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
// up immediately, not just from a live "burnInChanged" broadcast. // up immediately, not just from a live "burnInChanged" broadcast.
let burnInSubtitleIndex: string | null = party.burnInSubtitleIndex; let burnInSubtitleIndex: string | null = party.burnInSubtitleIndex;
let burnInAudioIndex: string | null = party.burnInAudioIndex; let burnInAudioIndex: string | null = party.burnInAudioIndex;
// The party's shared *text* subtitle choice - mutually exclusive with
// burnInSubtitleIndex above (only one subtitle can show at once; the
// server enforces this too, see SocketGateway's setBurnInSubtitle/
// setTextSubtitle handlers). Unlike burn-in, this never changes the
// stream itself - every member still renders it locally as a native
// <track> (see applySubtitleTrack) - so this is only the *sync*, not
// a rendering mechanism of its own.
let textSubtitleIndex: string | null = party.textSubtitleIndex;
// The party's shared normal stream's audio/quality choice (see // The party's shared normal stream's audio/quality choice (see
// PartyStreamService) - there's only one stream now, not one per // PartyStreamService) - there's only one stream now, not one per
// viewer, so - like burnIn* above - this is party-wide state read from // viewer, so - like burnIn* above - this is party-wide state read from
@ -271,8 +279,13 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
// match against; quality's own options are static (QUALITY_PRESETS, // match against; quality's own options are static (QUALITY_PRESETS,
// set once at the top of renderVideoPlayer) so this is really only // set once at the top of renderVideoPlayer) so this is really only
// needed here for symmetry/simplicity, not because it couldn't be // needed here for symmetry/simplicity, not because it couldn't be
// set any earlier. // set any earlier. Guarded (not a plain `|| ''` fallback) because
audioSelect.value = streamAudioIndex || ''; // an audio <option> never has value="" (only the no-tracks-at-all
// fallback branch above does) - assigning '' when streamAudioIndex
// is null would match nothing, leaving the select's selectedIndex
// at -1 and rendering as an empty-looking choice instead of falling
// back to the first real option the way leaving .value untouched does.
if (streamAudioIndex) audioSelect.value = streamAudioIndex;
qualitySelect.value = streamQuality || ''; qualitySelect.value = streamQuality || '';
renderSubtitleOptions(); renderSubtitleOptions();
applyStreamOptionsGating(); applyStreamOptionsGating();
@ -284,20 +297,25 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
/** /**
* (Re)builds the subtitle <select> from the last-fetched playback * (Re)builds the subtitle <select> from the last-fetched playback
* options, called both on a genuine item load and whenever burn-in/host * options, called both on a genuine item load and whenever burn-in/
* state changes (becomeHost, onBurnInChanged) without a fresh fetch. * text-subtitle/host state changes (becomeHost, onBurnInChanged,
* onTextSubtitleChanged) without a fresh fetch.
* *
* isTextBased: false means this is an image/bitmap subtitle (PGS/ * Every option here is host-only and party-wide now (subtitle choice
* VobSub/DVB, typically a Blu-ray/DVD rip) - a native <track> (see * used to be each viewer's own local pick, but that turned out to be
* applySubtitleTrack) needs real text, which a bitmap has none of for * the wrong call once people actually watched together - see
* Emby to convert to WebVTT. These now go through TranscodeService's * docs/architecture.md) - but the two kinds still work very
* own self-hosted ffmpeg burn-in instead - see docs/architecture.md's * differently underneath. isTextBased: false means this is an image/
* "Self-hosted ffmpeg" section - which is a party-*wide*, host-driven * bitmap subtitle (PGS/VobSub/DVB, typically a Blu-ray/DVD rip) - a
* choice (like play/pause/next already are), not a per-viewer local * native <track> (see applySubtitleTrack) needs real text, which a
* one the way a text subtitle still is. So: host-only selectable * bitmap has none of for Emby to convert to WebVTT. These go through
* TranscodeService's own self-hosted ffmpeg burn-in instead
* (`data-burn-in` flags which options these are, for the change * (`data-burn-in` flags which options these are, for the change
* handler below), and picking one restarts the stream for everyone - * handler below) - an actual re-encode of the shared stream, so
* the label says so instead of leaving it a surprise. * picking one restarts playback for everyone, the label says so
* instead of leaving it a surprise. A text option is just a sync of
* which native <track> every member renders locally - no reload, no
* transcode cost - see setTextSubtitle/onTextSubtitleChanged.
*/ */
function renderSubtitleOptions() { function renderSubtitleOptions() {
if (!lastPlaybackOptions) return; if (!lastPlaybackOptions) return;
@ -305,19 +323,18 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
subtitleSelect.innerHTML = '<option value="">Aucun</option>' + subtitleSelect.innerHTML = '<option value="">Aucun</option>' +
lastPlaybackOptions.subtitleTracks.map((t) => { lastPlaybackOptions.subtitleTracks.map((t) => {
if (t.isTextBased === false) { if (t.isTextBased === false) {
const suffix = isHost ? ' (incrustation - redémarre la vidéo pour tous)' : ' (incrustation, hôte uniquement)'; return `<option value="${t.index}" data-burn-in="1">${escapeHtml(t.label)} (incrustation - redémarre la vidéo pour tous)</option>`;
return `<option value="${t.index}" data-burn-in="1"${isHost ? '' : ' disabled'}>${escapeHtml(t.label)}${suffix}</option>`;
} }
return `<option value="${t.index}">${escapeHtml(t.label)}</option>`; return `<option value="${t.index}">${escapeHtml(t.label)}</option>`;
}).join(''); }).join('');
subtitleSelect.value = burnInSubtitleIndex || previousValue; subtitleSelect.value = burnInSubtitleIndex || textSubtitleIndex || previousValue;
// While a burn-in stream is active, a follower has nothing meaningful // Host-only and party-wide unconditionally now, same reasoning as
// left to pick here - the shared stream's audio+video bytes are // applyStreamOptionsGating below - a follower has nothing left to
// already fixed either way, and their own text-subtitle/"Aucun" // pick here at all, whether or not a burn-in stream happens to be
// choice wouldn't change anything about it. Only the host, who can // active (unlike audio/quality, this one isn't video-vs-burn-in
// also change or clear the burn-in itself, keeps this enabled. // specific - it's just "are you the host").
subtitleSelect.disabled = !!burnInSubtitleIndex && !isHost; subtitleSelect.disabled = !isHost;
burnInHint.classList.toggle('hide', !burnInSubtitleIndex); burnInHint.classList.toggle('hide', !burnInSubtitleIndex);
} }
@ -387,13 +404,15 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
// here too (via onStreamOptionsChanged, once the broadcast comes // here too (via onStreamOptionsChanged, once the broadcast comes
// back - see that handler and the setStreamOptions change listener // back - see that handler and the setStreamOptions change listener
// below), and the subtitle that was showing should survive that - // below), and the subtitle that was showing should survive that -
// subtitleSelect.value is untouched in that case, so this just // reads textSubtitleIndex (the synced party-wide state, kept current
// re-adds the same track. A genuinely new item (onItemChanged/init) // by onTextSubtitleChanged/renderSubtitleOptions) rather than
// already reset the select to "Aucun" before this runs (see // subtitleSelect.value directly, so it's correct even before the
// loadTrackOptions), so this correctly clears any leftover track // select itself has been (re)rendered for this load. A genuinely new
// from whatever played before. // item (onItemChanged) already reset it to null before this runs, so
// this correctly clears any leftover track from whatever played
// before.
const usingBurnIn = !!burnInSubtitleIndex; const usingBurnIn = !!burnInSubtitleIndex;
applySubtitleTrack(usingBurnIn ? '' : subtitleSelect.value); applySubtitleTrack(usingBurnIn ? '' : (textSubtitleIndex || ''));
applyStreamOptionsGating(); applyStreamOptionsGating();
// Both branches are already party-shared, one way or the other - // Both branches are already party-shared, one way or the other -
@ -451,25 +470,22 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
}); });
}); });
// A text pick (or "Aucun") just swaps the <track> - no reload, no // Both kinds of pick are host-only and go through the server now -
// re-buffering, the whole point of rendering those natively instead of // neither branch applies anything locally: every member (including
// asking Emby to burn them in. An image (burn-in) pick, or clearing an // this host) only ever reacts to the resulting broadcast
// already-active burn-in by picking a text subtitle/"Aucun" instead, // (onBurnInChanged / onTextSubtitleChanged below), same discipline as
// goes through the host-only "setBurnInSubtitle" socket call and does // onItemChanged/applyRemoteState never applying anything optimistically
// NOT reload here directly - every member (including this host) reacts // first. An image (burn-in) pick goes through "setBurnInSubtitle";
// to the resulting "burnInChanged" broadcast the same way, see // everything else - a text pick, or "Aucun" - goes through
// onBurnInChanged below. Same reasoning as onItemChanged/ // "setTextSubtitle", which the server also uses to clear any active
// applyRemoteState never applying anything optimistically before the // burn-in (the two are mutually exclusive - see SocketGateway).
// server's own broadcast confirms it.
subtitleSelect.addEventListener('change', () => { subtitleSelect.addEventListener('change', () => {
settingsPanel.classList.add('hide'); settingsPanel.classList.add('hide');
const isBurnInPick = subtitleSelect.selectedOptions[0]?.dataset.burnIn === '1'; const isBurnInPick = subtitleSelect.selectedOptions[0]?.dataset.burnIn === '1';
if (isBurnInPick) { if (isBurnInPick) {
conn?.setBurnInSubtitle(subtitleSelect.value, audioSelect.value || undefined); conn?.setBurnInSubtitle(subtitleSelect.value, audioSelect.value || undefined);
} else if (burnInSubtitleIndex) {
conn?.setBurnInSubtitle(null);
} else { } else {
applySubtitleTrack(subtitleSelect.value); conn?.setTextSubtitle(subtitleSelect.value || null);
} }
}); });
@ -481,15 +497,16 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
itemTitleEl.textContent = msg.itemName || ''; itemTitleEl.textContent = msg.itemName || '';
hasNextQueued = !!msg.hasNext; hasNextQueued = !!msg.hasNext;
nextBtn.classList.toggle('hide', !(isHost && hasNextQueued)); nextBtn.classList.toggle('hide', !(isHost && hasNextQueued));
// A new item's own stream indices have nothing to do with the old // A new item's own stream indices/subtitle pick have nothing to do
// one's - PartyService.advanceQueue already resets this server-side // with the old one's - PartyService.advanceQueue already resets this
// too (see its own comment); this just keeps this client in sync // server-side too (see its own comment); this just keeps this client
// without waiting on a separate broadcast for the same event. // in sync without waiting on a separate broadcast for the same event.
// streamQuality is deliberately left alone - a bandwidth/quality // streamQuality is deliberately left alone - a bandwidth/quality
// preference isn't tied to any one item, same reasoning as // preference isn't tied to any one item, same reasoning as
// PartyService.advanceQueue's own comment. // PartyService.advanceQueue's own comment.
burnInSubtitleIndex = null; burnInSubtitleIndex = null;
burnInAudioIndex = null; burnInAudioIndex = null;
textSubtitleIndex = null;
streamAudioIndex = null; streamAudioIndex = null;
await loadTrackOptions(msg.itemId); await loadTrackOptions(msg.itemId);
loadVideo(msg.itemId, 0, true); loadVideo(msg.itemId, 0, true);
@ -506,10 +523,50 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
function onBurnInChanged(msg: BurnInChangedMsg) { function onBurnInChanged(msg: BurnInChangedMsg) {
burnInSubtitleIndex = msg.subtitleIndex; burnInSubtitleIndex = msg.subtitleIndex;
burnInAudioIndex = msg.audioIndex; burnInAudioIndex = msg.audioIndex;
// Mutually exclusive with a text subtitle pick - the server already
// clears party.textSubtitleIndex whenever a *real* burn-in is set
// (see SocketGateway's setBurnInSubtitle) but only emits
// "burnInChanged" for it, not a separate "textSubtitleChanged" too -
// so mirror that clear here rather than waiting on a broadcast that
// isn't coming.
if (burnInSubtitleIndex) textSubtitleIndex = null;
renderSubtitleOptions(); renderSubtitleOptions();
if (currentItemId) loadVideo(currentItemId, video.currentTime, !video.paused); if (currentItemId) loadVideo(currentItemId, video.currentTime, !video.paused);
} }
/** The host picked (or cleared) the party's shared *text* subtitle -
* see the subtitleSelect change handler above for why every member
* (host included) only ever reacts to this broadcast rather than
* applying anything optimistically first. Usually no reload needed -
* unlike burn-in, a text subtitle is still just a native <track>
* each member renders locally (see applySubtitleTrack); only *which*
* one is synced. The exception is picking a text subtitle (or
* "Aucun") while a burn-in was active: the select allows it (host-
* only gating, not burn-in-specific any more - see
* renderSubtitleOptions), and it clears the burn-in for real, which
* DOES change the actual stream - so that specific transition still
* needs the same reload onBurnInChanged uses. A fresh page load/
* rejoin instead reads the party's already-current textSubtitleIndex
* directly - see the outer variable declarations above. */
function onTextSubtitleChanged(msg: TextSubtitleChangedMsg) {
textSubtitleIndex = msg.subtitleIndex;
const wasUsingBurnIn = !!burnInSubtitleIndex;
// Mutually exclusive with burn-in, mirroring the server (see
// SocketGateway's setTextSubtitle, which clears these two
// server-side too but only broadcasts "textSubtitleChanged" - not a
// second "burnInChanged" - for this side of the exclusivity).
if (textSubtitleIndex) {
burnInSubtitleIndex = null;
burnInAudioIndex = null;
}
renderSubtitleOptions();
if (wasUsingBurnIn && !burnInSubtitleIndex) {
if (currentItemId) loadVideo(currentItemId, video.currentTime, !video.paused);
} else {
applySubtitleTrack(textSubtitleIndex || '');
}
}
/** The host changed the party's shared normal stream's audio track/ /** The host changed the party's shared normal stream's audio track/
* quality - see the audioSelect/qualitySelect change listener above * quality - see the audioSelect/qualitySelect change listener above
* for why every member (host included) only ever reacts to this * for why every member (host included) only ever reacts to this
@ -521,7 +578,12 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
function onStreamOptionsChanged(msg: StreamOptionsChangedMsg) { function onStreamOptionsChanged(msg: StreamOptionsChangedMsg) {
streamAudioIndex = msg.audioStreamIndex; streamAudioIndex = msg.audioStreamIndex;
streamQuality = msg.quality; streamQuality = msg.quality;
audioSelect.value = streamAudioIndex || ''; // Same guard as loadTrackOptions above, and for the same reason - an
// audio <option> never has value="", so assigning '' when
// streamAudioIndex is null would match nothing and render as an
// empty-looking choice instead of leaving whichever option was
// already showing.
if (streamAudioIndex) audioSelect.value = streamAudioIndex;
qualitySelect.value = streamQuality || ''; qualitySelect.value = streamQuality || '';
if (currentItemId) loadVideo(currentItemId, video.currentTime, !video.paused); if (currentItemId) loadVideo(currentItemId, video.currentTime, !video.paused);
} }
@ -616,6 +678,7 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
onPauseRequested, onPauseRequested,
onResumeRequested, onResumeRequested,
onBurnInChanged, onBurnInChanged,
onTextSubtitleChanged,
onStreamOptionsChanged, onStreamOptionsChanged,
onDeviceConflict: () => { onDeviceConflict: () => {
// This socket is still connected but inert (see SocketGateway's // This socket is still connected but inert (see SocketGateway's

View file

@ -1665,8 +1665,12 @@ at all, unlike what Emby's own text burn-in required.
picking a burn-in subtitle is a host-only, party-*wide* choice picking a burn-in subtitle is a host-only, party-*wide* choice
(`SocketGateway`'s `setBurnInSubtitle` handler, broadcasting (`SocketGateway`'s `setBurnInSubtitle` handler, broadcasting
`burnInChanged` - byte-for-byte the same request/broadcast/react shape `burnInChanged` - byte-for-byte the same request/broadcast/react shape
`next`/`itemChanged` already use), not a per-viewer local one the way a `next`/`itemChanged` already use) - at the time this was written, a
*text* subtitle pick still is. `Party` gained `burnInSubtitleIndex`/ *text* subtitle pick was still each viewer's own local choice; it later
joined this same host-only/party-wide model too, once people actually
used this together and noticed the mismatch - see "Text subtitles join
burn-in as a party-wide, host-only pick" further below. `Party` gained
`burnInSubtitleIndex`/
`burnInAudioIndex` (and `PartyPublicState` exposes them too) so a `burnInAudioIndex` (and `PartyPublicState` exposes them too) so a
follower joining mid-party, or a page reload, picks up the party's follower joining mid-party, or a page reload, picks up the party's
current choice from a plain `getParty()` - same reason `itemId`/ current choice from a plain `getParty()` - same reason `itemId`/
@ -1730,6 +1734,11 @@ broadcast (`onBurnInChanged`, reloading `loadVideo`) - never
optimistically, same discipline `applyRemoteState`/`onItemChanged` optimistically, same discipline `applyRemoteState`/`onItemChanged`
already use for every other party-wide state change. already use for every other party-wide state change.
(A follower's subtitle select being disabled *only* while burn-in is
active, as just described, didn't last - see "Text subtitles join
burn-in as a party-wide, host-only pick" below, where the whole select
became host-gated unconditionally instead.)
Verified locally to the extent this dev environment allows (no Emby Verified locally to the extent this dev environment allows (no Emby
server, no ffmpeg binary reachable here either): full typecheck/test/ server, no ffmpeg binary reachable here either): full typecheck/test/
build on both projects; `absoluteToRelativeIndex`, the exact ffmpeg build on both projects; `absoluteToRelativeIndex`, the exact ffmpeg
@ -2086,6 +2095,93 @@ handling behaves once several viewers depend on the exact same one
instead of each having their own. Worth trying with two real accounts, instead of each having their own. Worth trying with two real accounts,
same standing item already on this project's own roadmap. same standing item already on this project's own roadmap.
## Text subtitles join burn-in as a party-wide, host-only pick
Reported directly, after the shared-stream work above shipped: watch
party members still didn't have "synced viewing settings" for
subtitles. The report was accurate but the shared-stream work only
half-closed the gap - it made audio/quality/burn-in host-only and
party-wide, but a plain *text* subtitle pick (the common case; burn-in
only ever applies to bitmap PGS/VobSub/DVB tracks) was still each
viewer's own local `<track>` choice, exactly as it always had been
since "Player track selection" above. Once people actually watched
together, "everyone but me changed the subtitle" read as broken sync,
not as a deliberate per-viewer feature.
**A parallel mechanism, not a merge into `burnInSubtitleIndex`** - the
two picks are similar in shape but stay separate fields:
`Party.textSubtitleIndex: string | null`, a new `setTextSubtitle`/
`textSubtitleChanged` socket pair, byte-for-byte the same host-only,
video-only gating and request/broadcast/react shape
`setBurnInSubtitle`/`burnInChanged` already used. Keeping them separate
(instead of reusing `burnInSubtitleIndex` for both) avoided touching
the already-proven burn-in path at all - `SocketGateway`'s existing
`setBurnInSubtitle` handler gained exactly one new line to clear
`textSubtitleIndex` when it sets a real value, and `setTextSubtitle` is
new code entirely. **Only one subtitle can be showing at once**, so the
two are mutually exclusive - enforced on the server, in both
directions: a real (non-null) pick on either handler clears the other's
`burnInSubtitleIndex`/`textSubtitleIndex`. Clearing one to null/"Aucun"
is deliberately *not* treated as a pick for this purpose - it doesn't
touch the other field, so picking a burn-in and then explicitly
clearing it back to "Aucun" doesn't also silently wipe out an unrelated
text choice that was never actually in the way.
**Mechanically, the two still work nothing alike underneath** - the
whole reason this wasn't just a rename of `burnInSubtitleIndex`: a
burn-in choice changes the actual encoded stream (ffmpeg's overlay
filter), so every member reloads through `loadVideo` when it changes.
A text choice only ever selects which native `<track>` each member
already renders locally (`applySubtitleTrack`) - no transcode, no
reload, no re-buffering, the entire point of rendering text subtitles
client-side in the first place. `Player.ts`'s `onTextSubtitleChanged`
reflects this: it's normally just a `<track>` swap, *except* when the
text pick is what clears an active burn-in (the select allows picking
a text option, or "Aucun", at any time now - see below - even while
burn-in is active) - that one transition really does change the
underlying stream back to `PartyStreamService`'s normal one, so it
still needs the same reload `onBurnInChanged` already uses.
**Player UI**: `renderSubtitleOptions`'s gating stopped being
conditional on burn-in - `subtitleSelect.disabled` is now simply
`!isHost`, unconditionally, matching `applyStreamOptionsGating`'s own
reasoning for audio/quality. The select's value on load or broadcast
restores from `burnInSubtitleIndex || textSubtitleIndex || <previous
value>` - whichever kind of pick, if any, the party currently has.
Changing the select now always goes to the server - an image option
calls `setBurnInSubtitle`; everything else (a text option, or "Aucun")
calls `setTextSubtitle` - never applies anything locally first, same
discipline every other party-wide control in this app already follows.
**A second, unrelated bug in the same report**: "the language selector
shows an empty choice at the start." Root cause was in
`audioSelect.value = streamAudioIndex || '';` (both in
`loadTrackOptions` and `onStreamOptionsChanged`) - `streamAudioIndex`
is `null` by default (Emby's own default, nothing picked yet), and a
real audio-track `<option>` never has `value=""` (only the
no-tracks-at-all fallback branch does), so assigning `''` matched
nothing and left the select's `selectedIndex` at `-1` - a genuinely
empty-looking choice, not merely "showing the default option". Fixed
at both call sites by only assigning `.value` when `streamAudioIndex`
is truthy, otherwise leaving the select to fall back to its first real
option the way an untouched `<select>` already does. Unrelated to the
subtitle-sync fix above beyond living in the same file and the same
bug report - `qualitySelect` never had this problem, since its preset
list (`QUALITY_PRESETS`) already has an explicit `value=""` "Auto"
option to match against.
Verified: full typecheck/test/build on both projects. `SocketGateway`
tests cover `setTextSubtitle`'s host-only gating, its broadcast, its
no-op on a music party, and the mutual-exclusivity behavior in both
directions (mirroring the existing `setBurnInSubtitle` tests);
`PartyService` tests cover `textSubtitleIndex` through `create`/
`advanceQueue`/`toPublicState`. A real-browser check (mocked-fetch +
real bundle, this session's standing harness) confirmed: a follower's
subtitle select renders disabled regardless of burn-in state; a host's
select restores the party's current text or burn-in pick correctly on
load; and the audio select now lands on a real first option instead of
an empty one when `streamAudioIndex` is `null`.
## Known limitations ## Known limitations
- Playback always goes through `hls.js` against whatever Emby's - Playback always goes through `hls.js` against whatever Emby's

View file

@ -39,9 +39,15 @@ import type { AppServer, LeaveResult, LobbyPartyDto, Party, SocketMemberDto } fr
* "setBurnInSubtitle" {subtitleIndex, audioIndex?} (host only - picks * "setBurnInSubtitle" {subtitleIndex, audioIndex?} (host only - picks
* or clears the party's *shared* self-hosted-ffmpeg burn-in stream, * or clears the party's *shared* self-hosted-ffmpeg burn-in stream,
* for a PGS/image subtitle a native <track> can't render - see * for a PGS/image subtitle a native <track> can't render - see
* TranscodeService. A text subtitle never goes through here - that * TranscodeService. Setting a real one clears "setTextSubtitle"'s
* stays the existing per-viewer client-side <track>.) * own state below - mutually exclusive, only one subtitle shows at once.)
* "burnInChanged" (server -> client) * "burnInChanged" (server -> client)
* "setTextSubtitle" {subtitleIndex} (host only - picks or clears a
* *text* subtitle for the whole party; every member still renders
* it themselves as a native <track>, this only syncs *which* one -
* no transcoding involved, unlike burn-in above. Setting a real one
* clears "setBurnInSubtitle"'s own state - mutually exclusive.)
* "textSubtitleChanged" (server -> client)
* "setStreamOptions" {audioStreamIndex?, quality?} (host only - picks * "setStreamOptions" {audioStreamIndex?, quality?} (host only - picks
* the audio track/quality of the party's *shared* normal Emby stream - * the audio track/quality of the party's *shared* normal Emby stream -
* see PartyStreamService. There's only ever one stream per party now * see PartyStreamService. There's only ever one stream per party now
@ -213,11 +219,37 @@ class SocketGatewayClass {
if (!party || party.mediaType !== 'video' || userId !== party.hostUserId) return; if (!party || party.mediaType !== 'video' || userId !== party.hostUserId) return;
party.burnInSubtitleIndex = msg?.subtitleIndex || null; party.burnInSubtitleIndex = msg?.subtitleIndex || null;
party.burnInAudioIndex = party.burnInSubtitleIndex ? (msg?.audioIndex || null) : null; party.burnInAudioIndex = party.burnInSubtitleIndex ? (msg?.audioIndex || null) : null;
// Mutually exclusive with a text pick - only one subtitle can be
// showing at once, see "setTextSubtitle" below.
if (party.burnInSubtitleIndex) party.textSubtitleIndex = null;
party.lastActivityUtc = Date.now(); party.lastActivityUtc = Date.now();
Logger.party(`burnInChanged partyId=${party.id} subtitleIndex=${party.burnInSubtitleIndex ?? '(none)'} audioIndex=${party.burnInAudioIndex ?? '(default)'}`); Logger.party(`burnInChanged partyId=${party.id} subtitleIndex=${party.burnInSubtitleIndex ?? '(none)'} audioIndex=${party.burnInAudioIndex ?? '(default)'}`);
io.to(party.id).emit('burnInChanged', { subtitleIndex: party.burnInSubtitleIndex, audioIndex: party.burnInAudioIndex }); io.to(party.id).emit('burnInChanged', { subtitleIndex: party.burnInSubtitleIndex, audioIndex: party.burnInAudioIndex });
})); }));
// Host-only, video-only: picks or clears a *text* subtitle for the
// whole party - subtitle choice in general is party-wide now, not
// each viewer's own local pick (see docs/architecture.md - that
// was the original design, but turned out to be the wrong call
// once people actually watched together). Every member still
// renders it themselves as a native <track> - only *which* one is
// synced here, no transcoding/stream change involved, unlike
// "setBurnInSubtitle" above.
socket.on('setTextSubtitle', this.safe((msg: { subtitleIndex: string | null }) => {
if (!party || party.mediaType !== 'video' || userId !== party.hostUserId) return;
party.textSubtitleIndex = msg?.subtitleIndex || null;
// Mutually exclusive with a burn-in pick - clearing it here is
// what actually drops the party back onto its normal shared
// stream (PartyStreamService) once a real text index is picked.
if (party.textSubtitleIndex) {
party.burnInSubtitleIndex = null;
party.burnInAudioIndex = null;
}
party.lastActivityUtc = Date.now();
Logger.party(`textSubtitleChanged partyId=${party.id} subtitleIndex=${party.textSubtitleIndex ?? '(none)'}`);
io.to(party.id).emit('textSubtitleChanged', { subtitleIndex: party.textSubtitleIndex });
}));
// Host-only, video-only: picks the audio track/quality of the // Host-only, video-only: picks the audio track/quality of the
// party's *shared* normal Emby stream - see PartyStreamService. // party's *shared* normal Emby stream - see PartyStreamService.
// Deliberately doesn't touch PartyStreamService directly here - // Deliberately doesn't touch PartyStreamService directly here -

View file

@ -63,6 +63,7 @@ class PartyServiceClass {
members: new Map(), members: new Map(),
burnInSubtitleIndex: null, burnInSubtitleIndex: null,
burnInAudioIndex: null, burnInAudioIndex: null,
textSubtitleIndex: null,
streamAudioIndex: null, streamAudioIndex: null,
streamQuality: null streamQuality: null
}; };
@ -203,20 +204,21 @@ class PartyServiceClass {
member.lastUpdatedUtc = Date.now(); member.lastUpdatedUtc = Date.now();
} }
// A new item's own MediaStreams indices have nothing to do with the // A new item's own MediaStreams indices have nothing to do with the
// old one's - carrying a burn-in pick across would burn in whatever // old one's - carrying a burn-in or text subtitle pick across would
// stream happens to share that index on the new item, not the same // show whatever track happens to share that index on the new item,
// subtitle; same reasoning for streamAudioIndex (an audio *track* // not the same subtitle; same reasoning for streamAudioIndex (an
// index is just as item-specific). streamQuality is left alone - // audio *track* index is just as item-specific). streamQuality is
// that's a bandwidth/quality preference, not tied to any one item, // left alone - that's a bandwidth/quality preference, not tied to
// and already persisted across an item change even before streams // any one item, and already persisted across an item change even
// were shared (qualitySelect was never rebuilt per-item either). // before streams were shared (qualitySelect was never rebuilt
// TranscodeService/PartyStreamService.endSession also release the // per-item either). TranscodeService/PartyStreamService.endSession
// old item's now-irrelevant ffmpeg process or negotiated Emby target // also release the old item's now-irrelevant ffmpeg process or
// immediately rather than waiting for their idle reapers - see // negotiated Emby target immediately rather than waiting for their
// index.ts's own comment on why those are a backstop, not the // idle reapers - see index.ts's own comment on why those are a
// normal path. // backstop, not the normal path.
party.burnInSubtitleIndex = null; party.burnInSubtitleIndex = null;
party.burnInAudioIndex = null; party.burnInAudioIndex = null;
party.textSubtitleIndex = null;
party.streamAudioIndex = null; party.streamAudioIndex = null;
TranscodeService.endSession(party.id); TranscodeService.endSession(party.id);
PartyStreamService.endSession(party.id); PartyStreamService.endSession(party.id);
@ -278,6 +280,7 @@ class PartyServiceClass {
music: party.mediaType === 'music' ? MusicQueueService.getState(party) : null, music: party.mediaType === 'music' ? MusicQueueService.getState(party) : null,
burnInSubtitleIndex: party.burnInSubtitleIndex, burnInSubtitleIndex: party.burnInSubtitleIndex,
burnInAudioIndex: party.burnInAudioIndex, burnInAudioIndex: party.burnInAudioIndex,
textSubtitleIndex: party.textSubtitleIndex,
streamAudioIndex: party.streamAudioIndex, streamAudioIndex: party.streamAudioIndex,
streamQuality: party.streamQuality streamQuality: party.streamQuality
}; };

View file

@ -38,9 +38,21 @@ export interface ClientToServerEvents {
* see TranscodeService. A null subtitleIndex clears it, dropping the * see TranscodeService. A null subtitleIndex clears it, dropping the
* party back to its normal party-shared Emby stream (PartyStreamService - * party back to its normal party-shared Emby stream (PartyStreamService -
* also shared, just proxied rather than transcoded by us). Not for * also shared, just proxied rather than transcoded by us). Not for
* text subtitles (those stay the existing per-viewer client-side * text subtitles - see "setTextSubtitle" below for those. Setting a
* <track>, no server involvement) - see docs/architecture.md. */ * real subtitleIndex here clears party.textSubtitleIndex (mutually
* exclusive - only one subtitle can be showing at once). */
setBurnInSubtitle: (msg: { subtitleIndex: string | null; audioIndex?: string }) => void; setBurnInSubtitle: (msg: { subtitleIndex: string | null; audioIndex?: string }) => void;
/** Host-only, video-only (checked server-side exactly like "next") -
* picks (or clears) a *text* subtitle for the whole party, now that
* subtitle choice in general is party-wide rather than each viewer's
* own local pick (see docs/architecture.md - it wasn't at first, but
* that turned out to be the wrong call once people actually used
* this together). Every member still renders it themselves as a
* native <track> - this only syncs *which* one, not the rendering
* itself, so there's no server-side transcoding cost the way burn-in
* has. Setting a real subtitleIndex here clears party.burnInSubtitleIndex/
* burnInAudioIndex (mutually exclusive, same as above). */
setTextSubtitle: (msg: { subtitleIndex: string | null }) => void;
/** Host-only, video-only (checked server-side exactly like "next") - /** Host-only, video-only (checked server-side exactly like "next") -
* picks the audio track/quality the party's *shared* normal stream * picks the audio track/quality the party's *shared* normal stream
* uses (PartyStreamService) - see docs/architecture.md's "One shared * uses (PartyStreamService) - see docs/architecture.md's "One shared
@ -93,6 +105,11 @@ export interface ServerToClientEvents {
* mid-party) reads instead - this event is only for someone already * mid-party) reads instead - this event is only for someone already
* connected when it changes live. */ * connected when it changes live. */
burnInChanged: (msg: { subtitleIndex: string | null; audioIndex: string | null }) => void; burnInChanged: (msg: { subtitleIndex: string | null; audioIndex: string | null }) => void;
/** Broadcast to the whole party room whenever the host changes (or
* clears) the party's *text* subtitle choice - see "setTextSubtitle"
* above. Mirrors party.textSubtitleIndex below, which is what a
* fresh "getParty" (a follower joining mid-party) reads instead. */
textSubtitleChanged: (msg: { subtitleIndex: string | null }) => void;
/** Broadcast to the whole party room whenever the host changes the /** Broadcast to the whole party room whenever the host changes the
* party's shared stream's audio track/quality - see * party's shared stream's audio track/quality - see
* "setStreamOptions" above and PartyStreamService. Mirrors * "setStreamOptions" above and PartyStreamService. Mirrors
@ -263,6 +280,13 @@ export interface Party {
* "getParty" - same reason itemId/queueIndex live here too. */ * "getParty" - same reason itemId/queueIndex live here too. */
burnInSubtitleIndex: string | null; burnInSubtitleIndex: string | null;
burnInAudioIndex: string | null; burnInAudioIndex: string | null;
/** Video only - the host's current pick for a *text* subtitle, party-
* wide - see SocketGateway's "setTextSubtitle" handler. Mutually
* exclusive with burnInSubtitleIndex (picking one clears the other -
* only one subtitle can be showing at once). Every member still
* renders it themselves as a native <track> - this only syncs
* *which* one, no server-side transcoding involved, unlike burn-in. */
textSubtitleIndex: string | null;
/** Video only - the host's current pick for the party's normal /** Video only - the host's current pick for the party's normal
* *shared* Emby stream (PartyStreamService) - null means "Emby's own * *shared* Emby stream (PartyStreamService) - null means "Emby's own
* default", same as an untouched picker meant before streams were * default", same as an untouched picker meant before streams were
@ -304,6 +328,7 @@ export interface PartyPublicState {
music: MusicPublicState | null; music: MusicPublicState | null;
burnInSubtitleIndex: string | null; burnInSubtitleIndex: string | null;
burnInAudioIndex: string | null; burnInAudioIndex: string | null;
textSubtitleIndex: string | null;
streamAudioIndex: string | null; streamAudioIndex: string | null;
streamQuality: string | null; streamQuality: string | null;
} }

View file

@ -26,6 +26,7 @@ function fakeParty(id: string): Party {
members: new Map(), members: new Map(),
burnInSubtitleIndex: null, burnInSubtitleIndex: null,
burnInAudioIndex: null, burnInAudioIndex: null,
textSubtitleIndex: null,
streamAudioIndex: null, streamAudioIndex: null,
streamQuality: null streamQuality: null
}; };

View file

@ -15,6 +15,7 @@ describe('PartyService', () => {
assert.equal(party.hostUserId, 'u-alice'); assert.equal(party.hostUserId, 'u-alice');
assert.equal(party.burnInSubtitleIndex, null, 'no burn-in stream until the host picks one'); assert.equal(party.burnInSubtitleIndex, null, 'no burn-in stream until the host picks one');
assert.equal(party.burnInAudioIndex, null); assert.equal(party.burnInAudioIndex, null);
assert.equal(party.textSubtitleIndex, null, 'no text subtitle pick until the host picks one');
}); });
it('advanceQueue moves through the fixed queue and stops at the end', () => { it('advanceQueue moves through the fixed queue and stops at the end', () => {
@ -28,18 +29,20 @@ describe('PartyService', () => {
assert.equal(PartyService.advanceQueue(party), false, 'nothing left after the last item'); assert.equal(PartyService.advanceQueue(party), false, 'nothing left after the last item');
}); });
it('advanceQueue clears the party\'s burn-in choice - a new item\'s stream indices mean nothing on the old one', () => { it('advanceQueue clears the party\'s burn-in/text-subtitle choice - a new item\'s stream indices mean nothing on the old one', () => {
const party = PartyService.create('u-alice', 'Alice', 'ep1', 'S01E01', '/img/ep1', [ const party = PartyService.create('u-alice', 'Alice', 'ep1', 'S01E01', '/img/ep1', [
{ itemId: 'ep1', itemName: 'S01E01', itemImageUrl: '/img/ep1' }, { itemId: 'ep1', itemName: 'S01E01', itemImageUrl: '/img/ep1' },
{ itemId: 'ep2', itemName: 'S01E02', itemImageUrl: '/img/ep2' } { itemId: 'ep2', itemName: 'S01E02', itemImageUrl: '/img/ep2' }
], 'video'); ], 'video');
party.burnInSubtitleIndex = '4'; party.burnInSubtitleIndex = '4';
party.burnInAudioIndex = '2'; party.burnInAudioIndex = '2';
party.textSubtitleIndex = '3';
PartyService.advanceQueue(party); PartyService.advanceQueue(party);
assert.equal(party.burnInSubtitleIndex, null); assert.equal(party.burnInSubtitleIndex, null);
assert.equal(party.burnInAudioIndex, null); assert.equal(party.burnInAudioIndex, null);
assert.equal(party.textSubtitleIndex, null);
}); });
it('leave(): a non-host leaving neither destroys the party nor hands off', () => { it('leave(): a non-host leaving neither destroys the party nor hands off', () => {
@ -115,6 +118,15 @@ describe('PartyService', () => {
assert.equal(state.burnInAudioIndex, '2'); assert.equal(state.burnInAudioIndex, '2');
}); });
it('toPublicState exposes the party\'s current text subtitle choice - a late joiner\'s getParty must see it too', () => {
const party = PartyService.create('u-eve', 'Eve', 'm2', 'Un Autre Film', '/img/m2', null, 'video');
party.textSubtitleIndex = '3';
const state = PartyService.toPublicState(party, 'u-eve');
assert.equal(state.textSubtitleIndex, '3');
});
it('setMemberEmbyToken records a member\'s own token, and is a harmless no-op for a non-member', () => { it('setMemberEmbyToken records a member\'s own token, and is a harmless no-op for a non-member', () => {
const party = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video'); const party = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video');

View file

@ -419,6 +419,96 @@ describe('SocketGateway', () => {
assert.ok(!io.roomEmits.some((e) => e.event === 'burnInChanged')); assert.ok(!io.roomEmits.some((e) => e.event === 'burnInChanged'));
}); });
it('setTextSubtitle is host-only and a no-op for anyone else (same pattern as "next")', () => {
const io = fakeIo();
attach(io);
const party = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video');
PartyService.join(party, 'u-bob', 'Bob');
const bobSocket = fakeSocket('u-bob');
io.trigger('connection', bobSocket as unknown as AppSocket);
bobSocket.trigger('join', { partyId: party.id, deviceId: 'device-bob' });
bobSocket.trigger('setTextSubtitle', { subtitleIndex: '3' });
assert.equal(party.textSubtitleIndex, null, 'a non-host request must not change party state');
assert.ok(!io.roomEmits.some((e) => e.event === 'textSubtitleChanged'), 'and must not broadcast anything');
});
it('setTextSubtitle from the host updates the party and broadcasts textSubtitleChanged to the room', () => {
const io = fakeIo();
attach(io);
const party = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video');
const aliceSocket = fakeSocket('u-alice');
io.trigger('connection', aliceSocket as unknown as AppSocket);
aliceSocket.trigger('join', { partyId: party.id, deviceId: 'device-alice' });
aliceSocket.trigger('setTextSubtitle', { subtitleIndex: '3' });
assert.equal(party.textSubtitleIndex, '3');
const broadcast = io.roomEmits.find((e) => e.room === party.id && e.event === 'textSubtitleChanged');
assert.deepEqual(broadcast?.payload, { subtitleIndex: '3' });
// Clearing it ("Aucun") resets it back to null.
aliceSocket.trigger('setTextSubtitle', { subtitleIndex: null });
assert.equal(party.textSubtitleIndex, null);
});
it('setTextSubtitle is a no-op on a music party (no host to drive it, no video stream either)', () => {
const io = fakeIo();
attach(io);
const party = PartyService.create('u-alice', 'Alice', 't1', 'Track One', '/img/t1', null, 'music');
const aliceSocket = fakeSocket('u-alice');
io.trigger('connection', aliceSocket as unknown as AppSocket);
aliceSocket.trigger('join', { partyId: party.id, deviceId: 'device-alice' });
aliceSocket.trigger('setTextSubtitle', { subtitleIndex: '3' });
assert.equal(party.textSubtitleIndex, null);
assert.ok(!io.roomEmits.some((e) => e.event === 'textSubtitleChanged'));
});
it('setBurnInSubtitle and setTextSubtitle are mutually exclusive - a real pick on one clears the other', () => {
const io = fakeIo();
attach(io);
const party = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video');
const aliceSocket = fakeSocket('u-alice');
io.trigger('connection', aliceSocket as unknown as AppSocket);
aliceSocket.trigger('join', { partyId: party.id, deviceId: 'device-alice' });
// A real text pick clears an active burn-in - both fields, so no
// stray audioIndex is left behind with no subtitle (same guarantee
// setBurnInSubtitle(null) itself already gives).
aliceSocket.trigger('setBurnInSubtitle', { subtitleIndex: '4', audioIndex: '2' });
aliceSocket.trigger('setTextSubtitle', { subtitleIndex: '3' });
assert.equal(party.textSubtitleIndex, '3');
assert.equal(party.burnInSubtitleIndex, null, 'picking a text subtitle must clear an active burn-in');
assert.equal(party.burnInAudioIndex, null);
// And the other direction: a real burn-in pick clears an active text subtitle.
aliceSocket.trigger('setTextSubtitle', { subtitleIndex: '3' });
aliceSocket.trigger('setBurnInSubtitle', { subtitleIndex: '4', audioIndex: '2' });
assert.equal(party.burnInSubtitleIndex, '4');
assert.equal(party.textSubtitleIndex, null, 'picking a burn-in must clear an active text subtitle');
});
it('clearing setTextSubtitle to null ("Aucun") does not disturb an unrelated active burn-in', () => {
const io = fakeIo();
attach(io);
const party = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video');
const aliceSocket = fakeSocket('u-alice');
io.trigger('connection', aliceSocket as unknown as AppSocket);
aliceSocket.trigger('join', { partyId: party.id, deviceId: 'device-alice' });
aliceSocket.trigger('setBurnInSubtitle', { subtitleIndex: '4', audioIndex: '2' });
// null is not a *real* pick fighting for the same slot - only a
// truthy subtitleIndex is, so this must not clobber the burn-in the
// way a real text pick does above.
aliceSocket.trigger('setTextSubtitle', { subtitleIndex: null });
assert.equal(party.burnInSubtitleIndex, '4', 'clearing the (already-empty) text subtitle must not clear burn-in');
assert.equal(party.textSubtitleIndex, null);
});
it('setStreamOptions is host-only and a no-op for anyone else (same pattern as "next")', () => { it('setStreamOptions is host-only and a no-op for anyone else (same pattern as "next")', () => {
const io = fakeIo(); const io = fakeIo();
attach(io); attach(io);