dynamic subtitle positioning so overlays stay inside the video content area
This commit is contained in:
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user