mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-07 14:38:35 +01: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>;
|
max: SharedValue<number>;
|
||||||
/** Cache progress value (SharedValue) in milliseconds */
|
/** Cache progress value (SharedValue) in milliseconds */
|
||||||
cacheProgress?: SharedValue<number>;
|
cacheProgress?: SharedValue<number>;
|
||||||
|
/** Chapter positions as percentages (0-100) for tick marks */
|
||||||
|
chapterPositions?: number[];
|
||||||
/** Callback when the progress bar receives focus */
|
/** Callback when the progress bar receives focus */
|
||||||
onFocus?: () => void;
|
onFocus?: () => void;
|
||||||
/** Callback when the progress bar loses focus */
|
/** Callback when the progress bar loses focus */
|
||||||
@@ -41,6 +43,7 @@ export const TVFocusableProgressBar: React.FC<TVFocusableProgressBarProps> =
|
|||||||
progress,
|
progress,
|
||||||
max,
|
max,
|
||||||
cacheProgress,
|
cacheProgress,
|
||||||
|
chapterPositions = [],
|
||||||
onFocus,
|
onFocus,
|
||||||
onBlur,
|
onBlur,
|
||||||
refSetter,
|
refSetter,
|
||||||
@@ -81,20 +84,36 @@ export const TVFocusableProgressBar: React.FC<TVFocusableProgressBarProps> =
|
|||||||
focused && styles.animatedContainerFocused,
|
focused && styles.animatedContainerFocused,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<View
|
<View style={styles.progressTrackWrapper}>
|
||||||
style={[
|
<View
|
||||||
styles.progressTrack,
|
style={[
|
||||||
focused && styles.progressTrackFocused,
|
styles.progressTrack,
|
||||||
]}
|
focused && styles.progressTrackFocused,
|
||||||
>
|
]}
|
||||||
{cacheProgress && (
|
>
|
||||||
|
{cacheProgress && (
|
||||||
|
<ReanimatedView
|
||||||
|
style={[styles.cacheProgress, cacheProgressStyle]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<ReanimatedView
|
<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>
|
</View>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
@@ -121,6 +140,10 @@ const styles = StyleSheet.create({
|
|||||||
shadowOpacity: 0.5,
|
shadowOpacity: 0.5,
|
||||||
shadowRadius: 12,
|
shadowRadius: 12,
|
||||||
},
|
},
|
||||||
|
progressTrackWrapper: {
|
||||||
|
position: "relative",
|
||||||
|
height: PROGRESS_BAR_HEIGHT,
|
||||||
|
},
|
||||||
progressTrack: {
|
progressTrack: {
|
||||||
height: PROGRESS_BAR_HEIGHT,
|
height: PROGRESS_BAR_HEIGHT,
|
||||||
backgroundColor: "rgba(255,255,255,0.2)",
|
backgroundColor: "rgba(255,255,255,0.2)",
|
||||||
@@ -147,4 +170,20 @@ const styles = StyleSheet.create({
|
|||||||
backgroundColor: "#fff",
|
backgroundColor: "#fff",
|
||||||
borderRadius: 8,
|
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 { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { ChapterMarkers } from "./ChapterMarkers";
|
||||||
import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton";
|
import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton";
|
||||||
import SkipButton from "./SkipButton";
|
import SkipButton from "./SkipButton";
|
||||||
import { TimeDisplay } from "./TimeDisplay";
|
import { TimeDisplay } from "./TimeDisplay";
|
||||||
@@ -57,6 +58,9 @@ interface BottomControlsProps {
|
|||||||
minutes: number;
|
minutes: number;
|
||||||
seconds: number;
|
seconds: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Chapter props
|
||||||
|
chapterPositions?: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BottomControls: FC<BottomControlsProps> = ({
|
export const BottomControls: FC<BottomControlsProps> = ({
|
||||||
@@ -87,6 +91,7 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
|||||||
trickPlayUrl,
|
trickPlayUrl,
|
||||||
trickplayInfo,
|
trickplayInfo,
|
||||||
time,
|
time,
|
||||||
|
chapterPositions = [],
|
||||||
}) => {
|
}) => {
|
||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
@@ -176,6 +181,7 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
|||||||
height: 10,
|
height: 10,
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
alignItems: "stretch",
|
alignItems: "stretch",
|
||||||
|
position: "relative",
|
||||||
}}
|
}}
|
||||||
onTouchStart={handleTouchStart}
|
onTouchStart={handleTouchStart}
|
||||||
onTouchEnd={handleTouchEnd}
|
onTouchEnd={handleTouchEnd}
|
||||||
@@ -212,6 +218,7 @@ export const BottomControls: FC<BottomControlsProps> = ({
|
|||||||
minimumValue={min}
|
minimumValue={min}
|
||||||
maximumValue={max}
|
maximumValue={max}
|
||||||
/>
|
/>
|
||||||
|
<ChapterMarkers chapterPositions={chapterPositions} />
|
||||||
</View>
|
</View>
|
||||||
<TimeDisplay
|
<TimeDisplay
|
||||||
currentTime={currentTime}
|
currentTime={currentTime}
|
||||||
|
|||||||
@@ -18,6 +18,12 @@ interface CenterControlsProps {
|
|||||||
togglePlay: () => void;
|
togglePlay: () => void;
|
||||||
handleSkipBackward: () => void;
|
handleSkipBackward: () => void;
|
||||||
handleSkipForward: () => void;
|
handleSkipForward: () => void;
|
||||||
|
// Chapter navigation props
|
||||||
|
hasChapters?: boolean;
|
||||||
|
hasPreviousChapter?: boolean;
|
||||||
|
hasNextChapter?: boolean;
|
||||||
|
goToPreviousChapter?: () => void;
|
||||||
|
goToNextChapter?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CenterControls: FC<CenterControlsProps> = ({
|
export const CenterControls: FC<CenterControlsProps> = ({
|
||||||
@@ -29,6 +35,11 @@ export const CenterControls: FC<CenterControlsProps> = ({
|
|||||||
togglePlay,
|
togglePlay,
|
||||||
handleSkipBackward,
|
handleSkipBackward,
|
||||||
handleSkipForward,
|
handleSkipForward,
|
||||||
|
hasChapters = false,
|
||||||
|
hasPreviousChapter = false,
|
||||||
|
hasNextChapter = false,
|
||||||
|
goToPreviousChapter,
|
||||||
|
goToNextChapter,
|
||||||
}) => {
|
}) => {
|
||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
@@ -94,6 +105,20 @@ export const CenterControls: FC<CenterControlsProps> = ({
|
|||||||
</TouchableOpacity>
|
</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" } : {}}>
|
<View style={Platform.isTV ? { flex: 1, alignItems: "center" } : {}}>
|
||||||
<TouchableOpacity onPress={togglePlay}>
|
<TouchableOpacity onPress={togglePlay}>
|
||||||
{!isBuffering ? (
|
{!isBuffering ? (
|
||||||
@@ -108,6 +133,20 @@ export const CenterControls: FC<CenterControlsProps> = ({
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</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 && (
|
{!Platform.isTV && (
|
||||||
<TouchableOpacity onPress={handleSkipForward}>
|
<TouchableOpacity onPress={handleSkipForward}>
|
||||||
<View
|
<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 { EpisodeList } from "./EpisodeList";
|
||||||
import { GestureOverlay } from "./GestureOverlay";
|
import { GestureOverlay } from "./GestureOverlay";
|
||||||
import { HeaderControls } from "./HeaderControls";
|
import { HeaderControls } from "./HeaderControls";
|
||||||
|
import { useChapterNavigation } from "./hooks/useChapterNavigation";
|
||||||
import { useRemoteControl } from "./hooks/useRemoteControl";
|
import { useRemoteControl } from "./hooks/useRemoteControl";
|
||||||
import { useVideoNavigation } from "./hooks/useVideoNavigation";
|
import { useVideoNavigation } from "./hooks/useVideoNavigation";
|
||||||
import { useVideoSlider } from "./hooks/useVideoSlider";
|
import { useVideoSlider } from "./hooks/useVideoSlider";
|
||||||
@@ -211,6 +212,21 @@ export const Controls: FC<Props> = ({
|
|||||||
isSeeking,
|
isSeeking,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Chapter navigation hook
|
||||||
|
const {
|
||||||
|
hasChapters,
|
||||||
|
hasPreviousChapter,
|
||||||
|
hasNextChapter,
|
||||||
|
goToPreviousChapter,
|
||||||
|
goToNextChapter,
|
||||||
|
chapterPositions,
|
||||||
|
} = useChapterNavigation({
|
||||||
|
chapters: item.Chapters,
|
||||||
|
progress,
|
||||||
|
maxMs,
|
||||||
|
seek,
|
||||||
|
});
|
||||||
|
|
||||||
const toggleControls = useCallback(() => {
|
const toggleControls = useCallback(() => {
|
||||||
if (showControls) {
|
if (showControls) {
|
||||||
setShowAudioSlider(false);
|
setShowAudioSlider(false);
|
||||||
@@ -526,6 +542,11 @@ export const Controls: FC<Props> = ({
|
|||||||
togglePlay={togglePlay}
|
togglePlay={togglePlay}
|
||||||
handleSkipBackward={handleSkipBackward}
|
handleSkipBackward={handleSkipBackward}
|
||||||
handleSkipForward={handleSkipForward}
|
handleSkipForward={handleSkipForward}
|
||||||
|
hasChapters={hasChapters}
|
||||||
|
hasPreviousChapter={hasPreviousChapter}
|
||||||
|
hasNextChapter={hasNextChapter}
|
||||||
|
goToPreviousChapter={goToPreviousChapter}
|
||||||
|
goToNextChapter={goToNextChapter}
|
||||||
/>
|
/>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
<Animated.View
|
<Animated.View
|
||||||
@@ -560,6 +581,7 @@ export const Controls: FC<Props> = ({
|
|||||||
trickPlayUrl={trickPlayUrl}
|
trickPlayUrl={trickPlayUrl}
|
||||||
trickplayInfo={trickplayInfo}
|
trickplayInfo={trickplayInfo}
|
||||||
time={isSliding || showRemoteBubble ? time : remoteTime}
|
time={isSliding || showRemoteBubble ? time : remoteTime}
|
||||||
|
chapterPositions={chapterPositions}
|
||||||
/>
|
/>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings"
|
|||||||
import { formatTimeString, msToTicks, ticksToMs } from "@/utils/time";
|
import { formatTimeString, msToTicks, ticksToMs } from "@/utils/time";
|
||||||
import { CONTROLS_CONSTANTS } from "./constants";
|
import { CONTROLS_CONSTANTS } from "./constants";
|
||||||
import { useVideoContext } from "./contexts/VideoContext";
|
import { useVideoContext } from "./contexts/VideoContext";
|
||||||
|
import { useChapterNavigation } from "./hooks/useChapterNavigation";
|
||||||
import { useRemoteControl } from "./hooks/useRemoteControl";
|
import { useRemoteControl } from "./hooks/useRemoteControl";
|
||||||
import { useVideoTime } from "./hooks/useVideoTime";
|
import { useVideoTime } from "./hooks/useVideoTime";
|
||||||
import { TechnicalInfoOverlay } from "./TechnicalInfoOverlay";
|
import { TechnicalInfoOverlay } from "./TechnicalInfoOverlay";
|
||||||
@@ -375,6 +376,21 @@ export const Controls: FC<Props> = ({
|
|||||||
isSeeking,
|
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
|
// Countdown logic - needs to be early so toggleControls can reference it
|
||||||
const isCountdownActive = useMemo(() => {
|
const isCountdownActive = useMemo(() => {
|
||||||
if (!nextItem) return false;
|
if (!nextItem) return false;
|
||||||
@@ -1038,23 +1054,44 @@ export const Controls: FC<Props> = ({
|
|||||||
<View
|
<View
|
||||||
style={[styles.progressBarContainer, styles.minimalProgressGlow]}
|
style={[styles.progressBarContainer, styles.minimalProgressGlow]}
|
||||||
>
|
>
|
||||||
<View style={[styles.progressTrack, styles.minimalProgressTrack]}>
|
<View style={styles.minimalProgressTrackWrapper}>
|
||||||
<Animated.View
|
<View
|
||||||
style={[
|
style={[styles.progressTrack, styles.minimalProgressTrack]}
|
||||||
styles.cacheProgress,
|
>
|
||||||
useAnimatedStyle(() => ({
|
<Animated.View
|
||||||
width: `${max.value > 0 ? (cacheProgress.value / max.value) * 100 : 0}%`,
|
style={[
|
||||||
})),
|
styles.cacheProgress,
|
||||||
]}
|
useAnimatedStyle(() => ({
|
||||||
/>
|
width: `${max.value > 0 ? (cacheProgress.value / max.value) * 100 : 0}%`,
|
||||||
<Animated.View
|
})),
|
||||||
style={[
|
]}
|
||||||
styles.progressFill,
|
/>
|
||||||
useAnimatedStyle(() => ({
|
<Animated.View
|
||||||
width: `${max.value > 0 ? (effectiveProgress.value / max.value) * 100 : 0}%`,
|
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>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -1135,6 +1172,14 @@ export const Controls: FC<Props> = ({
|
|||||||
disabled={!previousItem}
|
disabled={!previousItem}
|
||||||
size={28}
|
size={28}
|
||||||
/>
|
/>
|
||||||
|
{hasChapters && (
|
||||||
|
<TVControlButton
|
||||||
|
icon='play-back'
|
||||||
|
onPress={goToPreviousChapter}
|
||||||
|
disabled={!hasPreviousChapter}
|
||||||
|
size={24}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<TVControlButton
|
<TVControlButton
|
||||||
icon={isPlaying ? "pause" : "play"}
|
icon={isPlaying ? "pause" : "play"}
|
||||||
onPress={handlePlayPauseButton}
|
onPress={handlePlayPauseButton}
|
||||||
@@ -1146,6 +1191,14 @@ export const Controls: FC<Props> = ({
|
|||||||
lastOpenedModal === null
|
lastOpenedModal === null
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
{hasChapters && (
|
||||||
|
<TVControlButton
|
||||||
|
icon='play-forward'
|
||||||
|
onPress={goToNextChapter}
|
||||||
|
disabled={!hasNextChapter}
|
||||||
|
size={24}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<TVControlButton
|
<TVControlButton
|
||||||
icon='play-skip-forward'
|
icon='play-skip-forward'
|
||||||
onPress={handleNextItemButton}
|
onPress={handleNextItemButton}
|
||||||
@@ -1221,6 +1274,7 @@ export const Controls: FC<Props> = ({
|
|||||||
progress={effectiveProgress}
|
progress={effectiveProgress}
|
||||||
max={max}
|
max={max}
|
||||||
cacheProgress={cacheProgress}
|
cacheProgress={cacheProgress}
|
||||||
|
chapterPositions={chapterPositions}
|
||||||
onFocus={() => setIsProgressBarFocused(true)}
|
onFocus={() => setIsProgressBarFocused(true)}
|
||||||
onBlur={() => setIsProgressBarFocused(false)}
|
onBlur={() => setIsProgressBarFocused(false)}
|
||||||
refSetter={setProgressBarRef}
|
refSetter={setProgressBarRef}
|
||||||
@@ -1310,7 +1364,7 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
trickplayBubbleContainer: {
|
trickplayBubbleContainer: {
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
bottom: 170,
|
bottom: 190,
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
zIndex: 20,
|
zIndex: 20,
|
||||||
@@ -1392,4 +1446,24 @@ const styles = StyleSheet.create({
|
|||||||
// Brighter track like focused state
|
// Brighter track like focused state
|
||||||
backgroundColor: "rgba(255,255,255,0.35)",
|
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 { useRemoteControl } from "./useRemoteControl";
|
||||||
export { useVideoNavigation } from "./useVideoNavigation";
|
export { useVideoNavigation } from "./useVideoNavigation";
|
||||||
export { useVideoSlider } from "./useVideoSlider";
|
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