From 5f64ce49c0e0cab7b50b25ee98e6d9b2238b4483 Mon Sep 17 00:00:00 2001 From: Gauvain Date: Wed, 27 May 2026 16:39:32 +0200 Subject: [PATCH] feat(chapters): add ChapterTicks overlay and ChapterList modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two presentational components, both player-agnostic: ChapterTicks — absolute overlay that draws tick marks on the progress slider, one per chapter start (skipping the leading 0ms marker). - Reads markers from a memoized prop computed by the caller so the filter/sort runs at most once per chapters change, not per render. - Snaps tick position AND width to the device pixel grid via PixelRatio.roundToNearestPixel(). Without this, fractional dp values land at different sub-pixel fractions on non-integer density displays (420dpi -> 2.625x ratio) and Android anti-aliases each tick differently, making some look visibly thicker than others. - Tick colour defaults to rgba(0,0,0,0.55), contrasting against both the filled progress (#fff) and the unfilled track so ticks stay visible as playback advances. - pointerEvents="none" so the slider underneath still receives touches. - overflow: "visible" so taller ticks can bleed past the parent track. ChapterList — bottom-sheet modal listing chapters with their timestamps. - Highlights the currently active row (purple primary tint). - Falls back to a localized "Chapter N" label when a chapter has no name. - Imperatively scrolls to the active row each time the sheet becomes visible. keeps its children mounted across visible toggles, so FlatList.initialScrollIndex (which only fires at first mount) would only work on the very first open. Uses a ref + useEffect on `visible` + scrollToIndex inside requestAnimationFrame, with an onScrollToIndexFailed fallback for indices outside the render window. - All static styles in StyleSheet.create() — only dynamic backgroundColor / text colour stays inline. The list re-renders on every playback tick so cutting the per-render style allocations is worth it. - Colors from constants/Colors.ts (primary, background, text, icon), no hardcoded hex. --- components/chapters/ChapterList.tsx | 194 +++++++++++++++++++++++++++ components/chapters/ChapterTicks.tsx | 85 ++++++++++++ 2 files changed, 279 insertions(+) create mode 100644 components/chapters/ChapterList.tsx create mode 100644 components/chapters/ChapterTicks.tsx 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 ( + + ); + })} + + ); +}