diff --git a/README.md b/README.md index 1d31258..f972b37 100644 --- a/README.md +++ b/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 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 diff --git a/client/src/api/party.ts b/client/src/api/party.ts index edcc874..40c84fb 100644 --- a/client/src/api/party.ts +++ b/client/src/api/party.ts @@ -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(path: string, options: RequestInit = {}): Promise { 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 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 (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 (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 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 = '' + lastPlaybackOptions.subtitleTracks.map((t) => { if (t.isTextBased === false) { - const suffix = isHost ? ' (incrustation - redémarre la vidéo pour tous)' : ' (incrustation, hôte uniquement)'; - return ``; + return ``; } return ``; }).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 - 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 + * 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