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 {
|
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,70 +145,48 @@ 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
|
<div className="absolute inset-0 z-[100] flex items-center justify-center pointer-events-none">
|
||||||
className={`${className} z-[100]`}
|
<div
|
||||||
style={{
|
className="relative w-full"
|
||||||
...style,
|
style={{
|
||||||
...posStyle,
|
aspectRatio: `${vW} / ${vH}`,
|
||||||
transform: 'translateZ(0)',
|
maxHeight: '100%',
|
||||||
willChange: 'transform',
|
transform: 'translateZ(0)',
|
||||||
backfaceVisibility: 'hidden',
|
willChange: 'transform',
|
||||||
}}
|
backfaceVisibility: 'hidden',
|
||||||
>
|
}}
|
||||||
{activeCues.map((cue, ci) => (
|
>
|
||||||
<div key={ci} className={ci > 0 ? 'mt-1.5' : ''}>
|
<div
|
||||||
<span data-cue className="whitespace-pre-line">
|
className={className}
|
||||||
{cue}
|
style={{
|
||||||
</span>
|
...style,
|
||||||
|
...(subtitlePosition === 'bottom'
|
||||||
|
? { bottom: 32 }
|
||||||
|
: { top: 24 }),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{activeCues.map((cue, ci) => (
|
||||||
|
<div key={ci} className={ci > 0 ? 'mt-1.5' : ''}>
|
||||||
|
<span data-cue className="whitespace-pre-line">
|
||||||
|
{cue}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})()}
|
})()}
|
||||||
|
|||||||
Reference in New Issue
Block a user