dynamic subtitle positioning so overlays stay inside the video content area

This commit is contained in:
2026-04-20 11:56:21 +03:00
parent 85455acfa9
commit 4ea7fda3d5
+68
View File
@@ -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<Cue[]>([])
const [activeCues, setActiveCues] = useState<string[]>([])
const [offset, setOffset] = useState<number | null>(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 (
<div
className={`${className} z-[100]`}
style={{
...style,
...posStyle,
transform: 'translateZ(0)',
willChange: 'transform',
backfaceVisibility: 'hidden',