import { Ionicons } from "@expo/vector-icons"; import type { Api } from "@jellyfin/sdk"; import type { BaseItemDto, ChapterInfo, } from "@jellyfin/sdk/lib/generated-client"; import { type FC, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Pressable, View } from "react-native"; import { Slider } from "react-native-awesome-slider"; import { type SharedValue } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { ChapterList } from "@/components/chapters/ChapterList"; import { ChapterTicks } from "@/components/chapters/ChapterTicks"; import { Text } from "@/components/common/Text"; import { AutoplayCountdown } from "@/components/player/AutoplayCountdown"; import { useSettings } from "@/utils/atoms/settings"; import { chapterMarkers } from "@/utils/chapters"; import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; import SkipButton from "./SkipButton"; import { TimeDisplay } from "./TimeDisplay"; import { TrickplayBubble } from "./TrickplayBubble"; interface BottomControlsProps { item: BaseItemDto; /** Item chapters, used for the tick overlay and chapter list. */ chapters?: ChapterInfo[] | null; /** Total media duration in milliseconds. */ durationMs: number; showControls: boolean; isSliding: boolean; showRemoteBubble: boolean; currentTime: number; remainingTime: number; showSkipButton: boolean; skipButtonText: string; showSkipCreditButton: boolean; skipCreditButtonText: string; hasContentAfterCredits: boolean; skipIntro: () => void; skipCredit: () => void; nextItem?: BaseItemDto | null; api?: Api | null; handleNextEpisodeAutoPlay: () => void; handleNextEpisodeManual: () => void; handleControlsInteraction: () => void; // Slider props min: SharedValue; max: SharedValue; effectiveProgress: SharedValue; cacheProgress: SharedValue; handleSliderStart: () => void; handleSliderComplete: (value: number) => void; handleSliderChange: (value: number) => void; handleTouchStart: () => void; handleTouchEnd: () => void; // Trickplay props trickPlayUrl: { x: number; y: number; url: string; } | null; trickplayInfo: { aspectRatio?: number; data: { TileWidth?: number; TileHeight?: number; }; } | null; time: { hours: number; minutes: number; seconds: number; }; } export const BottomControls: FC = ({ item, chapters, durationMs, showControls, isSliding, showRemoteBubble, currentTime, remainingTime, showSkipButton, skipButtonText, showSkipCreditButton, skipCreditButtonText, hasContentAfterCredits, skipIntro, skipCredit, nextItem, api, handleNextEpisodeAutoPlay, handleNextEpisodeManual, handleControlsInteraction, min, max, effectiveProgress, cacheProgress, handleSliderStart, handleSliderComplete, handleSliderChange, handleTouchStart, handleTouchEnd, trickPlayUrl, trickplayInfo, time, }) => { const { settings } = useSettings(); const { t } = useTranslation(); const insets = useSafeAreaInsets(); const [chapterListVisible, setChapterListVisible] = useState(false); // Only expose chapter UI when there are at least two real markers. const chapterMarkerList = useMemo( () => chapterMarkers(chapters, durationMs), [chapters, durationMs], ); const hasChapters = chapterMarkerList.length > 1; // Autoplay overlay: shown under the same condition the old countdown button used. const autoplayAllowed = settings.autoPlayNextEpisode !== false && (settings.maxAutoPlayEpisodeCount.value === -1 || settings.autoPlayEpisodeCount < settings.maxAutoPlayEpisodeCount.value); const showNextEpisodeCountdown = autoplayAllowed && (!nextItem ? false : // Show during credits if no content after, OR near end of video (showSkipCreditButton && !hasContentAfterCredits) || remainingTime < 10000); const [secondsRemaining, setSecondsRemaining] = useState( settings.autoplayCountdownSeconds, ); const [autoplayCancelled, setAutoplayCancelled] = useState(false); const intervalRef = useRef | null>(null); // Keep a stable ref to the autoplay handler so the timer effect does not // restart when the handler identity changes. const autoPlayHandlerRef = useRef(handleNextEpisodeAutoPlay); autoPlayHandlerRef.current = handleNextEpisodeAutoPlay; useEffect(() => { if (!showNextEpisodeCountdown || autoplayCancelled) { // Either the show-condition flipped off OR the user cancelled. // In both cases, stop the running timer immediately so autoplay // can't fire after Cancel was pressed. if (intervalRef.current) { clearInterval(intervalRef.current); intervalRef.current = null; } // Only reset cancellation + seconds when the show-condition itself // flipped off — a fresh credits/end-of-video window then starts a // brand-new countdown. If we got here because autoplayCancelled // just flipped true, keep it true so the countdown stays stopped. if (!showNextEpisodeCountdown) { setAutoplayCancelled(false); setSecondsRemaining(settings.autoplayCountdownSeconds); } return; } setSecondsRemaining(settings.autoplayCountdownSeconds); intervalRef.current = setInterval(() => { setSecondsRemaining((prev) => { if (prev <= 1) { if (intervalRef.current) { clearInterval(intervalRef.current); intervalRef.current = null; } autoPlayHandlerRef.current(); return 0; } return prev - 1; }); }, 1000); return () => { if (intervalRef.current) { clearInterval(intervalRef.current); intervalRef.current = null; } }; }, [ showNextEpisodeCountdown, autoplayCancelled, settings.autoplayCountdownSeconds, ]); const nextEpisodePosterUrl = useMemo( () => nextItem ? getPrimaryImageUrl({ api, item: nextItem, width: 200 }) : null, [api, nextItem], ); return ( {item?.Type === "Episode" && ( {`${item.SeriesName} - ${item.SeasonName} Episode ${item.IndexNumber}`} )} {item?.Name} {item?.Type === "Movie" && ( {item?.ProductionYear} )} {item?.Type === "Audio" && ( {item?.Album} )} {hasChapters && ( setChapterListVisible(true)} hitSlop={10} className='justify-center mr-4' accessibilityRole='button' accessibilityLabel={t("chapters.open")} > )} {/* Smart Skip Credits behavior: - Show "Skip Credits" if there's content after credits OR no next episode - Show "Next Episode" if credits extend to video end AND next episode exists */} {showNextEpisodeCountdown && !autoplayCancelled && nextItem && ( setAutoplayCancelled(true)} /> )} null} cache={cacheProgress} onSlidingStart={handleSliderStart} onSlidingComplete={handleSliderComplete} onValueChange={handleSliderChange} containerStyle={{ borderRadius: 100, }} renderBubble={() => (isSliding || showRemoteBubble) && ( ) } sliderHeight={10} thumbWidth={0} progress={effectiveProgress} minimumValue={min} maximumValue={max} /> handleSliderComplete(ms)} onClose={() => setChapterListVisible(false)} /> ); };