diff --git a/src/components/player/SubtitleOverlay.tsx b/src/components/player/SubtitleOverlay.tsx index 49422e1..2fbc49a 100644 --- a/src/components/player/SubtitleOverlay.tsx +++ b/src/components/player/SubtitleOverlay.tsx @@ -28,6 +28,8 @@ interface Cue { interface Props { playerRef: RefObject subtitleUrl: string | null + videoWidth?: number + videoHeight?: number } function parseTimeStamp(h: string | undefined, m: string, s: string, ms: string): number { @@ -74,34 +76,11 @@ function parseVTT(raw: string): Cue[] { return cues } -function getVideoFitOffsets(video: HTMLVideoElement): { top: number; bottom: number } { - const rect = video.getBoundingClientRect() - const vW = video.videoWidth || 1920 - const vH = video.videoHeight || 1080 - const videoAspect = vW / vH - const containerAspect = rect.width / rect.height - - let displayHeight: number - if (containerAspect > videoAspect) { - displayHeight = rect.width / videoAspect - } else { - displayHeight = rect.height - } - - const displayTop = rect.top + (rect.height - displayHeight) / 2 - const displayBottom = displayTop + displayHeight - return { - top: displayTop, - bottom: window.innerHeight - displayBottom, - } -} - -export default function SubtitleOverlay({ playerRef, subtitleUrl }: Props) { +export default function SubtitleOverlay({ playerRef, subtitleUrl, videoWidth, videoHeight }: Props) { const { className, style } = useSubtitleStyles() const subtitlePosition = usePreferencesStore(s => s.subtitlePosition) const cuesRef = useRef([]) const [activeCues, setActiveCues] = useState([]) - const [offset, setOffset] = useState(null) useEffect(() => { cuesRef.current = [] @@ -166,70 +145,48 @@ export default function SubtitleOverlay({ playerRef, subtitleUrl }: Props) { } }, [playerRef, subtitleUrl]) - // Keep subtitles inside the actual video content area (not the black - // letterbox bars). In portrait mode object-fit:contain leaves huge black - // bars and our old fixed bottom-32 landed deep inside them where Chrome's - // video compositor layer clips short DOM overlays. - useEffect(() => { - const player = playerRef.current - const el = (player as any)?.el as HTMLElement | undefined - const video = el?.querySelector('video') as HTMLVideoElement | null - if (!video) return - - function update() { - if (!video) return - const { top: fitTop, bottom: fitBottom } = getVideoFitOffsets(video) - if (subtitlePosition === 'bottom') { - setOffset(Math.max(128, fitBottom + 32)) - } else { - setOffset(Math.max(96, fitTop + 32)) - } - } - - update() - - const ro = new ResizeObserver(update) - ro.observe(video) - window.addEventListener('resize', update) - - // Also update once metadata is known (videoWidth/videoHeight may be 0 - // before loadedmetadata fires). - video.addEventListener('loadedmetadata', update) - - return () => { - ro.disconnect() - window.removeEventListener('resize', update) - video.removeEventListener('loadedmetadata', update) - } - }, [playerRef, subtitlePosition]) - if (activeCues.length === 0) return null - const posStyle: React.CSSProperties = - offset != null - ? subtitlePosition === 'bottom' - ? { bottom: offset } - : { top: offset } - : {} + // Build a centred box with the video's intrinsic aspect ratio. When the + // viewport and video aspects differ (portrait monitor playing 16:9, + // landscape playing 9:16, etc.) object-fit:contain letterboxes the video. + // A fixed bottom-32 / top-24 then lands inside the black bars and Chrome's + // compositor clips short overlays there. By matching the video aspect in + // a flex-centred container we position the text relative to the *content* + // area instead of the viewport. + const vW = videoWidth || 1920 + const vH = videoHeight || 1080 return ( -
- {activeCues.map((cue, ci) => ( -
0 ? 'mt-1.5' : ''}> - - {cue} - +
+
+
+ {activeCues.map((cue, ci) => ( +
0 ? 'mt-1.5' : ''}> + + {cue} + +
+ ))}
- ))} +
) } diff --git a/src/pages/PlayerPage.tsx b/src/pages/PlayerPage.tsx index 897c51e..d80edda 100644 --- a/src/pages/PlayerPage.tsx +++ b/src/pages/PlayerPage.tsx @@ -970,6 +970,9 @@ export default function PlayerPage() { /* Track lists from Jellyfin metadata */ const audioTracks = item ? getAudioStreams(item) : [] + const videoStream = ((item as any)?.MediaSources?.[0]?.MediaStreams || []).find((s: any) => s.Type === 'Video') + const videoWidth = videoStream?.Width || 1920 + const videoHeight = videoStream?.Height || 1080 /** * Pick an audio track. For direct-play sources whose underlying