From d07a521f60e9f8d075c25f0fe6908f28899554b7 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Fri, 23 Jan 2026 21:23:00 +0100 Subject: [PATCH] feat(tv): add trickplay bubble positioning aligned with progress bar --- .../video-player/controls/Controls.tv.tsx | 152 +++++++++++++++--- .../video-player/controls/TrickplayBubble.tsx | 33 ++-- 2 files changed, 151 insertions(+), 34 deletions(-) diff --git a/components/video-player/controls/Controls.tv.tsx b/components/video-player/controls/Controls.tv.tsx index 01f4ad81..55985978 100644 --- a/components/video-player/controls/Controls.tv.tsx +++ b/components/video-player/controls/Controls.tv.tsx @@ -13,7 +13,12 @@ import { useState, } from "react"; import { useTranslation } from "react-i18next"; -import { StyleSheet, TVFocusGuideView, View } from "react-native"; +import { + StyleSheet, + TVFocusGuideView, + useWindowDimensions, + View, +} from "react-native"; import Animated, { Easing, type SharedValue, @@ -82,6 +87,96 @@ interface Props { const TV_SEEKBAR_HEIGHT = 14; const TV_AUTO_HIDE_TIMEOUT = 5000; +// Trickplay bubble positioning constants +const TV_TRICKPLAY_SCALE = 2; +const TV_TRICKPLAY_BUBBLE_BASE_WIDTH = CONTROLS_CONSTANTS.TILE_WIDTH * 1.5; +const TV_TRICKPLAY_BUBBLE_WIDTH = + TV_TRICKPLAY_BUBBLE_BASE_WIDTH * TV_TRICKPLAY_SCALE; +const TV_TRICKPLAY_INTERNAL_OFFSET = 62 * TV_TRICKPLAY_SCALE; +const TV_TRICKPLAY_CENTERING_OFFSET = 98 * TV_TRICKPLAY_SCALE; +const TV_TRICKPLAY_RIGHT_PADDING = 150; +const TV_TRICKPLAY_FADE_DURATION = 200; + +interface TVTrickplayBubbleProps { + trickPlayUrl: { + x: number; + y: number; + url: string; + } | null; + trickplayInfo: { + aspectRatio?: number; + data: { + TileWidth?: number; + TileHeight?: number; + }; + } | null; + time: { + hours: number; + minutes: number; + seconds: number; + }; + progress: SharedValue; + max: SharedValue; + progressBarWidth: number; + visible: boolean; +} + +const TVTrickplayBubblePositioned: FC = ({ + trickPlayUrl, + trickplayInfo, + time, + progress, + max, + progressBarWidth, + visible, +}) => { + const opacity = useSharedValue(0); + + useEffect(() => { + opacity.value = withTiming(visible ? 1 : 0, { + duration: TV_TRICKPLAY_FADE_DURATION, + easing: Easing.out(Easing.quad), + }); + }, [visible, opacity]); + + const minX = TV_TRICKPLAY_INTERNAL_OFFSET; + const maxX = + progressBarWidth - + TV_TRICKPLAY_BUBBLE_WIDTH + + TV_TRICKPLAY_INTERNAL_OFFSET + + TV_TRICKPLAY_RIGHT_PADDING; + + const animatedStyle = useAnimatedStyle(() => { + const progressPercent = max.value > 0 ? progress.value / max.value : 0; + + const xPosition = Math.max( + minX, + Math.min( + maxX, + progressPercent * progressBarWidth - + TV_TRICKPLAY_BUBBLE_WIDTH / 2 + + TV_TRICKPLAY_CENTERING_OFFSET, + ), + ); + + return { + transform: [{ translateX: xPosition }], + opacity: opacity.value, + }; + }); + + return ( + + + + ); +}; + export const Controls: FC = ({ item, seek, @@ -112,7 +207,15 @@ export const Controls: FC = ({ transcodeReasons, }) => { const insets = useSafeAreaInsets(); + const { width: screenWidth } = useWindowDimensions(); const { t } = useTranslation(); + + // Calculate progress bar width (matches the padding used in bottomInner) + const progressBarWidth = useMemo(() => { + const leftPadding = Math.max(insets.left, 48); + const rightPadding = Math.max(insets.right, 48); + return screenWidth - leftPadding - rightPadding; + }, [screenWidth, insets.left, insets.right]); const api = useAtomValue(apiAtom); const { settings } = useSettings(); const router = useRouter(); @@ -831,15 +934,17 @@ export const Controls: FC = ({ }, ]} > - {showSeekBubble && ( - - - - )} + + + {/* Same padding as TVFocusableProgressBar for alignment */} @@ -963,15 +1068,17 @@ export const Controls: FC = ({ )} - {showSeekBubble && ( - - - - )} + + + {/* Bidirectional focus guides - stacked together per docs */} {/* Downward: play button → progress bar */} @@ -1063,12 +1170,15 @@ const styles = StyleSheet.create({ }, trickplayBubbleContainer: { position: "absolute", - bottom: 120, + bottom: 170, left: 0, right: 0, - alignItems: "center", zIndex: 20, }, + trickplayBubblePositioned: { + position: "absolute", + bottom: 0, + }, focusGuide: { height: 1, width: "100%", diff --git a/components/video-player/controls/TrickplayBubble.tsx b/components/video-player/controls/TrickplayBubble.tsx index 49645ed2..416bb92c 100644 --- a/components/video-player/controls/TrickplayBubble.tsx +++ b/components/video-player/controls/TrickplayBubble.tsx @@ -4,6 +4,10 @@ import { View } from "react-native"; import { Text } from "@/components/common/Text"; import { CONTROLS_CONSTANTS } from "./constants"; +const BASE_IMAGE_SCALE = 1.4; +const BUBBLE_LEFT_OFFSET = 62; +const BUBBLE_WIDTH_MULTIPLIER = 1.5; + interface TrickplayBubbleProps { trickPlayUrl: { x: number; @@ -22,12 +26,21 @@ interface TrickplayBubbleProps { minutes: number; seconds: number; }; + /** Scale factor for the image (default 1). Does not affect timestamp text. */ + imageScale?: number; +} + +function formatTime(hours: number, minutes: number, seconds: number): string { + const pad = (n: number) => (n < 10 ? `0${n}` : `${n}`); + const prefix = hours > 0 ? `${hours}:` : ""; + return `${prefix}${pad(minutes)}:${pad(seconds)}`; } export const TrickplayBubble: FC = ({ trickPlayUrl, trickplayInfo, time, + imageScale = 1, }) => { if (!trickPlayUrl || !trickplayInfo) { return null; @@ -36,16 +49,17 @@ export const TrickplayBubble: FC = ({ const { x, y, url } = trickPlayUrl; const tileWidth = CONTROLS_CONSTANTS.TILE_WIDTH; const tileHeight = tileWidth / trickplayInfo.aspectRatio!; + const finalScale = BASE_IMAGE_SCALE * imageScale; return ( = ({ width: tileWidth, height: tileHeight, alignSelf: "center", - transform: [{ scale: 1.4 }], + transform: [{ scale: finalScale }], borderRadius: 5, }} className='bg-neutral-800 overflow-hidden' > = ({ contentFit='cover' /> - - {`${time.hours > 0 ? `${time.hours}:` : ""}${ - time.minutes < 10 ? `0${time.minutes}` : time.minutes - }:${time.seconds < 10 ? `0${time.seconds}` : time.seconds}`} + + {formatTime(time.hours, time.minutes, time.seconds)} );