feat(player): add chapter navigation support with visual markers

This commit is contained in:
Fredrik Burmester
2026-01-30 18:02:32 +01:00
parent 3814237ac6
commit 28e3060ace
8 changed files with 426 additions and 29 deletions

View File

@@ -19,6 +19,8 @@ export interface TVFocusableProgressBarProps {
max: SharedValue<number>;
/** Cache progress value (SharedValue) in milliseconds */
cacheProgress?: SharedValue<number>;
/** Chapter positions as percentages (0-100) for tick marks */
chapterPositions?: number[];
/** Callback when the progress bar receives focus */
onFocus?: () => void;
/** Callback when the progress bar loses focus */
@@ -41,6 +43,7 @@ export const TVFocusableProgressBar: React.FC<TVFocusableProgressBarProps> =
progress,
max,
cacheProgress,
chapterPositions = [],
onFocus,
onBlur,
refSetter,
@@ -81,20 +84,36 @@ export const TVFocusableProgressBar: React.FC<TVFocusableProgressBarProps> =
focused && styles.animatedContainerFocused,
]}
>
<View
style={[
styles.progressTrack,
focused && styles.progressTrackFocused,
]}
>
{cacheProgress && (
<View style={styles.progressTrackWrapper}>
<View
style={[
styles.progressTrack,
focused && styles.progressTrackFocused,
]}
>
{cacheProgress && (
<ReanimatedView
style={[styles.cacheProgress, cacheProgressStyle]}
/>
)}
<ReanimatedView
style={[styles.cacheProgress, cacheProgressStyle]}
style={[styles.progressFill, progressFillStyle]}
/>
</View>
{/* Chapter markers - positioned outside track to extend above */}
{chapterPositions.length > 0 && (
<View
style={styles.chapterMarkersContainer}
pointerEvents='none'
>
{chapterPositions.map((position, index) => (
<View
key={`chapter-marker-${index}`}
style={[styles.chapterMarker, { left: `${position}%` }]}
/>
))}
</View>
)}
<ReanimatedView
style={[styles.progressFill, progressFillStyle]}
/>
</View>
</Animated.View>
</Pressable>
@@ -121,6 +140,10 @@ const styles = StyleSheet.create({
shadowOpacity: 0.5,
shadowRadius: 12,
},
progressTrackWrapper: {
position: "relative",
height: PROGRESS_BAR_HEIGHT,
},
progressTrack: {
height: PROGRESS_BAR_HEIGHT,
backgroundColor: "rgba(255,255,255,0.2)",
@@ -147,4 +170,20 @@ const styles = StyleSheet.create({
backgroundColor: "#fff",
borderRadius: 8,
},
chapterMarkersContainer: {
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
},
chapterMarker: {
position: "absolute",
width: 2,
height: PROGRESS_BAR_HEIGHT + 5,
bottom: 0,
backgroundColor: "rgba(255, 255, 255, 0.6)",
borderRadius: 1,
transform: [{ translateX: -1 }],
},
});

View File

@@ -6,6 +6,7 @@ import { type SharedValue } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { useSettings } from "@/utils/atoms/settings";
import { ChapterMarkers } from "./ChapterMarkers";
import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton";
import SkipButton from "./SkipButton";
import { TimeDisplay } from "./TimeDisplay";
@@ -57,6 +58,9 @@ interface BottomControlsProps {
minutes: number;
seconds: number;
};
// Chapter props
chapterPositions?: number[];
}
export const BottomControls: FC<BottomControlsProps> = ({
@@ -87,6 +91,7 @@ export const BottomControls: FC<BottomControlsProps> = ({
trickPlayUrl,
trickplayInfo,
time,
chapterPositions = [],
}) => {
const { settings } = useSettings();
const insets = useSafeAreaInsets();
@@ -176,6 +181,7 @@ export const BottomControls: FC<BottomControlsProps> = ({
height: 10,
justifyContent: "center",
alignItems: "stretch",
position: "relative",
}}
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
@@ -212,6 +218,7 @@ export const BottomControls: FC<BottomControlsProps> = ({
minimumValue={min}
maximumValue={max}
/>
<ChapterMarkers chapterPositions={chapterPositions} />
</View>
<TimeDisplay
currentTime={currentTime}

View File

@@ -18,6 +18,12 @@ interface CenterControlsProps {
togglePlay: () => void;
handleSkipBackward: () => void;
handleSkipForward: () => void;
// Chapter navigation props
hasChapters?: boolean;
hasPreviousChapter?: boolean;
hasNextChapter?: boolean;
goToPreviousChapter?: () => void;
goToNextChapter?: () => void;
}
export const CenterControls: FC<CenterControlsProps> = ({
@@ -29,6 +35,11 @@ export const CenterControls: FC<CenterControlsProps> = ({
togglePlay,
handleSkipBackward,
handleSkipForward,
hasChapters = false,
hasPreviousChapter = false,
hasNextChapter = false,
goToPreviousChapter,
goToNextChapter,
}) => {
const { settings } = useSettings();
const insets = useSafeAreaInsets();
@@ -94,6 +105,20 @@ export const CenterControls: FC<CenterControlsProps> = ({
</TouchableOpacity>
)}
{!Platform.isTV && hasChapters && (
<TouchableOpacity
onPress={goToPreviousChapter}
disabled={!hasPreviousChapter}
style={{ opacity: hasPreviousChapter ? 1 : 0.3 }}
>
<Ionicons
name='play-back'
size={ICON_SIZES.CENTER - 10}
color='white'
/>
</TouchableOpacity>
)}
<View style={Platform.isTV ? { flex: 1, alignItems: "center" } : {}}>
<TouchableOpacity onPress={togglePlay}>
{!isBuffering ? (
@@ -108,6 +133,20 @@ export const CenterControls: FC<CenterControlsProps> = ({
</TouchableOpacity>
</View>
{!Platform.isTV && hasChapters && (
<TouchableOpacity
onPress={goToNextChapter}
disabled={!hasNextChapter}
style={{ opacity: hasNextChapter ? 1 : 0.3 }}
>
<Ionicons
name='play-forward'
size={ICON_SIZES.CENTER - 10}
color='white'
/>
</TouchableOpacity>
)}
{!Platform.isTV && (
<TouchableOpacity onPress={handleSkipForward}>
<View

View File

@@ -0,0 +1,65 @@
import React from "react";
import { StyleSheet, View, type ViewStyle } from "react-native";
export interface ChapterMarkersProps {
/** Array of chapter positions as percentages (0-100) */
chapterPositions: number[];
/** Optional style overrides for the container */
style?: ViewStyle;
/** Height of the marker lines (should be > track height to extend above) */
markerHeight?: number;
/** Color of the marker lines */
markerColor?: string;
}
/**
* Renders vertical tick marks on the progress bar at chapter positions
* Should be overlaid on the slider track
*/
export const ChapterMarkers: React.FC<ChapterMarkersProps> = React.memo(
({
chapterPositions,
style,
markerHeight = 15,
markerColor = "rgba(255, 255, 255, 0.6)",
}) => {
if (!chapterPositions.length) {
return null;
}
return (
<View style={[styles.container, style]} pointerEvents='none'>
{chapterPositions.map((position, index) => (
<View
key={`chapter-marker-${index}`}
style={[
styles.marker,
{
left: `${position}%`,
height: markerHeight,
bottom: 0,
backgroundColor: markerColor,
},
]}
/>
))}
</View>
);
},
);
const styles = StyleSheet.create({
container: {
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
},
marker: {
position: "absolute",
width: 2,
borderRadius: 1,
transform: [{ translateX: -1 }], // Center the marker on its position
},
});

View File

@@ -33,6 +33,7 @@ import { CONTROLS_CONSTANTS } from "./constants";
import { EpisodeList } from "./EpisodeList";
import { GestureOverlay } from "./GestureOverlay";
import { HeaderControls } from "./HeaderControls";
import { useChapterNavigation } from "./hooks/useChapterNavigation";
import { useRemoteControl } from "./hooks/useRemoteControl";
import { useVideoNavigation } from "./hooks/useVideoNavigation";
import { useVideoSlider } from "./hooks/useVideoSlider";
@@ -211,6 +212,21 @@ export const Controls: FC<Props> = ({
isSeeking,
});
// Chapter navigation hook
const {
hasChapters,
hasPreviousChapter,
hasNextChapter,
goToPreviousChapter,
goToNextChapter,
chapterPositions,
} = useChapterNavigation({
chapters: item.Chapters,
progress,
maxMs,
seek,
});
const toggleControls = useCallback(() => {
if (showControls) {
setShowAudioSlider(false);
@@ -526,6 +542,11 @@ export const Controls: FC<Props> = ({
togglePlay={togglePlay}
handleSkipBackward={handleSkipBackward}
handleSkipForward={handleSkipForward}
hasChapters={hasChapters}
hasPreviousChapter={hasPreviousChapter}
hasNextChapter={hasNextChapter}
goToPreviousChapter={goToPreviousChapter}
goToNextChapter={goToNextChapter}
/>
</Animated.View>
<Animated.View
@@ -560,6 +581,7 @@ export const Controls: FC<Props> = ({
trickPlayUrl={trickPlayUrl}
trickplayInfo={trickplayInfo}
time={isSliding || showRemoteBubble ? time : remoteTime}
chapterPositions={chapterPositions}
/>
</Animated.View>
</>

View File

@@ -45,6 +45,7 @@ import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings"
import { formatTimeString, msToTicks, ticksToMs } from "@/utils/time";
import { CONTROLS_CONSTANTS } from "./constants";
import { useVideoContext } from "./contexts/VideoContext";
import { useChapterNavigation } from "./hooks/useChapterNavigation";
import { useRemoteControl } from "./hooks/useRemoteControl";
import { useVideoTime } from "./hooks/useVideoTime";
import { TechnicalInfoOverlay } from "./TechnicalInfoOverlay";
@@ -375,6 +376,21 @@ export const Controls: FC<Props> = ({
isSeeking,
});
// Chapter navigation hook
const {
hasChapters,
hasPreviousChapter,
hasNextChapter,
goToPreviousChapter,
goToNextChapter,
chapterPositions,
} = useChapterNavigation({
chapters: item.Chapters,
progress,
maxMs,
seek,
});
// Countdown logic - needs to be early so toggleControls can reference it
const isCountdownActive = useMemo(() => {
if (!nextItem) return false;
@@ -1038,23 +1054,44 @@ export const Controls: FC<Props> = ({
<View
style={[styles.progressBarContainer, styles.minimalProgressGlow]}
>
<View style={[styles.progressTrack, styles.minimalProgressTrack]}>
<Animated.View
style={[
styles.cacheProgress,
useAnimatedStyle(() => ({
width: `${max.value > 0 ? (cacheProgress.value / max.value) * 100 : 0}%`,
})),
]}
/>
<Animated.View
style={[
styles.progressFill,
useAnimatedStyle(() => ({
width: `${max.value > 0 ? (effectiveProgress.value / max.value) * 100 : 0}%`,
})),
]}
/>
<View style={styles.minimalProgressTrackWrapper}>
<View
style={[styles.progressTrack, styles.minimalProgressTrack]}
>
<Animated.View
style={[
styles.cacheProgress,
useAnimatedStyle(() => ({
width: `${max.value > 0 ? (cacheProgress.value / max.value) * 100 : 0}%`,
})),
]}
/>
<Animated.View
style={[
styles.progressFill,
useAnimatedStyle(() => ({
width: `${max.value > 0 ? (effectiveProgress.value / max.value) * 100 : 0}%`,
})),
]}
/>
</View>
{/* Chapter markers */}
{chapterPositions.length > 0 && (
<View
style={styles.minimalChapterMarkersContainer}
pointerEvents='none'
>
{chapterPositions.map((position, index) => (
<View
key={`minimal-chapter-marker-${index}`}
style={[
styles.minimalChapterMarker,
{ left: `${position}%` },
]}
/>
))}
</View>
)}
</View>
</View>
</View>
@@ -1135,6 +1172,14 @@ export const Controls: FC<Props> = ({
disabled={!previousItem}
size={28}
/>
{hasChapters && (
<TVControlButton
icon='play-back'
onPress={goToPreviousChapter}
disabled={!hasPreviousChapter}
size={24}
/>
)}
<TVControlButton
icon={isPlaying ? "pause" : "play"}
onPress={handlePlayPauseButton}
@@ -1146,6 +1191,14 @@ export const Controls: FC<Props> = ({
lastOpenedModal === null
}
/>
{hasChapters && (
<TVControlButton
icon='play-forward'
onPress={goToNextChapter}
disabled={!hasNextChapter}
size={24}
/>
)}
<TVControlButton
icon='play-skip-forward'
onPress={handleNextItemButton}
@@ -1221,6 +1274,7 @@ export const Controls: FC<Props> = ({
progress={effectiveProgress}
max={max}
cacheProgress={cacheProgress}
chapterPositions={chapterPositions}
onFocus={() => setIsProgressBarFocused(true)}
onBlur={() => setIsProgressBarFocused(false)}
refSetter={setProgressBarRef}
@@ -1310,7 +1364,7 @@ const styles = StyleSheet.create({
},
trickplayBubbleContainer: {
position: "absolute",
bottom: 170,
bottom: 190,
left: 0,
right: 0,
zIndex: 20,
@@ -1392,4 +1446,24 @@ const styles = StyleSheet.create({
// Brighter track like focused state
backgroundColor: "rgba(255,255,255,0.35)",
},
minimalProgressTrackWrapper: {
position: "relative",
height: TV_SEEKBAR_HEIGHT,
},
minimalChapterMarkersContainer: {
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
},
minimalChapterMarker: {
position: "absolute",
width: 2,
height: TV_SEEKBAR_HEIGHT + 5,
bottom: 0,
backgroundColor: "rgba(255, 255, 255, 0.6)",
borderRadius: 1,
transform: [{ translateX: -1 }],
},
});

View File

@@ -1,3 +1,4 @@
export { useChapterNavigation } from "./useChapterNavigation";
export { useRemoteControl } from "./useRemoteControl";
export { useVideoNavigation } from "./useVideoNavigation";
export { useVideoSlider } from "./useVideoSlider";

View File

@@ -0,0 +1,150 @@
import type { ChapterInfo } from "@jellyfin/sdk/lib/generated-client";
import { useCallback, useMemo } from "react";
import type { SharedValue } from "react-native-reanimated";
import { ticksToMs } from "@/utils/time";
export interface UseChapterNavigationProps {
/** Chapters array from the item */
chapters: ChapterInfo[] | null | undefined;
/** Current progress in milliseconds (SharedValue) */
progress: SharedValue<number>;
/** Total duration in milliseconds */
maxMs: number;
/** Seek function that accepts milliseconds */
seek: (ms: number) => void;
}
export interface UseChapterNavigationReturn {
/** Array of chapters */
chapters: ChapterInfo[];
/** Index of the current chapter (-1 if no chapters) */
currentChapterIndex: number;
/** Current chapter info or null */
currentChapter: ChapterInfo | null;
/** Whether there's a next chapter available */
hasNextChapter: boolean;
/** Whether there's a previous chapter available */
hasPreviousChapter: boolean;
/** Navigate to the next chapter */
goToNextChapter: () => void;
/** Navigate to the previous chapter (or restart current if >3s in) */
goToPreviousChapter: () => void;
/** Array of chapter positions as percentages (0-100) for tick marks */
chapterPositions: number[];
/** Whether chapters are available */
hasChapters: boolean;
}
// Threshold in ms - if more than 3 seconds into chapter, restart instead of going to previous
const RESTART_THRESHOLD_MS = 3000;
/**
* Hook for chapter navigation in video player
* Provides current chapter info and navigation functions
*/
export function useChapterNavigation({
chapters: rawChapters,
progress,
maxMs,
seek,
}: UseChapterNavigationProps): UseChapterNavigationReturn {
// Ensure chapters is always an array
const chapters = useMemo(() => rawChapters ?? [], [rawChapters]);
// Calculate chapter positions as percentages for tick marks
const chapterPositions = useMemo(() => {
if (!chapters.length || maxMs <= 0) return [];
return chapters
.map((chapter) => {
const positionMs = ticksToMs(chapter.StartPositionTicks);
return (positionMs / maxMs) * 100;
})
.filter((pos) => pos > 0 && pos < 100); // Skip first (0%) and any at the end
}, [chapters, maxMs]);
// Find current chapter index based on progress
// The current chapter is the one with the largest StartPositionTicks that is <= current progress
const getCurrentChapterIndex = useCallback((): number => {
if (!chapters.length) return -1;
const currentMs = progress.value;
let currentIndex = -1;
for (let i = 0; i < chapters.length; i++) {
const chapterMs = ticksToMs(chapters[i].StartPositionTicks);
if (chapterMs <= currentMs) {
currentIndex = i;
} else {
break;
}
}
return currentIndex;
}, [chapters, progress]);
// Current chapter index (computed once for rendering)
const currentChapterIndex = getCurrentChapterIndex();
// Current chapter info
const currentChapter = useMemo(() => {
if (currentChapterIndex < 0 || currentChapterIndex >= chapters.length) {
return null;
}
return chapters[currentChapterIndex];
}, [chapters, currentChapterIndex]);
// Navigation availability
const hasNextChapter =
chapters.length > 0 && currentChapterIndex < chapters.length - 1;
const hasPreviousChapter = chapters.length > 0 && currentChapterIndex >= 0;
// Navigate to next chapter
const goToNextChapter = useCallback(() => {
const idx = getCurrentChapterIndex();
if (idx < chapters.length - 1) {
const nextChapter = chapters[idx + 1];
const nextMs = ticksToMs(nextChapter.StartPositionTicks);
progress.value = nextMs;
seek(nextMs);
}
}, [chapters, getCurrentChapterIndex, progress, seek]);
// Navigate to previous chapter (or restart current if >3s in)
const goToPreviousChapter = useCallback(() => {
const idx = getCurrentChapterIndex();
if (idx < 0) return;
const currentChapterMs = ticksToMs(chapters[idx].StartPositionTicks);
const currentMs = progress.value;
const timeIntoChapter = currentMs - currentChapterMs;
// If more than 3 seconds into the current chapter, restart it
// Otherwise, go to the previous chapter
if (timeIntoChapter > RESTART_THRESHOLD_MS && idx >= 0) {
progress.value = currentChapterMs;
seek(currentChapterMs);
} else if (idx > 0) {
const prevChapter = chapters[idx - 1];
const prevMs = ticksToMs(prevChapter.StartPositionTicks);
progress.value = prevMs;
seek(prevMs);
} else {
// At the first chapter, just restart it
progress.value = currentChapterMs;
seek(currentChapterMs);
}
}, [chapters, getCurrentChapterIndex, progress, seek]);
return {
chapters,
currentChapterIndex,
currentChapter,
hasNextChapter,
hasPreviousChapter,
goToNextChapter,
goToPreviousChapter,
chapterPositions,
hasChapters: chapters.length > 0,
};
}