From 4efcd67fb330e91eed879100988ac1cd6a8a669b Mon Sep 17 00:00:00 2001 From: Nicolas Date: Mon, 14 Sep 2026 20:41:02 +0200 Subject: [PATCH] Sync text subtitle choice too, and fix an empty audio option 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 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 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 . Not for a text - * subtitle - that stays the existing per-viewer client-side , - * 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 , 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 }); }, diff --git a/client/src/types.ts b/client/src/types.ts index 828e8df..a214c2c 100644 --- a/client/src/types.ts +++ b/client/src/types.ts @@ -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 diff --git a/client/src/views/player/Player.ts b/client/src/views/player/Player.ts index bf66b64..b969e4e 100644 --- a/client/src/views/player/Player.ts +++ b/client/src/views/player/Player.ts @@ -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 + // (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