/** * A modal listing an item's chapters. Each row shows the chapter name and its * timestamp; the current chapter is highlighted. Tapping a row seeks to that * chapter and closes the modal. Player-agnostic — the seek is injected. */ import { Ionicons } from "@expo/vector-icons"; import type { ChapterInfo } from "@jellyfin/sdk/lib/generated-client/models"; import { useEffect, useMemo, useRef } from "react"; import { useTranslation } from "react-i18next"; import { FlatList, Modal, Pressable, StyleSheet, View } from "react-native"; import { Text } from "@/components/common/Text"; import { Colors } from "@/constants/Colors"; import { type ChapterEntry, chapterStartsMs, formatChapterTime, sortedChapters, } from "@/utils/chapters"; interface ChapterListProps { visible: boolean; chapters: ChapterInfo[] | null | undefined; /** Current playback position in milliseconds (to highlight the row). */ currentPositionMs: number; /** Seek the player to this millisecond position. */ onSeek: (positionMs: number) => void; onClose: () => void; } const ROW_HEIGHT = 48; export function ChapterList({ visible, chapters, currentPositionMs, onSeek, onClose, }: ChapterListProps) { const { t } = useTranslation(); const listRef = useRef>(null); const entries = useMemo(() => sortedChapters(chapters), [chapters]); // Memoize starts so currentChapterIndex computation doesn't re-sort/filter // every tick — chapters is the only input that drives the underlying array. const starts = useMemo(() => chapterStartsMs(chapters), [chapters]); const activeIndex = useMemo(() => { let idx = -1; for (let i = 0; i < starts.length; i++) { if (currentPositionMs >= starts[i]) idx = i; else break; } return idx; }, [currentPositionMs, starts]); // FlatList.initialScrollIndex only fires at first mount; keeps its // children mounted across visible toggles, so subsequent opens never scroll. // Trigger an imperative scroll each time the sheet becomes visible. useEffect(() => { if (!visible || activeIndex < 0 || entries.length === 0) return; const raf = requestAnimationFrame(() => { listRef.current?.scrollToIndex({ index: activeIndex, animated: false, viewPosition: 0.5, }); }); return () => cancelAnimationFrame(raf); }, [visible, activeIndex, entries.length]); return ( e.stopPropagation()} style={styles.sheet}> {t("chapters.title")} `${item.positionMs}-${index}`} getItemLayout={(_, index) => ({ length: ROW_HEIGHT, offset: ROW_HEIGHT * index, index, })} onScrollToIndexFailed={(info) => { // Required when getItemLayout is provided and the target index // is outside the currently rendered window. Fallback to an // offset-based scroll, then retry the precise scroll once a // frame has elapsed. listRef.current?.scrollToOffset({ offset: info.averageItemLength * info.index, animated: false, }); setTimeout(() => { listRef.current?.scrollToIndex({ index: info.index, animated: false, viewPosition: 0.5, }); }, 50); }} renderItem={({ item, index }) => { const positionMs = item.positionMs; const isActive = index === activeIndex; return ( { onSeek(positionMs); onClose(); }} style={[ styles.row, isActive && { backgroundColor: `${Colors.primary}33` }, ]} > {item.chapter.Name || t("chapters.chapter_number", { number: index + 1 })} {formatChapterTime(positionMs)} ); }} /> ); } const styles = StyleSheet.create({ backdrop: { flex: 1, justifyContent: "flex-end", backgroundColor: "rgba(0,0,0,0.6)", }, sheet: { backgroundColor: Colors.background, borderTopLeftRadius: 16, borderTopRightRadius: 16, maxHeight: "70%", paddingBottom: 24, }, header: { flexDirection: "row", justifyContent: "space-between", alignItems: "center", padding: 16, }, title: { color: Colors.text, fontSize: 17, fontWeight: "700", }, row: { flexDirection: "row", justifyContent: "space-between", alignItems: "center", paddingHorizontal: 16, height: ROW_HEIGHT, }, rowText: { fontSize: 15, flex: 1, }, rowTime: { color: Colors.icon, fontSize: 13, marginLeft: 12, }, });