/** * Casting Player Progress Bar * Progress slider with trickplay preview bubble and current/end time display. */ import { Image } from "expo-image"; import type { TFunction } from "i18next"; import { Dimensions, Text, View } from "react-native"; import { Slider } from "react-native-awesome-slider"; import type { RemoteMediaClient } from "react-native-google-cast"; import type { SharedValue } from "react-native-reanimated"; import type { useTrickplay } from "@/hooks/useTrickplay"; import { calculateEndingTime, formatTime, formatTrickplayTime, } from "@/utils/casting/helpers"; import { msToTicks, ticksToSeconds } from "@/utils/time"; type TrickplayReturn = ReturnType; interface CastPlayerProgressBarProps { /** Shared value tracking the slider progress, in milliseconds. */ sliderProgress: SharedValue; /** Shared value for the slider minimum, in milliseconds. */ sliderMin: SharedValue; /** Shared value for the slider maximum, in milliseconds. */ sliderMax: SharedValue; /** Mutable ref flag set true while the user is scrubbing. */ isScrubbing: { current: boolean }; /** Current scrub percentage (0-1), used to position the trickplay bubble. */ scrubPercentage: number; /** Updates the scrub percentage. */ setScrubPercentage: (value: number) => void; /** Trickplay time display state for the bubble. */ trickplayTime: { hours: number; minutes: number; seconds: number }; /** Updates the trickplay time display state. */ setTrickplayTime: (time: { hours: number; minutes: number; seconds: number; }) => void; /** Current trickplay image URL/coordinates, or null. */ trickPlayUrl: TrickplayReturn["trickPlayUrl"]; /** Computes the trickplay URL for a given progress in ticks. */ calculateTrickplayUrl: TrickplayReturn["calculateTrickplayUrl"]; /** Parsed trickplay metadata, or null. */ trickplayInfo: TrickplayReturn["trickplayInfo"]; /** Current playback progress, in seconds. */ progress: number; /** Total media duration, in seconds. */ duration: number; /** Remote media client, or null when no session. */ remoteMediaClient: RemoteMediaClient | null; /** Theme color used for the slider track and bubbles. */ protocolColor: string; /** Translation function. */ t: TFunction; } export function CastPlayerProgressBar({ sliderProgress, sliderMin, sliderMax, isScrubbing, scrubPercentage, setScrubPercentage, trickplayTime, setTrickplayTime, trickPlayUrl, calculateTrickplayUrl, trickplayInfo, progress, duration, remoteMediaClient, protocolColor, t, }: CastPlayerProgressBarProps) { return ( <> {/* Progress slider with trickplay preview */} { isScrubbing.current = true; }} onValueChange={(value) => { // Calculate trickplay preview const progressInTicks = msToTicks(value); calculateTrickplayUrl(progressInTicks); // Update time display for trickplay bubble const progressInSeconds = Math.floor( ticksToSeconds(progressInTicks), ); const hours = Math.floor(progressInSeconds / 3600); const minutes = Math.floor((progressInSeconds % 3600) / 60); const seconds = progressInSeconds % 60; setTrickplayTime({ hours, minutes, seconds }); // Track scrub percentage for bubble positioning const durationMs = duration * 1000; if (durationMs > 0) { setScrubPercentage(value / durationMs); } }} onSlidingComplete={(value) => { isScrubbing.current = false; // Seek to the position (value is in milliseconds, convert to seconds) const positionSeconds = value / 1000; if (remoteMediaClient && duration > 0) { remoteMediaClient .seek({ position: positionSeconds }) .catch((error) => { console.error("[Casting Player] Seek error:", error); }); } }} renderBubble={() => { // Calculate bubble position with edge clamping const screenWidth = Dimensions.get("window").width; const containerPadding = 20; // left/right padding of slider container (matches style) const thumbWidth = 16; // matches thumbWidth prop on Slider const sliderWidth = screenWidth - containerPadding * 2; // Adjust thumb position to account for thumb width affecting travel range const effectiveTrackWidth = sliderWidth - thumbWidth; const thumbPosition = thumbWidth / 2 + scrubPercentage * effectiveTrackWidth; if (!trickPlayUrl || !trickplayInfo) { // Show simple time bubble when no trickplay const timeBubbleWidth = 80; // Clamp position so bubble stays on screen // minLeft prevents going off left edge, maxLeft prevents going off right edge const minLeft = -thumbPosition; const maxLeft = sliderWidth - thumbPosition - timeBubbleWidth; const centeredLeft = -timeBubbleWidth / 2; const clampedLeft = Math.max( minLeft, Math.min(maxLeft, centeredLeft), ); return ( {formatTrickplayTime(trickplayTime)} ); } const { x, y, url } = trickPlayUrl; const tileWidth = 220; // Larger preview for casting player const tileHeight = tileWidth / (trickplayInfo.aspectRatio ?? 1.78); // Calculate clamped position for trickplay preview // minLeft: furthest left (when thumb is at left edge) // maxLeft: furthest right (when thumb is at right edge) const minLeft = -thumbPosition; const maxLeft = sliderWidth - thumbPosition - tileWidth; const centeredLeft = -tileWidth / 2; const clampedLeft = Math.max( minLeft, Math.min(maxLeft, centeredLeft), ); return ( {/* Trickplay image preview */} {/* Time overlay */} {formatTrickplayTime(trickplayTime)} ); }} sliderHeight={6} thumbWidth={16} panHitSlop={{ top: 30, bottom: 30, left: 10, right: 10 }} /> {/* Time display */} {formatTime(progress * 1000)} {t("casting_player.ending_at", { time: calculateEndingTime(progress * 1000, duration * 1000), })} {formatTime(duration * 1000)} ); }