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 <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:
parent
f78b2f1408
commit
4efcd67fb3
11 changed files with 433 additions and 79 deletions
24
README.md
24
README.md
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 -
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue