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
toolbar lets you ask the host to pause or resume — everyone sees the
request, including the host, without needing chat.
6. Subtitles render natively in the browser for any text-based track. A
Blu-ray/DVD-style image subtitle (PGS/VobSub/DVB) is different — the
host can pick one to have it burned into the video by this app's own
ffmpeg, shared live by everyone in the party (picking one restarts
the stream for
everyone, so it's a host-only choice, like play/pause already is).
6. Subtitles are a host-only, party-wide pick too, like the
language/quality pick above — the host picks one (or "Aucun") and
everyone's player follows. Any text-based track just renders
natively in each viewer's own browser, in sync (no reload, no extra
server load — only *which* one is synced). A Blu-ray/DVD-style image
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 →
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,
@ -229,9 +232,10 @@ Then put it behind your existing reverse proxy like everything else
- [x] Design + scaffold the standalone app
- [x] Proxy all Emby access server-side; Emby-themed UI (sidebar, carousels)
- [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
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
(dead-connection cleanup server-side, view teardown on navigation
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
- [x] One shared Emby stream per party instead of one per viewer — see
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)
- [ ] 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
// (connectLobby below) by party id - see docs/architecture.md.
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> {
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
* setBurnInSubtitle below and Player.ts. */
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 -
* see setStreamOptions below and Player.ts. */
onStreamOptionsChanged?: (msg: StreamOptionsChangedMsg) => void;
@ -117,9 +120,13 @@ export interface PartyConnection {
/** Host-only: picks the party's shared self-hosted-ffmpeg burn-in
* stream for a PGS/image subtitle (null clears it) - see
* TranscodeService and Player.ts's subtitle <select>. Not for a text
* subtitle - that stays the existing per-viewer client-side <track>,
* no socket call at all. */
* subtitle - see setTextSubtitle below for those. */
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
* shared normal stream (PartyStreamService) - there's only ever one
* 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.
*/
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 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 (onDeviceConflict) socket.on('deviceConflict', () => onDeviceConflict());
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 (onError) socket.on('errorMessage', (m) => onError((m as { message: string }).message));
if (onClose) socket.on('disconnect', () => onClose());
@ -188,6 +196,9 @@ export function connectParty(partyId: string, callbacks: PartyConnectionCallback
setBurnInSubtitle(subtitleIndex, audioIndex) {
socket.emit('setBurnInSubtitle', { subtitleIndex, audioIndex });
},
setTextSubtitle(subtitleIndex) {
socket.emit('setTextSubtitle', { subtitleIndex });
},
setStreamOptions(audioStreamIndex, quality) {
socket.emit('setStreamOptions', { audioStreamIndex, quality });
},

View file

@ -117,6 +117,12 @@ export interface PartyPublicState {
// SocketGateway's own comment.
burnInSubtitleIndex: 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
// stream (PartyStreamService) - null means "Emby's own default".
// There's only ever one stream now, so this is party-wide, host-set
@ -184,6 +190,13 @@ export interface BurnInChangedMsg {
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/
* onStreamOptionsChanged and PartyPublicState.streamAudioIndex/
* 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 type { CleanupFn, RouteParams } from '../../router';
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';
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.
let burnInSubtitleIndex: string | null = party.burnInSubtitleIndex;
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
// PartyStreamService) - there's only one stream now, not one per
// 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,
// set once at the top of renderVideoPlayer) so this is really only
// needed here for symmetry/simplicity, not because it couldn't be
// set any earlier.
audioSelect.value = streamAudioIndex || '';
// set any earlier. Guarded (not a plain `|| ''` fallback) because
// 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 || '';
renderSubtitleOptions();
applyStreamOptionsGating();
@ -284,20 +297,25 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
/**
* (Re)builds the subtitle <select> from the last-fetched playback
* options, called both on a genuine item load and whenever burn-in/host
* state changes (becomeHost, onBurnInChanged) without a fresh fetch.
* options, called both on a genuine item load and whenever burn-in/
* text-subtitle/host state changes (becomeHost, onBurnInChanged,
* onTextSubtitleChanged) without a fresh fetch.
*
* isTextBased: false means this is an image/bitmap subtitle (PGS/
* VobSub/DVB, typically a Blu-ray/DVD rip) - a native <track> (see
* applySubtitleTrack) needs real text, which a bitmap has none of for
* Emby to convert to WebVTT. These now go through TranscodeService's
* own self-hosted ffmpeg burn-in instead - see docs/architecture.md's
* "Self-hosted ffmpeg" section - which is a party-*wide*, host-driven
* choice (like play/pause/next already are), not a per-viewer local
* one the way a text subtitle still is. So: host-only selectable
* Every option here is host-only and party-wide now (subtitle choice
* used to be each viewer's own local pick, but that turned out to be
* the wrong call once people actually watched together - see
* docs/architecture.md) - but the two kinds still work very
* differently underneath. isTextBased: false means this is an image/
* bitmap subtitle (PGS/VobSub/DVB, typically a Blu-ray/DVD rip) - a
* native <track> (see applySubtitleTrack) needs real text, which a
* 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
* handler below), and picking one restarts the stream for everyone -
* the label says so instead of leaving it a surprise.
* handler below) - an actual re-encode of the shared stream, so
* 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() {
if (!lastPlaybackOptions) return;
@ -305,19 +323,18 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
subtitleSelect.innerHTML = '<option value="">Aucun</option>' +
lastPlaybackOptions.subtitleTracks.map((t) => {
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"${isHost ? '' : ' disabled'}>${escapeHtml(t.label)}${suffix}</option>`;
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}">${escapeHtml(t.label)}</option>`;
}).join('');
subtitleSelect.value = burnInSubtitleIndex || previousValue;
subtitleSelect.value = burnInSubtitleIndex || textSubtitleIndex || previousValue;
// While a burn-in stream is active, a follower has nothing meaningful
// left to pick here - the shared stream's audio+video bytes are
// already fixed either way, and their own text-subtitle/"Aucun"
// choice wouldn't change anything about it. Only the host, who can
// also change or clear the burn-in itself, keeps this enabled.
subtitleSelect.disabled = !!burnInSubtitleIndex && !isHost;
// Host-only and party-wide unconditionally now, same reasoning as
// applyStreamOptionsGating below - a follower has nothing left to
// pick here at all, whether or not a burn-in stream happens to be
// active (unlike audio/quality, this one isn't video-vs-burn-in
// specific - it's just "are you the host").
subtitleSelect.disabled = !isHost;
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
// back - see that handler and the setStreamOptions change listener
// below), and the subtitle that was showing should survive that -
// subtitleSelect.value is untouched in that case, so this just
// re-adds the same track. A genuinely new item (onItemChanged/init)
// already reset the select to "Aucun" before this runs (see
// loadTrackOptions), so this correctly clears any leftover track
// from whatever played before.
// reads textSubtitleIndex (the synced party-wide state, kept current
// by onTextSubtitleChanged/renderSubtitleOptions) rather than
// subtitleSelect.value directly, so it's correct even before the
// select itself has been (re)rendered for this load. A genuinely new
// item (onItemChanged) already reset it to null before this runs, so
// this correctly clears any leftover track from whatever played
// before.
const usingBurnIn = !!burnInSubtitleIndex;
applySubtitleTrack(usingBurnIn ? '' : subtitleSelect.value);
applySubtitleTrack(usingBurnIn ? '' : (textSubtitleIndex || ''));
applyStreamOptionsGating();
// 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
// re-buffering, the whole point of rendering those natively instead of
// asking Emby to burn them in. An image (burn-in) pick, or clearing an
// already-active burn-in by picking a text subtitle/"Aucun" instead,
// goes through the host-only "setBurnInSubtitle" socket call and does
// NOT reload here directly - every member (including this host) reacts
// to the resulting "burnInChanged" broadcast the same way, see
// onBurnInChanged below. Same reasoning as onItemChanged/
// applyRemoteState never applying anything optimistically before the
// server's own broadcast confirms it.
// Both kinds of pick are host-only and go through the server now -
// neither branch applies anything locally: every member (including
// this host) only ever reacts to the resulting broadcast
// (onBurnInChanged / onTextSubtitleChanged below), same discipline as
// onItemChanged/applyRemoteState never applying anything optimistically
// first. An image (burn-in) pick goes through "setBurnInSubtitle";
// everything else - a text pick, or "Aucun" - goes through
// "setTextSubtitle", which the server also uses to clear any active
// burn-in (the two are mutually exclusive - see SocketGateway).
subtitleSelect.addEventListener('change', () => {
settingsPanel.classList.add('hide');
const isBurnInPick = subtitleSelect.selectedOptions[0]?.dataset.burnIn === '1';
if (isBurnInPick) {
conn?.setBurnInSubtitle(subtitleSelect.value, audioSelect.value || undefined);
} else if (burnInSubtitleIndex) {
conn?.setBurnInSubtitle(null);
} 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 || '';
hasNextQueued = !!msg.hasNext;
nextBtn.classList.toggle('hide', !(isHost && hasNextQueued));
// A new item's own stream indices have nothing to do with the old
// one's - PartyService.advanceQueue already resets this server-side
// too (see its own comment); this just keeps this client in sync
// without waiting on a separate broadcast for the same event.
// A new item's own stream indices/subtitle pick have nothing to do
// with the old one's - PartyService.advanceQueue already resets this
// server-side too (see its own comment); this just keeps this client
// in sync without waiting on a separate broadcast for the same event.
// streamQuality is deliberately left alone - a bandwidth/quality
// preference isn't tied to any one item, same reasoning as
// PartyService.advanceQueue's own comment.
burnInSubtitleIndex = null;
burnInAudioIndex = null;
textSubtitleIndex = null;
streamAudioIndex = null;
await loadTrackOptions(msg.itemId);
loadVideo(msg.itemId, 0, true);
@ -506,10 +523,50 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
function onBurnInChanged(msg: BurnInChangedMsg) {
burnInSubtitleIndex = msg.subtitleIndex;
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();
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/
* quality - see the audioSelect/qualitySelect change listener above
* 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) {
streamAudioIndex = msg.audioStreamIndex;
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 || '';
if (currentItemId) loadVideo(currentItemId, video.currentTime, !video.paused);
}
@ -616,6 +678,7 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
onPauseRequested,
onResumeRequested,
onBurnInChanged,
onTextSubtitleChanged,
onStreamOptionsChanged,
onDeviceConflict: () => {
// 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
(`SocketGateway`'s `setBurnInSubtitle` handler, broadcasting
`burnInChanged` - byte-for-byte the same request/broadcast/react shape
`next`/`itemChanged` already use), not a per-viewer local one the way a
*text* subtitle pick still is. `Party` gained `burnInSubtitleIndex`/
`next`/`itemChanged` already use) - at the time this was written, a
*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
follower joining mid-party, or a page reload, picks up the party's
current choice from a plain `getParty()` - same reason `itemId`/
@ -1730,6 +1734,11 @@ broadcast (`onBurnInChanged`, reloading `loadVideo`) - never
optimistically, same discipline `applyRemoteState`/`onItemChanged`
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
server, no ffmpeg binary reachable here either): full typecheck/test/
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,
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
- 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
* or clears the party's *shared* self-hosted-ffmpeg burn-in stream,
* for a PGS/image subtitle a native <track> can't render - see
* TranscodeService. A text subtitle never goes through here - that
* stays the existing per-viewer client-side <track>.)
* TranscodeService. Setting a real one clears "setTextSubtitle"'s
* own state below - mutually exclusive, only one subtitle shows at once.)
* "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
* the audio track/quality of the party's *shared* normal Emby stream -
* 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;
party.burnInSubtitleIndex = msg?.subtitleIndex || 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();
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 });
}));
// 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
// party's *shared* normal Emby stream - see PartyStreamService.
// Deliberately doesn't touch PartyStreamService directly here -

View file

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

View file

@ -38,9 +38,21 @@ export interface ClientToServerEvents {
* see TranscodeService. A null subtitleIndex clears it, dropping the
* party back to its normal party-shared Emby stream (PartyStreamService -
* also shared, just proxied rather than transcoded by us). Not for
* text subtitles (those stay the existing per-viewer client-side
* <track>, no server involvement) - see docs/architecture.md. */
* text subtitles - see "setTextSubtitle" below for those. Setting a
* real subtitleIndex here clears party.textSubtitleIndex (mutually
* exclusive - only one subtitle can be showing at once). */
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") -
* picks the audio track/quality the party's *shared* normal stream
* 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
* connected when it changes live. */
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
* party's shared stream's audio track/quality - see
* "setStreamOptions" above and PartyStreamService. Mirrors
@ -263,6 +280,13 @@ export interface Party {
* "getParty" - same reason itemId/queueIndex live here too. */
burnInSubtitleIndex: 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
* *shared* Emby stream (PartyStreamService) - null means "Emby's own
* default", same as an untouched picker meant before streams were
@ -304,6 +328,7 @@ export interface PartyPublicState {
music: MusicPublicState | null;
burnInSubtitleIndex: string | null;
burnInAudioIndex: string | null;
textSubtitleIndex: string | null;
streamAudioIndex: string | null;
streamQuality: string | null;
}

View file

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

View file

@ -15,6 +15,7 @@ describe('PartyService', () => {
assert.equal(party.hostUserId, 'u-alice');
assert.equal(party.burnInSubtitleIndex, null, 'no burn-in stream until the host picks one');
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', () => {
@ -28,18 +29,20 @@ describe('PartyService', () => {
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', [
{ itemId: 'ep1', itemName: 'S01E01', itemImageUrl: '/img/ep1' },
{ itemId: 'ep2', itemName: 'S01E02', itemImageUrl: '/img/ep2' }
], 'video');
party.burnInSubtitleIndex = '4';
party.burnInAudioIndex = '2';
party.textSubtitleIndex = '3';
PartyService.advanceQueue(party);
assert.equal(party.burnInSubtitleIndex, null);
assert.equal(party.burnInAudioIndex, null);
assert.equal(party.textSubtitleIndex, null);
});
it('leave(): a non-host leaving neither destroys the party nor hands off', () => {
@ -115,6 +118,15 @@ describe('PartyService', () => {
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', () => {
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'));
});
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")', () => {
const io = fakeIo();
attach(io);