match subtitle overlay aspect ratio to video so it stays inside content area

This commit is contained in:
2026-04-23 07:05:32 +03:00
parent fb18e169fd
commit 9027dab1a1
2 changed files with 45 additions and 83 deletions
+27 -70
View File
@@ -28,6 +28,8 @@ interface Cue {
interface Props { interface Props {
playerRef: RefObject<MediaPlayerInstance | null> playerRef: RefObject<MediaPlayerInstance | null>
subtitleUrl: string | null subtitleUrl: string | null
videoWidth?: number
videoHeight?: number
} }
function parseTimeStamp(h: string | undefined, m: string, s: string, ms: string): number { function parseTimeStamp(h: string | undefined, m: string, s: string, ms: string): number {
@@ -74,34 +76,11 @@ function parseVTT(raw: string): Cue[] {
return cues return cues
} }
function getVideoFitOffsets(video: HTMLVideoElement): { top: number; bottom: number } { export default function SubtitleOverlay({ playerRef, subtitleUrl, videoWidth, videoHeight }: Props) {
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 { className, style } = useSubtitleStyles()
const subtitlePosition = usePreferencesStore(s => s.subtitlePosition) const subtitlePosition = usePreferencesStore(s => s.subtitlePosition)
const cuesRef = useRef<Cue[]>([]) const cuesRef = useRef<Cue[]>([])
const [activeCues, setActiveCues] = useState<string[]>([]) const [activeCues, setActiveCues] = useState<string[]>([])
const [offset, setOffset] = useState<number | null>(null)
useEffect(() => { useEffect(() => {
cuesRef.current = [] cuesRef.current = []
@@ -166,62 +145,38 @@ export default function SubtitleOverlay({ playerRef, subtitleUrl }: Props) {
} }
}, [playerRef, subtitleUrl]) }, [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 if (activeCues.length === 0) return null
const posStyle: React.CSSProperties = // Build a centred box with the video's intrinsic aspect ratio. When the
offset != null // viewport and video aspects differ (portrait monitor playing 16:9,
? subtitlePosition === 'bottom' // landscape playing 9:16, etc.) object-fit:contain letterboxes the video.
? { bottom: offset } // A fixed bottom-32 / top-24 then lands inside the black bars and Chrome's
: { top: offset } // 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 ( return (
<div className="absolute inset-0 z-[100] flex items-center justify-center pointer-events-none">
<div <div
className={`${className} z-[100]`} className="relative w-full"
style={{ style={{
...style, aspectRatio: `${vW} / ${vH}`,
...posStyle, maxHeight: '100%',
transform: 'translateZ(0)', transform: 'translateZ(0)',
willChange: 'transform', willChange: 'transform',
backfaceVisibility: 'hidden', backfaceVisibility: 'hidden',
}} }}
>
<div
className={className}
style={{
...style,
...(subtitlePosition === 'bottom'
? { bottom: 32 }
: { top: 24 }),
}}
> >
{activeCues.map((cue, ci) => ( {activeCues.map((cue, ci) => (
<div key={ci} className={ci > 0 ? 'mt-1.5' : ''}> <div key={ci} className={ci > 0 ? 'mt-1.5' : ''}>
@@ -231,5 +186,7 @@ export default function SubtitleOverlay({ playerRef, subtitleUrl }: Props) {
</div> </div>
))} ))}
</div> </div>
</div>
</div>
) )
} }
+5
View File
@@ -970,6 +970,9 @@ export default function PlayerPage() {
/* Track lists from Jellyfin metadata */ /* Track lists from Jellyfin metadata */
const audioTracks = item ? getAudioStreams(item) : [] 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 <video> * Pick an audio track. For direct-play sources whose underlying <video>
@@ -1183,6 +1186,8 @@ export default function PlayerPage() {
<SubtitleOverlay <SubtitleOverlay
playerRef={playerRef} playerRef={playerRef}
subtitleUrl={getSubtitleUrl(serverUrl, id, mediaSourceId, subtitleIndex, token)} subtitleUrl={getSubtitleUrl(serverUrl, id, mediaSourceId, subtitleIndex, token)}
videoWidth={videoWidth}
videoHeight={videoHeight}
/> />
) )
})()} })()}