diff --git a/client/src/api/embyApi.ts b/client/src/api/embyApi.ts index 624961f..cbbc1fb 100644 --- a/client/src/api/embyApi.ts +++ b/client/src/api/embyApi.ts @@ -107,7 +107,9 @@ export function getPlaybackOptions(itemId: string): Promise return apiFetch(`/api/catalog/item/${itemId}/playback-options`); } -/** `options` may carry AudioStreamIndex/SubtitleStreamIndex/MaxStreamingBitrate/mediaType. */ +/** `options` may carry AudioStreamIndex/MaxStreamingBitrate/mediaType - not + * subtitles, which are their own separate request now (see subtitleUrl + * below and Player.ts's applySubtitleTrack for why). */ export function streamUrl(itemId: string, options: Record = {}): string { const entries = Object.entries(options).filter(([, v]) => v !== '' && v != null) as [string, string][]; const qs = new URLSearchParams(entries).toString(); @@ -121,3 +123,12 @@ export function streamUrl(itemId: string, options: Record, not part of the video + * stream at all. Only ever built for a track getPlaybackOptions already + * reported as isTextBased - see Player.ts's subtitle population. - supportsBurnIn?: boolean; + // typically). The player renders subtitles as a native (WebVTT, + // converted server-side - see api/embyApi.ts's subtitleUrl and + // Player.ts's applySubtitleTrack), which needs actual text; a bitmap + // has none to extract. + isTextBased?: boolean; } export interface PlaybackOptionsDto { diff --git a/client/src/views/player/Player.ts b/client/src/views/player/Player.ts index d5500ea..7963c4c 100644 --- a/client/src/views/player/Player.ts +++ b/client/src/views/player/Player.ts @@ -1,7 +1,7 @@ import '../../styles/presence.css'; import './Player.css'; import html from './Player.html'; -import { auth, getPlaybackOptions, streamUrl } from '../../api/embyApi'; +import { auth, getPlaybackOptions, streamUrl, subtitleUrl } from '../../api/embyApi'; import { getParty, leaveParty, connectParty } from '../../api/party'; import type { PartyConnection } from '../../api/party'; import { navigate } from '../../router'; @@ -17,17 +17,18 @@ const PAUSE_REQUEST_HIGHLIGHT_MS = 4000; // how long the banner + the orange bad // hls.js's own defaults (10s manifest/level/frag load timeouts) assume a // file server or a source already being direct-played/remuxed - too tight -// for this app, where every request chains through our own proxy into a -// FRESH Emby transcode (see CatalogService.proxyStream/applyPlaybackOverrides). -// Confirmed for real: picking a subtitle track forces SubtitleMethod=Encode -// (a real re-encode, to actually burn the subtitle in - see -// applyPlaybackOverrides's own comment), which measurably took Emby longer -// than 10s to produce a first segment for, and hls.js gave up with a fatal -// fragLoadTimeOut after its default 4 retries - not a hang, a real but slow -// transcode start being mistaken for a dead server. Same config duplicated -// in musicBar/MusicBar.ts (its own HLS_CONFIG) rather than shared - the two -// players' hls.js setup is already independently duplicated for the same -// reason (see that file's own loadTrack). +// for this app, where every load/reload (audio track, quality, the +// initial load itself) chains through our own proxy into a FRESH Emby +// transcode (see CatalogService.proxyStream). Confirmed for real: a +// forced re-encode (this used to be how subtitles worked too, before they +// moved to a native - see proxyStream's own comment for why) +// measurably took Emby longer than 10s to produce a first segment for, +// and hls.js gave up with a fatal fragLoadTimeOut after its default 4 +// retries - not a hang, a real but slow transcode start being mistaken +// for a dead server. Same config duplicated in musicBar/MusicBar.ts (its +// own HLS_CONFIG) rather than shared - the two players' hls.js setup is +// already independently duplicated for the same reason (see that file's +// own loadTrack). const HLS_CONFIG: HlsConfig = { manifestLoadingTimeOut: 30000, manifestLoadingMaxRetry: 2, @@ -230,23 +231,52 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli audioSelect.innerHTML = options.audioTracks.length ? options.audioTracks.map((t) => ``).join('') : ''; - // supportsBurnIn: false means this is an image/bitmap subtitle (PGS/ - // VobSub/DVB, typically a Blu-ray/DVD rip) - this player forces a - // real burn-in (SubtitleMethod=Encode, see CatalogService) whenever - // a subtitle is picked, which only ever works for text subtitles. + // isTextBased: false means this is an image/bitmap subtitle (PGS/ + // VobSub/DVB, typically a Blu-ray/DVD rip) - rendered as a native + // (see applySubtitleTrack), which needs real text; a + // bitmap has none for Emby to convert to WebVTT (proxySubtitle). // Disabled rather than hidden, so it's clear the track exists and // *why* it can't be picked, instead of silently missing from the - // list - confirmed against a real server that picking one of these - // doesn't just fail to show, it breaks playback outright (see - // docs/architecture.md's player-track-selection section). + // list. subtitleSelect.innerHTML = '' + - options.subtitleTracks.map((t) => ``).join(''); + options.subtitleTracks.map((t) => ``).join(''); } catch (err) { console.error('[watchparty player] playback-options failed', err); // Non-fatal: playback can still work with Emby's own defaults, just without a picker. } } + /** + * Adds/replaces the video's own element for whichever subtitle + * (if any) is currently selected - a native browser feature, entirely + * separate from the video stream itself (see CatalogService. + * proxySubtitle for why: a server-side burn-in used to be how this + * worked, and a long real-server debugging session found it unreliable + * in ways this app's own request could never have controlled or + * worked around - see docs/architecture.md's player-track-selection + * section). Removes and re-adds a fresh every time rather than + * changing an existing one's `src` in place - browsers don't reliably + * re-fetch/re-parse a track from a plain attribute change after it's + * already loaded, and a fresh element always does. + */ + function applySubtitleTrack(subtitleIndex: string) { + video.querySelectorAll('track').forEach((t) => t.remove()); + if (!subtitleIndex || !currentItemId) return; + const track = document.createElement('track'); + track.kind = 'subtitles'; + track.src = subtitleUrl(currentItemId, Number(subtitleIndex)); + video.appendChild(track); + // A freshly added track defaults to "hidden", not "showing". Set + // synchronously, right here - the browser registers the TextTrack in + // video.textTracks the moment the element is connected, before its + // actual subtitle content has loaded, and honors `mode` regardless. + // Deliberately NOT waiting on the 's own "load" event for + // this: confirmed against a real browser that it doesn't fire + // reliably at all (a known cross-browser quirk, not specific + // to this app), which would have left the track silently never shown. + if (video.textTracks[0]) video.textTracks[0].mode = 'showing'; + } + function loadVideo(itemId: string, resumeSeconds: number, wasPlaying: boolean) { currentItemId = itemId; clearError(); @@ -257,9 +287,18 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli hls = null; } + // Re-applied on every (re)load, not just when the subtitle picker + // itself changes: an audio/quality change reloads this same item + // through here too (see reloadWithCurrentOptions), 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. + applySubtitleTrack(subtitleSelect.value); + const src = streamUrl(itemId, { AudioStreamIndex: audioSelect.value, - SubtitleStreamIndex: subtitleSelect.value, MaxStreamingBitrate: qualitySelect.value }); @@ -294,20 +333,29 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli } } - // Changing language/subtitles/quality means a fresh transcode (Emby bakes - // the selection in server-side - see CatalogService) - reload at the - // same position instead of restarting from zero. + // Changing language/quality means a fresh transcode (Emby bakes the + // selection in server-side - see CatalogService) - reload at the same + // position instead of restarting from zero. Subtitles are NOT part of + // this - see the subtitleSelect listener below, and applySubtitleTrack. function reloadWithCurrentOptions() { if (currentItemId) loadVideo(currentItemId, video.currentTime, !video.paused); } - [audioSelect, subtitleSelect, qualitySelect].forEach((sel) => { + [audioSelect, qualitySelect].forEach((sel) => { sel.addEventListener('change', () => { settingsPanel.classList.add('hide'); reloadWithCurrentOptions(); }); }); + // Just swaps the - no reload, no re-buffering, unlike audio/ + // quality above. This is the whole point of rendering subtitles + // natively instead of asking Emby to burn them into the video. + subtitleSelect.addEventListener('change', () => { + settingsPanel.classList.add('hide'); + applySubtitleTrack(subtitleSelect.value); + }); + (root.querySelector('#playerErrorRetry') as HTMLButtonElement).addEventListener('click', () => { if (currentItemId) loadVideo(currentItemId, video.currentTime, true); }); diff --git a/docs/architecture.md b/docs/architecture.md index d1a0191..c47c3f2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -605,6 +605,67 @@ The next 500 on this or any future item gets its real cause into `docker logs` directly, without another round of trying to hand-copy it out of a browser or Emby's own log viewer. +### The actual fix: subtitles rendered client-side, not burned in by Emby + +Six rounds in, the pattern across every failure was the same shape: this +app's own request was never the problem (confirmed each time - the +override reached Emby correctly, `getPlaybackOptions` already correctly +told PGS apart from real text), but *something* about asking Emby to +burn a subtitle into the video itself kept finding a new way to fail - +missing fonts, a format `libass` fundamentally can't burn (a bitmap, not +text), then "Error starting ffmpeg" outright on a *third*, otherwise +unremarkable file with neither of those problems. Three failures, three +different causes, all downstream of the one thing every attempt had in +common: asking for a real-time video re-encode. Fixing this by finding +and patching every way ffmpeg could fail was an open-ended list; not +needing ffmpeg to do this at all closes the whole class at once. + +Subtitles are now delivered as their own resource and rendered by the +browser's native `` element, not encoded into the video: + +- `CatalogService.proxySubtitle` (new) asks Emby to convert the track to + WebVTT - `GET /Videos/{itemId}/{mediaSourceId}/Subtitles/{index}/ + Stream.vtt`, a real Emby endpoint their own web client already relies + on for exactly this, entirely separate from the transcode pipeline. + Needs its own `PlaybackInfo` round trip just for `MediaSourceId` (the + video's own negotiation doesn't share this with a ``'s + independent request). `applyPlaybackOverrides` lost its + `SubtitleStreamIndex`/`SubtitleMethod=Encode` branch entirely - nothing + about the video request needs to know a subtitle was even picked + anymore. +- `getPlaybackOptions`'s per-track flag was renamed `supportsBurnIn` → + `isTextBased` - same underlying fact (image vs. text codec, + `IMAGE_SUBTITLE_CODECS`), but the name shouldn't keep describing a + mechanism (burning in) this app no longer uses. A bitmap subtitle still + has no text for Emby to hand back as WebVTT either, so it's still + correctly disabled in the picker - the *reason* just changed. +- `views/player/Player.ts`'s subtitle `