diff --git a/components/DownloadItem.tsx b/components/DownloadItem.tsx index e50b4efca..0d2b8ffbb 100644 --- a/components/DownloadItem.tsx +++ b/components/DownloadItem.tsx @@ -9,6 +9,7 @@ import type { BaseItemDto, MediaSourceInfo, } from "@jellyfin/sdk/lib/generated-client/models"; +import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api"; import { type Href } from "expo-router"; import { t } from "i18next"; import { useAtom } from "jotai"; @@ -195,9 +196,30 @@ export const DownloadItems: React.FC = ({ ); } const downloadDetailsPromises = items.map(async (item) => { + // Ensure the snapshot we store offline carries the Chapters array. + // Page-level fetches sometimes use a fields filter that omits it; the + // offline player would then render no chapter ticks / list. + let itemForDownload = item; + if (!itemForDownload.Chapters && itemForDownload.Id) { + try { + const enriched = await getUserLibraryApi(api).getItem({ + itemId: itemForDownload.Id, + userId: user.Id!, + }); + if (enriched.data) { + itemForDownload = enriched.data; + } + } catch (e) { + console.warn( + "[DownloadItem] failed to refresh item for Chapters, falling back to original", + e, + ); + } + } + const { mediaSource, audioIndex, subtitleIndex } = itemsNotDownloaded.length > 1 - ? getDefaultPlaySettings(item, settings!) + ? getDefaultPlaySettings(itemForDownload, settings!) : { mediaSource: selectedOptions?.mediaSource, audioIndex: selectedOptions?.audioIndex, @@ -206,7 +228,7 @@ export const DownloadItems: React.FC = ({ const downloadDetails = await getDownloadUrl({ api, - item, + item: itemForDownload, userId: user.Id!, mediaSource: mediaSource!, audioStreamIndex: audioIndex ?? -1, @@ -218,7 +240,7 @@ export const DownloadItems: React.FC = ({ return { url: downloadDetails?.url, - item, + item: itemForDownload, mediaSource: downloadDetails?.mediaSource, }; }); diff --git a/components/chapters/ChapterList.tsx b/components/chapters/ChapterList.tsx new file mode 100644 index 000000000..42a90b89e --- /dev/null +++ b/components/chapters/ChapterList.tsx @@ -0,0 +1,196 @@ +/** + * 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 { memo, 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; + +function ChapterListComponent({ + 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)} + + + ); + }} + /> + + + + ); +} + +export const ChapterList = memo(ChapterListComponent); + +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 000000000..850c63bf0 --- /dev/null +++ b/components/chapters/ChapterTicks.tsx @@ -0,0 +1,87 @@ +/** + * 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 { memo, 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; +} + +function ChapterTicksComponent({ + 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 ( + + ); + })} + + ); +} + +export const ChapterTicks = memo(ChapterTicksComponent); diff --git a/components/video-player/controls/BottomControls.tsx b/components/video-player/controls/BottomControls.tsx index 51abf68c7..af445d373 100644 --- a/components/video-player/controls/BottomControls.tsx +++ b/components/video-player/controls/BottomControls.tsx @@ -1,18 +1,34 @@ -import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; -import type { FC } from "react"; -import { View } from "react-native"; +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; @@ -38,6 +54,8 @@ interface BottomControlsProps { handleSliderChange: (value: number) => void; handleTouchStart: () => void; handleTouchEnd: () => void; + /** Programmatic seek (chapter list, hotkeys) — bypasses slide gesture state. */ + seekTo: (value: number) => void; // Trickplay props trickPlayUrl: { @@ -61,6 +79,8 @@ interface BottomControlsProps { export const BottomControls: FC = ({ item, + chapters, + durationMs, showControls, isSliding, showRemoteBubble, @@ -84,12 +104,38 @@ export const BottomControls: FC = ({ 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 === "Audio" && ( {item?.Album} )} + {currentChapterName ? ( + + {currentChapterName} + + ) : null} - + + {hasChapters && ( + setChapterListVisible(true)} + hitSlop={10} + className='justify-center mr-4' + accessibilityRole='button' + accessibilityLabel={t("chapters.open")} + > + + + )} = ({ height: 10, justifyContent: "center", alignItems: "stretch", + // Allow chapter ticks taller than the 10px track to bleed out + // top/bottom (RN defaults to overflow: "hidden" on Android). + overflow: "visible", }} onTouchStart={handleTouchStart} onTouchEnd={handleTouchEnd} @@ -203,6 +268,7 @@ export const BottomControls: FC = ({ trickPlayUrl={trickPlayUrl} trickplayInfo={trickplayInfo} time={time} + chapterName={scrubChapterName} /> ) } @@ -212,6 +278,7 @@ export const BottomControls: FC = ({ minimumValue={min} maximumValue={max} /> + = ({ /> + setChapterListVisible(false)} + /> ); }; diff --git a/components/video-player/controls/Controls.tsx b/components/video-player/controls/Controls.tsx index 96dfad6b3..a13c59a56 100644 --- a/components/video-player/controls/Controls.tsx +++ b/components/video-player/controls/Controls.tsx @@ -251,6 +251,7 @@ export const Controls: FC = ({ handleTouchEnd, handleSliderComplete, handleSliderChange, + seekTo, } = useVideoSlider({ progress, isSeeking, @@ -528,6 +529,8 @@ export const Controls: FC = ({ > = ({ handleSliderChange={handleSliderChange} handleTouchStart={handleTouchStart} handleTouchEnd={handleTouchEnd} + seekTo={seekTo} trickPlayUrl={trickPlayUrl} trickplayInfo={trickplayInfo} time={isSliding || showRemoteBubble ? time : remoteTime} diff --git a/components/video-player/controls/TrickplayBubble.tsx b/components/video-player/controls/TrickplayBubble.tsx index 49645ed2e..ea126a120 100644 --- a/components/video-player/controls/TrickplayBubble.tsx +++ b/components/video-player/controls/TrickplayBubble.tsx @@ -22,12 +22,15 @@ interface TrickplayBubbleProps { minutes: number; seconds: number; }; + /** Chapter name at the scrubbed position, if any. */ + chapterName?: string | null; } export const TrickplayBubble: FC = ({ trickPlayUrl, trickplayInfo, time, + chapterName, }) => { if (!trickPlayUrl || !trickplayInfo) { return null; @@ -36,18 +39,30 @@ export const TrickplayBubble: FC = ({ const { x, y, url } = trickPlayUrl; const tileWidth = CONTROLS_CONSTANTS.TILE_WIDTH; const tileHeight = tileWidth / trickplayInfo.aspectRatio!; + const timeStr = `${time.hours > 0 ? `${time.hours}:` : ""}${ + time.minutes < 10 ? `0${time.minutes}` : time.minutes + }:${time.seconds < 10 ? `0${time.seconds}` : time.seconds}`; + + // Slightly larger preview than before (scale 1.6 vs old 1.4) to give the + // overlay text more room and feel closer to the Jellyfin web style. + const previewScale = 1.6; return ( = ({ width: tileWidth, height: tileHeight, alignSelf: "center", - transform: [{ scale: 1.4 }], + transform: [{ scale: previewScale }], borderRadius: 5, }} className='bg-neutral-800 overflow-hidden' @@ -75,17 +90,51 @@ export const TrickplayBubble: FC = ({ source={{ uri: url }} contentFit='cover' /> + {/* + * Bottom-right overlay (Jellyfin web style) — chapter name (small, + * faded) above the timestamp (small, bold). Sits on top of the + * trickplay frame inside the same overflow:hidden container so it + * always stays within the bubble bounds. + */} + + {chapterName ? ( + + {chapterName} + + ) : null} + + {timeStr} + + - - {`${time.hours > 0 ? `${time.hours}:` : ""}${ - time.minutes < 10 ? `0${time.minutes}` : time.minutes - }:${time.seconds < 10 ? `0${time.seconds}` : time.seconds}`} - ); }; diff --git a/components/video-player/controls/hooks/useVideoSlider.ts b/components/video-player/controls/hooks/useVideoSlider.ts index dfc1164bb..3c19ce7ad 100644 --- a/components/video-player/controls/hooks/useVideoSlider.ts +++ b/components/video-player/controls/hooks/useVideoSlider.ts @@ -74,6 +74,21 @@ export function useVideoSlider({ [seek, play, progress, isSeeking], ); + // Programmatic seek (chapter list, hotkeys) that bypasses the slide gesture. + // Reads `isPlaying` directly instead of `wasPlayingRef`, which is only set + // during a real slide and would carry stale state on a tap-to-seek. + const seekTo = useCallback( + (value: number) => { + const seekValue = Math.max(0, Math.floor(value)); + progress.value = seekValue; + seek(seekValue); + if (isPlaying) { + play(); + } + }, + [seek, play, progress, isPlaying], + ); + const handleSliderChange = useCallback( debounce((value: number) => { // Convert ms to ticks for trickplay @@ -96,5 +111,6 @@ export function useVideoSlider({ handleTouchEnd, handleSliderComplete, handleSliderChange, + seekTo, }; } diff --git a/translations/en.json b/translations/en.json index 3fe9efb66..dfad3bc06 100644 --- a/translations/en.json +++ b/translations/en.json @@ -610,6 +610,12 @@ "downloaded_file_no": "No", "downloaded_file_cancel": "Cancel" }, + "chapters": { + "title": "Chapters", + "chapter_number": "Chapter {{number}}", + "open": "Open chapters", + "close": "Close chapters" + }, "item_card": { "next_up": "Next Up", "no_items_to_display": "No Items to Display", diff --git a/utils/chapters.test.ts b/utils/chapters.test.ts new file mode 100644 index 000000000..875bc7e2a --- /dev/null +++ b/utils/chapters.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, test } from "bun:test"; +import { + chapterMarkers, + chapterNameAt, + chapterStartsMs, + currentChapterIndex, + formatChapterTime, + sortedChapters, +} from "./chapters"; + +// Helper: a ChapterInfo with a start in milliseconds. +const ch = (ms: number, name?: string) => ({ + StartPositionTicks: ms * 10000, + Name: name, +}); + +describe("chapterMarkers", () => { + test("maps chapters to position + percent", () => { + expect(chapterMarkers([ch(0), ch(30_000), ch(60_000)], 120_000)).toEqual([ + { positionMs: 0, percent: 0 }, + { positionMs: 30_000, percent: 25 }, + { positionMs: 60_000, percent: 50 }, + ]); + }); + + test("drops chapters past the duration", () => { + expect(chapterMarkers([ch(0), ch(200_000)], 120_000)).toEqual([ + { positionMs: 0, percent: 0 }, + ]); + }); + + test("returns [] when duration is 0 or chapters missing", () => { + expect(chapterMarkers([ch(0)], 0)).toEqual([]); + expect(chapterMarkers(null, 120_000)).toEqual([]); + expect(chapterMarkers(undefined, 120_000)).toEqual([]); + }); + + test("excludes a chapter exactly at the duration", () => { + expect(chapterMarkers([ch(0), ch(120_000)], 120_000)).toEqual([ + { positionMs: 0, percent: 0 }, + ]); + }); + + test("skips chapters with no StartPositionTicks", () => { + expect( + chapterMarkers([{ StartPositionTicks: undefined }, ch(30_000)], 120_000), + ).toEqual([{ positionMs: 30_000, percent: 25 }]); + }); +}); + +describe("currentChapterIndex", () => { + const chapters = [ch(0), ch(30_000), ch(60_000)]; + test("returns the chapter containing the position", () => { + expect(currentChapterIndex(0, chapters)).toBe(0); + expect(currentChapterIndex(15_000, chapters)).toBe(0); + expect(currentChapterIndex(30_000, chapters)).toBe(1); + expect(currentChapterIndex(90_000, chapters)).toBe(2); + }); + test("returns -1 before the first chapter and for no chapters", () => { + expect(currentChapterIndex(-5, chapters)).toBe(-1); + expect(currentChapterIndex(10_000, [])).toBe(-1); + expect(currentChapterIndex(10_000, null)).toBe(-1); + }); +}); + +describe("sortedChapters", () => { + test("pairs each chapter with its ms start, sorted ascending", () => { + const a = ch(60_000, "C"); + const b = ch(0, "A"); + const c = ch(30_000, "B"); + expect(sortedChapters([a, b, c])).toEqual([ + { chapter: b, positionMs: 0 }, + { chapter: c, positionMs: 30_000 }, + { chapter: a, positionMs: 60_000 }, + ]); + }); + test("returns [] for null/undefined", () => { + expect(sortedChapters(null)).toEqual([]); + expect(sortedChapters(undefined)).toEqual([]); + }); +}); + +describe("chapterStartsMs", () => { + test("returns sorted ms positions", () => { + expect(chapterStartsMs([ch(60_000), ch(0), ch(30_000)])).toEqual([ + 0, 30_000, 60_000, + ]); + }); + + test("skips entries without StartPositionTicks", () => { + expect( + chapterStartsMs([ch(30_000), { StartPositionTicks: undefined }, ch(0)]), + ).toEqual([0, 30_000]); + }); + + test("returns [] for null/undefined/empty", () => { + expect(chapterStartsMs(null)).toEqual([]); + expect(chapterStartsMs(undefined)).toEqual([]); + expect(chapterStartsMs([])).toEqual([]); + }); +}); + +describe("chapterNameAt", () => { + const named = [ + { StartPositionTicks: 0, Name: "Intro" }, + { StartPositionTicks: 30_000 * 10000, Name: "Action" }, + { StartPositionTicks: 60_000 * 10000, Name: "Outro" }, + ]; + + test("returns the chapter name for the active position", () => { + expect(chapterNameAt(0, named)).toBe("Intro"); + expect(chapterNameAt(15_000, named)).toBe("Intro"); + expect(chapterNameAt(45_000, named)).toBe("Action"); + expect(chapterNameAt(90_000, named)).toBe("Outro"); + }); + + test("returns null before the first chapter", () => { + expect(chapterNameAt(-1, named)).toBeNull(); + }); + + test("returns null for null/undefined/empty chapters", () => { + expect(chapterNameAt(10_000, null)).toBeNull(); + expect(chapterNameAt(10_000, undefined)).toBeNull(); + expect(chapterNameAt(10_000, [])).toBeNull(); + }); + + test("returns null when the active chapter has no Name", () => { + expect(chapterNameAt(15_000, [ch(0), ch(30_000)])).toBeNull(); + }); +}); + +describe("formatChapterTime", () => { + test("formats m:ss and h:mm:ss", () => { + expect(formatChapterTime(65_000)).toBe("1:05"); + expect(formatChapterTime(3_725_000)).toBe("1:02:05"); + expect(formatChapterTime(-100)).toBe("0:00"); + }); +}); diff --git a/utils/chapters.ts b/utils/chapters.ts new file mode 100644 index 000000000..8b0e0e7bc --- /dev/null +++ b/utils/chapters.ts @@ -0,0 +1,97 @@ +/** + * Pure helpers for Jellyfin chapter markers. Dependency-free so they are + * unit-testable under `bun test`. + */ + +import type { ChapterInfo } from "@jellyfin/sdk/lib/generated-client/models"; +import { ticksToMs } from "@/utils/time"; + +export interface ChapterMarker { + /** Chapter start, in milliseconds. */ + positionMs: number; + /** Chapter start as a percentage (0-100) of the media duration. */ + percent: number; +} + +export interface ChapterEntry { + chapter: ChapterInfo; + /** Chapter start, in milliseconds. */ + positionMs: number; +} + +/** Chapters paired with their millisecond start, sorted ascending by start. */ +export const sortedChapters = ( + chapters: ChapterInfo[] | null | undefined, +): ChapterEntry[] => + (chapters ?? []) + .filter((c) => c.StartPositionTicks != null) + .map((chapter) => ({ + chapter, + positionMs: ticksToMs(chapter.StartPositionTicks), + })) + .sort((a, b) => a.positionMs - b.positionMs); + +/** Chapter start positions in milliseconds, ascending. */ +export const chapterStartsMs = ( + chapters: ChapterInfo[] | null | undefined, +): number[] => + (chapters ?? []) + .filter((c) => c.StartPositionTicks != null) + .map((c) => ticksToMs(c.StartPositionTicks)) + .sort((a, b) => a - b); + +/** Chapter markers within [0, durationMs]; empty when duration is unknown. */ +export const chapterMarkers = ( + chapters: ChapterInfo[] | null | undefined, + durationMs: number, +): ChapterMarker[] => { + if (durationMs <= 0) return []; + return chapterStartsMs(chapters) + .filter((ms) => ms >= 0 && ms < durationMs) + .map((ms) => ({ positionMs: ms, percent: (ms / durationMs) * 100 })); +}; + +/** Index of the chapter containing `positionMs`, or -1 if before the first. */ +export const currentChapterIndex = ( + positionMs: number, + chapters: ChapterInfo[] | null | undefined, +): number => { + const starts = chapterStartsMs(chapters); + let index = -1; + for (let i = 0; i < starts.length; i++) { + if (positionMs >= starts[i]) index = i; + else break; + } + return index; +}; + +/** Name of the chapter containing `positionMs`, or null if none / unnamed. */ +export const chapterNameAt = ( + positionMs: number, + chapters: ChapterInfo[] | null | undefined, +): string | null => { + // Sort once, derive both the active index and the entry from the same array + // — `chapterNameAt` runs on every playback tick, so paying for one `sort()` + // instead of two is worth the duplication of the index loop here. + const sorted = sortedChapters(chapters); + let idx = -1; + for (let i = 0; i < sorted.length; i++) { + if (positionMs >= sorted[i].positionMs) idx = i; + else break; + } + if (idx < 0) return null; + const name = sorted[idx]?.chapter.Name; + return name && name.length > 0 ? name : null; +}; + +/** `m:ss` (or `h:mm:ss` past an hour) label for a millisecond position. */ +export const formatChapterTime = (positionMs: number): string => { + const total = Math.max(0, Math.floor(positionMs / 1000)); + const hours = Math.floor(total / 3600); + const minutes = Math.floor((total % 3600) / 60); + const seconds = total % 60; + const pad = (n: number) => String(n).padStart(2, "0"); + return hours > 0 + ? `${hours}:${pad(minutes)}:${pad(seconds)}` + : `${minutes}:${pad(seconds)}`; +};