diff --git a/components/chapters/ChapterList.tsx b/components/chapters/ChapterList.tsx new file mode 100644 index 00000000..e4927e6b --- /dev/null +++ b/components/chapters/ChapterList.tsx @@ -0,0 +1,194 @@ +/** + * 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, + }, +}); diff --git a/components/chapters/ChapterTicks.tsx b/components/chapters/ChapterTicks.tsx new file mode 100644 index 00000000..e4ec5ca6 --- /dev/null +++ b/components/chapters/ChapterTicks.tsx @@ -0,0 +1,85 @@ +/** + * Chapter tick marks drawn as an absolute overlay over a progress slider. + * Renders nothing for media with one or zero chapters. `pointerEvents: "none"` + * so the slider underneath still receives touches. + */ + +import { useState } from "react"; +import { type LayoutChangeEvent, PixelRatio, View } from "react-native"; +import type { ChapterMarker } from "@/utils/chapters"; + +interface ChapterTicksProps { + /** Pre-computed markers (caller memoizes — avoids double-computing here). */ + markers: ChapterMarker[]; + /** Tick colour. */ + color?: string; + /** Tick height in px — slightly less than the slider track thickness. */ + height?: number; + /** Tick width in px — integer to avoid sub-pixel anti-aliasing. */ + width?: number; +} + +export function ChapterTicks({ + markers, + // Semi-transparent black contrasts against both the filled progress + // (#fff) and the unfilled track (rgba(255,255,255,0.2)) so the ticks + // stay visible across the whole bar as playback advances. + color = "rgba(0,0,0,0.55)", + height = 14, + width = 2, +}: ChapterTicksProps) { + // Hooks must run unconditionally — keep them before any early return. + const [sliderWidth, setSliderWidth] = useState(0); + + const handleLayout = (e: LayoutChangeEvent) => { + setSliderWidth(e.nativeEvent.layout.width); + }; + + // One chapter (typically a single marker at 0) is not worth marking. + if (markers.length <= 1) return null; + + return ( + + {sliderWidth > 0 && + markers + // Skip the leading 0ms marker — it overlaps the slider start and + // adds visual noise at an already-rendered boundary. + .filter((marker) => marker.positionMs > 0) + .map((marker, index) => { + // Align both the position AND the width onto the device's + // physical pixel grid. Without this, fractional dp values land + // at different sub-pixel fractions per tick — Android samples + // each one differently and some ticks render visibly thicker. + const centerDp = (marker.percent / 100) * sliderWidth; + const left = PixelRatio.roundToNearestPixel(centerDp - width / 2); + const snappedWidth = PixelRatio.roundToNearestPixel(width); + return ( + + ); + })} + + ); +}