match subtitle overlay aspect ratio to video so it stays inside content area
This commit is contained in:
@@ -28,6 +28,8 @@ interface Cue {
|
||||
interface Props {
|
||||
playerRef: RefObject<MediaPlayerInstance | null>
|
||||
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<Cue[]>([])
|
||||
const [activeCues, setActiveCues] = useState<string[]>([])
|
||||
const [offset, setOffset] = useState<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
cuesRef.current = []
|
||||
@@ -166,62 +145,38 @@ 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 (
|
||||
<div className="absolute inset-0 z-[100] flex items-center justify-center pointer-events-none">
|
||||
<div
|
||||
className={`${className} z-[100]`}
|
||||
className="relative w-full"
|
||||
style={{
|
||||
...style,
|
||||
...posStyle,
|
||||
aspectRatio: `${vW} / ${vH}`,
|
||||
maxHeight: '100%',
|
||||
transform: 'translateZ(0)',
|
||||
willChange: 'transform',
|
||||
backfaceVisibility: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={className}
|
||||
style={{
|
||||
...style,
|
||||
...(subtitlePosition === 'bottom'
|
||||
? { bottom: 32 }
|
||||
: { top: 24 }),
|
||||
}}
|
||||
>
|
||||
{activeCues.map((cue, ci) => (
|
||||
<div key={ci} className={ci > 0 ? 'mt-1.5' : ''}>
|
||||
@@ -231,5 +186,7 @@ export default function SubtitleOverlay({ playerRef, subtitleUrl }: Props) {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 <video>
|
||||
@@ -1183,6 +1186,8 @@ export default function PlayerPage() {
|
||||
<SubtitleOverlay
|
||||
playerRef={playerRef}
|
||||
subtitleUrl={getSubtitleUrl(serverUrl, id, mediaSourceId, subtitleIndex, token)}
|
||||
videoWidth={videoWidth}
|
||||
videoHeight={videoHeight}
|
||||
/>
|
||||
)
|
||||
})()}
|
||||
|
||||
Reference in New Issue
Block a user