diff --git a/src/components/player/SubtitleOverlay.tsx b/src/components/player/SubtitleOverlay.tsx index 87618f5..9eda558 100644 --- a/src/components/player/SubtitleOverlay.tsx +++ b/src/components/player/SubtitleOverlay.tsx @@ -74,10 +74,34 @@ 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) { 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 = [] @@ -142,13 +166,57 @@ 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() { + 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 } + : {} + return (