import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; import { type RefObject, useEffect, useRef, useState } from "react"; import { MediaPlayerState, type MediaStatus } from "react-native-google-cast"; import { type SharedValue, useSharedValue } from "react-native-reanimated"; import { useTrickplay } from "@/hooks/useTrickplay"; interface TrickplayTime { hours: number; minutes: number; seconds: number; } interface UseCastPlayerProgressParams { /** Raw Chromecast media status, or null when no session. */ mediaStatus: MediaStatus | null; /** Full item fetched from Jellyfin, used to derive trickplay data. */ fetchedItem: BaseItemDto | null; /** Total media duration, in seconds. */ duration: number; } type TrickplayReturn = ReturnType; interface UseCastPlayerProgressResult { /** 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: RefObject; /** Trickplay time display state for the bubble. */ trickplayTime: TrickplayTime; /** Updates the trickplay time display state. */ setTrickplayTime: (time: TrickplayTime) => void; /** Current scrub percentage (0-1), used to position the trickplay bubble. */ scrubPercentage: number; /** Updates the scrub percentage. */ setScrubPercentage: (value: number) => void; /** Current playback progress, in seconds (live-updating). */ progress: number; /** Live-updating playback position, in seconds. */ liveProgress: number; /** Last stable playback position (seconds), for resuming across reloads. */ resumePositionRef: RefObject; /** 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"]; } /** * Progress/slider/trickplay cluster for the casting player. * Owns the slider shared values, scrub state, live-progress interpolation, * resume-position tracking, and trickplay preview. */ export function useCastPlayerProgress({ mediaStatus, fetchedItem, duration, }: UseCastPlayerProgressParams): UseCastPlayerProgressResult { // Shared values for progress slider (must be initialized before any early returns) const sliderProgress = useSharedValue(0); const sliderMin = useSharedValue(0); const sliderMax = useSharedValue(100); const isScrubbing = useRef(false); // Trickplay time display const [trickplayTime, setTrickplayTime] = useState({ hours: 0, minutes: 0, seconds: 0, }); // Track scrub percentage for trickplay bubble positioning const [scrubPercentage, setScrubPercentage] = useState(0); // Live progress tracking - update every second const [liveProgress, setLiveProgress] = useState(0); const lastSyncPositionRef = useRef(0); const lastSyncTimestampRef = useRef(Date.now()); // Last stable playback position (seconds), for resuming across reloads. const resumePositionRef = useRef(0); useEffect(() => { // Sync refs whenever mediaStatus provides a new position if (mediaStatus?.streamPosition !== undefined) { lastSyncPositionRef.current = mediaStatus.streamPosition; lastSyncTimestampRef.current = Date.now(); setLiveProgress(mediaStatus.streamPosition); } // Update every second when playing, deriving from last sync point const interval = setInterval(() => { if ( mediaStatus?.playerState === MediaPlayerState.PLAYING && mediaStatus?.streamPosition !== undefined ) { const elapsed = (Date.now() - lastSyncTimestampRef.current) / 1000; setLiveProgress(lastSyncPositionRef.current + elapsed); } else if (mediaStatus?.streamPosition !== undefined) { // Sync with actual position when paused/buffering setLiveProgress(mediaStatus.streamPosition); } }, 1000); return () => clearInterval(interval); }, [mediaStatus?.playerState, mediaStatus?.streamPosition]); // Track the last stable position so a reload mid-switch resumes correctly. useEffect(() => { const pos = mediaStatus?.streamPosition ?? 0; if (mediaStatus?.playerState === MediaPlayerState.PLAYING && pos > 0) { resumePositionRef.current = pos; } }, [mediaStatus?.streamPosition, mediaStatus?.playerState]); // Derive state from raw Chromecast hooks const progress = liveProgress; // Use live-updating progress // Trickplay for seeking preview - use fetched item with full data const { trickPlayUrl, calculateTrickplayUrl, trickplayInfo } = useTrickplay( fetchedItem ?? null, ); // Update slider max when duration changes useEffect(() => { if (duration > 0) { sliderMax.value = duration * 1000; // Convert to milliseconds } }, [duration, sliderMax]); // Update slider progress when not scrubbing useEffect(() => { if (!isScrubbing.current && progress > 0) { sliderProgress.value = progress * 1000; // Convert to milliseconds } }, [progress, sliderProgress]); return { sliderProgress, sliderMin, sliderMax, isScrubbing, trickplayTime, setTrickplayTime, scrubPercentage, setScrubPercentage, progress, liveProgress, resumePositionRef, trickPlayUrl, calculateTrickplayUrl, trickplayInfo, }; }