From 5e8ef6aa9b57baa6d83b869a037a4520158ec1d5 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Tue, 17 Mar 2026 09:05:58 -0700 Subject: [PATCH] Adjust VTT cue line to avoid occlusion by video controls (#10017) Co-authored-by: Claude Opus 4.6 --- .../VideoEmbedInner/VideoEmbedInnerWeb.tsx | 51 ++++++++++++++++++- .../web-controls/VideoControls.tsx | 9 ++++ 2 files changed, 58 insertions(+), 2 deletions(-) diff --git a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx index de3c130c26..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} = useHLS({ + const {hlsRef, loop, updateCuePositions} = useHLS({ playlist: embed.playlist, setHasSubtitleTrack, setError, @@ -90,6 +90,7 @@ export function VideoEmbedInnerWeb({ hasSubtitleTrack={hasSubtitleTrack} isGif={embed.presentation === 'gif'} altText={embed.alt} + updateCuePositions={updateCuePositions} /> @@ -145,6 +146,47 @@ function useHLS({ }, [Hls, setHlsLoading]) const hlsRef = useRef(undefined) + 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[] >([]) @@ -220,6 +262,10 @@ function useHLS({ } }) + hls.on(Hls.Events.SUBTITLE_FRAG_PROCESSED, () => { + updateCuePositions() + }) + hls.on(Hls.Events.FRAG_BUFFERED, (_event, {frag}) => { if (frag.level === 0) { setLowQualityFragments(prev => [...prev, frag]) @@ -307,5 +353,6 @@ function useHLS({ return { hlsRef, loop: !hasLowQualityFragmentAtStart, + 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 d6a5628bb4..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,6 +48,7 @@ export function Controls({ hasSubtitleTrack, isGif, altText, + updateCuePositions, }: { videoRef: React.RefObject hlsRef: React.RefObject @@ -61,6 +62,7 @@ export function Controls({ hasSubtitleTrack: boolean isGif: boolean altText?: string + updateCuePositions: (controlsVisible?: boolean) => void }) { const { play, @@ -294,6 +296,13 @@ export function Controls({ ((focused || autoplayDisabled) && !playing) || (interactingViaKeypress ? hasFocus : hovered) + // adjust subtitle cue positioning to avoid occlusion by controls + // uses percentage-based positioning (snapToLines=false) so wrapped + // multi-line cues grow upward instead of extending offscreen + useEffect(() => { + updateCuePositions(showControls) + }, [showControls, updateCuePositions]) + if (isGif) { return (