mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-02-02 00:18:08 +00:00
feat(player): add chapter navigation support with visual markers
This commit is contained in:
@@ -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 }],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
65
components/video-player/controls/ChapterMarkers.tsx
Normal file
65
components/video-player/controls/ChapterMarkers.tsx
Normal 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
|
||||
},
|
||||
});
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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 }],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export { useChapterNavigation } from "./useChapterNavigation";
|
||||
export { useRemoteControl } from "./useRemoteControl";
|
||||
export { useVideoNavigation } from "./useVideoNavigation";
|
||||
export { useVideoSlider } from "./useVideoSlider";
|
||||
|
||||
150
components/video-player/controls/hooks/useChapterNavigation.ts
Normal file
150
components/video-player/controls/hooks/useChapterNavigation.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user