diff --git a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx index 645e398478..849241fd75 100644 --- a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx +++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx @@ -1,4 +1,4 @@ -import {useEffect, useId, useRef, useState} from 'react' +import {useCallback, useEffect, useId, useRef, useState} from 'react' import {View} from 'react-native' import {type AppBskyEmbedVideo} from '@atproto/api' import {msg} from '@lingui/core/macro' @@ -37,7 +37,7 @@ export function VideoEmbedInnerWeb({ throw error } - const {hlsRef, loop, cueLineRef} = useHLS({ + const {hlsRef, loop, updateCuePositions} = useHLS({ playlist: embed.playlist, setHasSubtitleTrack, setError, @@ -90,7 +90,7 @@ export function VideoEmbedInnerWeb({ hasSubtitleTrack={hasSubtitleTrack} isGif={embed.presentation === 'gif'} altText={embed.alt} - cueLineRef={cueLineRef} + updateCuePositions={updateCuePositions} /> @@ -146,7 +146,47 @@ function useHLS({ }, [Hls, setHlsLoading]) const hlsRef = useRef(undefined) - const cueLineRef = useRef(-2) + const controlsVisibleRef = useRef(false) + + /** + * Repositions VTT subtitle cues using percentage-based line values + * (snapToLines=false) so that multi-line/wrapped cues grow upward + * instead of extending offscreen. Moves cues higher when controls + * are visible to avoid occlusion by the scrub bar. + * + * Called from two sites: + * - SUBTITLE_FRAG_PROCESSED: applies positioning to newly loaded cues + * - VideoControls effect: updates positioning when controls show/hide + */ + const updateCuePositions = useCallback( + (controlsVisible?: boolean) => { + if (controlsVisible != null) { + // save controlsVisible state so that when it's called from SUBTITLE_FRAG_PROCESSED, + // the most recent value is used (as we won't know the control state there) + controlsVisibleRef.current = controlsVisible + } + // magic numbers: cue position, % from top of video + const line = controlsVisibleRef.current ? 70 : 85 + const video = videoRef.current + if (!video) return + for (let i = 0; i < video.textTracks.length; i++) { + const track = video.textTracks[i] + if (track.cues) { + for (let j = 0; j < track.cues.length; j++) { + const cue = track.cues[j] as VTTCue + cue.snapToLines = false + cue.line = line + } + } + // toggle track mode to force the browser to re-render active cues + if (track.mode === 'showing') { + track.mode = 'hidden' + track.mode = 'showing' + } + } + }, + [videoRef], + ) const [lowQualityFragments, setLowQualityFragments] = useState< HlsTypes.Fragment[] >([]) @@ -222,12 +262,8 @@ function useHLS({ } }) - hls.on(Hls.Events.CUES_PARSED, (_event, data) => { - if (Array.isArray(data.cues)) { - for (const cue of data.cues) { - cue.line = cueLineRef.current - } - } + hls.on(Hls.Events.SUBTITLE_FRAG_PROCESSED, () => { + updateCuePositions() }) hls.on(Hls.Events.FRAG_BUFFERED, (_event, {frag}) => { @@ -317,6 +353,6 @@ function useHLS({ return { hlsRef, loop: !hasLowQualityFragmentAtStart, - cueLineRef, + updateCuePositions, } } diff --git a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx index 0b5add305d..2e5822589e 100644 --- a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx +++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx @@ -48,7 +48,7 @@ export function Controls({ hasSubtitleTrack, isGif, altText, - cueLineRef, + updateCuePositions, }: { videoRef: React.RefObject hlsRef: React.RefObject @@ -62,7 +62,7 @@ export function Controls({ hasSubtitleTrack: boolean isGif: boolean altText?: string - cueLineRef: React.RefObject + updateCuePositions: (controlsVisible?: boolean) => void }) { const { play, @@ -297,25 +297,11 @@ export function Controls({ (interactingViaKeypress ? hasFocus : hovered) // adjust subtitle cue positioning to avoid occlusion by controls - const cueLine = showControls ? -4 : -2 + // uses percentage-based positioning (snapToLines=false) so wrapped + // multi-line cues grow upward instead of extending offscreen useEffect(() => { - cueLineRef.current = cueLine - const video = videoRef.current - if (!video) return - for (let i = 0; i < video.textTracks.length; i++) { - const track = video.textTracks[i] - if (track.cues) { - for (let j = 0; j < track.cues.length; j++) { - ;(track.cues[j] as VTTCue).line = cueLine - } - } - // toggle track mode to force the browser to re-render active cues - if (track.mode === 'showing') { - track.mode = 'hidden' - track.mode = 'showing' - } - } - }, [cueLine, videoRef, cueLineRef]) + updateCuePositions(showControls) + }, [showControls, updateCuePositions]) if (isGif) { return (