mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-05-28 17:48:26 +01:00
feat(chapters): add ChapterTicks overlay and ChapterList modal
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.
This commit is contained in:
194
components/chapters/ChapterList.tsx
Normal file
194
components/chapters/ChapterList.tsx
Normal file
@@ -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<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,
|
||||
},
|
||||
});
|
||||
85
components/chapters/ChapterTicks.tsx
Normal file
85
components/chapters/ChapterTicks.tsx
Normal file
@@ -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 (
|
||||
<View
|
||||
pointerEvents='none'
|
||||
onLayout={handleLayout}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
// Let ticks taller than this container bleed beyond its bounds.
|
||||
overflow: "visible",
|
||||
}}
|
||||
>
|
||||
{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 (
|
||||
<View
|
||||
key={`${marker.positionMs}-${index}`}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left,
|
||||
top: "50%",
|
||||
marginTop: -height / 2,
|
||||
height,
|
||||
width: snappedWidth,
|
||||
backgroundColor: color,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user