mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-05-28 09:38:25 +01:00
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. <Modal> 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.
195 lines
5.9 KiB
TypeScript
195 lines
5.9 KiB
TypeScript
/**
|
|
* 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<FlatList<ChapterEntry>>(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; <Modal> 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 (
|
|
<Modal
|
|
visible={visible}
|
|
transparent
|
|
animationType='slide'
|
|
onRequestClose={onClose}
|
|
>
|
|
<Pressable onPress={onClose} style={styles.backdrop}>
|
|
<Pressable onPress={(e) => e.stopPropagation()} style={styles.sheet}>
|
|
<View style={styles.header}>
|
|
<Text style={styles.title}>{t("chapters.title")}</Text>
|
|
<Pressable
|
|
onPress={onClose}
|
|
hitSlop={10}
|
|
accessibilityRole='button'
|
|
accessibilityLabel={t("chapters.close")}
|
|
>
|
|
<Ionicons name='close' size={24} color={Colors.text} />
|
|
</Pressable>
|
|
</View>
|
|
<FlatList
|
|
ref={listRef}
|
|
data={entries}
|
|
keyExtractor={(item, index) => `${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 (
|
|
<Pressable
|
|
onPress={() => {
|
|
onSeek(positionMs);
|
|
onClose();
|
|
}}
|
|
style={[
|
|
styles.row,
|
|
isActive && { backgroundColor: `${Colors.primary}33` },
|
|
]}
|
|
>
|
|
<Text
|
|
style={[
|
|
styles.rowText,
|
|
{ color: isActive ? Colors.primary : Colors.text },
|
|
]}
|
|
numberOfLines={1}
|
|
>
|
|
{item.chapter.Name ||
|
|
t("chapters.chapter_number", { number: index + 1 })}
|
|
</Text>
|
|
<Text style={styles.rowTime}>
|
|
{formatChapterTime(positionMs)}
|
|
</Text>
|
|
</Pressable>
|
|
);
|
|
}}
|
|
/>
|
|
</Pressable>
|
|
</Pressable>
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
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,
|
|
},
|
|
});
|