import { Ionicons } from "@expo/vector-icons"; import type { BaseItemDto, ChapterInfo, } from "@jellyfin/sdk/lib/generated-client"; import { type FC, useMemo, 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 { useSettings } from "@/utils/atoms/settings"; import { chapterMarkers, chapterNameAt } from "@/utils/chapters"; import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton"; import SkipButton from "./SkipButton"; import { TimeDisplay } from "./TimeDisplay"; import { TrickplayBubble } from "./TrickplayBubble"; // Chapter tick height in dp — matches the slider track height for a clean, // flush look (no top/bottom overflow). const TICK_HEIGHT = 10; 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; showSkipCreditButton: boolean; hasContentAfterCredits: boolean; skipIntro: () => void; skipCredit: () => void; nextItem?: BaseItemDto | 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; /** Programmatic seek (chapter list, hotkeys) — bypasses slide gesture state. */ seekTo: (value: number) => 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, showSkipCreditButton, hasContentAfterCredits, skipIntro, skipCredit, nextItem, handleNextEpisodeAutoPlay, handleNextEpisodeManual, handleControlsInteraction, min, max, effectiveProgress, cacheProgress, handleSliderStart, handleSliderComplete, handleSliderChange, handleTouchStart, handleTouchEnd, seekTo, 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; // Current chapter name for the always-visible header label (live playback). const currentChapterName = useMemo( () => (hasChapters ? chapterNameAt(currentTime, chapters) : null), [hasChapters, currentTime, chapters], ); // Chapter name at the scrubbed position for the trickplay bubble. `time` is // an {h,m,s} object derived from the slider's dragged value — convert back // to ms for the lookup. Only useful while actively scrubbing. const scrubChapterName = useMemo(() => { if (!hasChapters) return null; const scrubMs = (time.hours * 3600 + time.minutes * 60 + time.seconds) * 1000; return chapterNameAt(scrubMs, chapters); }, [hasChapters, time.hours, time.minutes, time.seconds, chapters]); return ( {item?.Type === "Episode" && ( {`${item.SeriesName} - ${item.SeasonName} Episode ${item.IndexNumber}`} )} {item?.Name} {item?.Type === "Movie" && ( {item?.ProductionYear} )} {item?.Type === "Audio" && ( {item?.Album} )} {currentChapterName ? ( {currentChapterName} ) : null} {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 */} {settings.autoPlayNextEpisode !== false && (settings.maxAutoPlayEpisodeCount.value === -1 || settings.autoPlayEpisodeCount < settings.maxAutoPlayEpisodeCount.value) && ( )} 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} /> setChapterListVisible(false)} /> ); };