diff --git a/components/tv/TVFocusableProgressBar.tsx b/components/tv/TVFocusableProgressBar.tsx index e33e4444..8d6a4888 100644 --- a/components/tv/TVFocusableProgressBar.tsx +++ b/components/tv/TVFocusableProgressBar.tsx @@ -19,6 +19,8 @@ export interface TVFocusableProgressBarProps { max: SharedValue; /** Cache progress value (SharedValue) in milliseconds */ cacheProgress?: SharedValue; + /** 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 = progress, max, cacheProgress, + chapterPositions = [], onFocus, onBlur, refSetter, @@ -81,20 +84,36 @@ export const TVFocusableProgressBar: React.FC = focused && styles.animatedContainerFocused, ]} > - - {cacheProgress && ( + + + {cacheProgress && ( + + )} + + {/* Chapter markers - positioned outside track to extend above */} + {chapterPositions.length > 0 && ( + + {chapterPositions.map((position, index) => ( + + ))} + )} - @@ -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 }], + }, }); diff --git a/components/video-player/controls/BottomControls.tsx b/components/video-player/controls/BottomControls.tsx index 51abf68c..e4f26492 100644 --- a/components/video-player/controls/BottomControls.tsx +++ b/components/video-player/controls/BottomControls.tsx @@ -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 = ({ @@ -87,6 +91,7 @@ export const BottomControls: FC = ({ trickPlayUrl, trickplayInfo, time, + chapterPositions = [], }) => { const { settings } = useSettings(); const insets = useSafeAreaInsets(); @@ -176,6 +181,7 @@ export const BottomControls: FC = ({ height: 10, justifyContent: "center", alignItems: "stretch", + position: "relative", }} onTouchStart={handleTouchStart} onTouchEnd={handleTouchEnd} @@ -212,6 +218,7 @@ export const BottomControls: FC = ({ minimumValue={min} maximumValue={max} /> + void; handleSkipBackward: () => void; handleSkipForward: () => void; + // Chapter navigation props + hasChapters?: boolean; + hasPreviousChapter?: boolean; + hasNextChapter?: boolean; + goToPreviousChapter?: () => void; + goToNextChapter?: () => void; } export const CenterControls: FC = ({ @@ -29,6 +35,11 @@ export const CenterControls: FC = ({ 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 = ({ )} + {!Platform.isTV && hasChapters && ( + + + + )} + {!isBuffering ? ( @@ -108,6 +133,20 @@ export const CenterControls: FC = ({ + {!Platform.isTV && hasChapters && ( + + + + )} + {!Platform.isTV && ( 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 = React.memo( + ({ + chapterPositions, + style, + markerHeight = 15, + markerColor = "rgba(255, 255, 255, 0.6)", + }) => { + if (!chapterPositions.length) { + return null; + } + + return ( + + {chapterPositions.map((position, index) => ( + + ))} + + ); + }, +); + +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 + }, +}); diff --git a/components/video-player/controls/Controls.tsx b/components/video-player/controls/Controls.tsx index 1f1b37cc..de6326ea 100644 --- a/components/video-player/controls/Controls.tsx +++ b/components/video-player/controls/Controls.tsx @@ -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 = ({ 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 = ({ togglePlay={togglePlay} handleSkipBackward={handleSkipBackward} handleSkipForward={handleSkipForward} + hasChapters={hasChapters} + hasPreviousChapter={hasPreviousChapter} + hasNextChapter={hasNextChapter} + goToPreviousChapter={goToPreviousChapter} + goToNextChapter={goToNextChapter} /> = ({ trickPlayUrl={trickPlayUrl} trickplayInfo={trickplayInfo} time={isSliding || showRemoteBubble ? time : remoteTime} + chapterPositions={chapterPositions} /> diff --git a/components/video-player/controls/Controls.tv.tsx b/components/video-player/controls/Controls.tv.tsx index 42b584a0..69b91790 100644 --- a/components/video-player/controls/Controls.tv.tsx +++ b/components/video-player/controls/Controls.tv.tsx @@ -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 = ({ 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 = ({ - - ({ - width: `${max.value > 0 ? (cacheProgress.value / max.value) * 100 : 0}%`, - })), - ]} - /> - ({ - width: `${max.value > 0 ? (effectiveProgress.value / max.value) * 100 : 0}%`, - })), - ]} - /> + + + ({ + width: `${max.value > 0 ? (cacheProgress.value / max.value) * 100 : 0}%`, + })), + ]} + /> + ({ + width: `${max.value > 0 ? (effectiveProgress.value / max.value) * 100 : 0}%`, + })), + ]} + /> + + {/* Chapter markers */} + {chapterPositions.length > 0 && ( + + {chapterPositions.map((position, index) => ( + + ))} + + )} @@ -1135,6 +1172,14 @@ export const Controls: FC = ({ disabled={!previousItem} size={28} /> + {hasChapters && ( + + )} = ({ lastOpenedModal === null } /> + {hasChapters && ( + + )} = ({ 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 }], + }, }); diff --git a/components/video-player/controls/hooks/index.ts b/components/video-player/controls/hooks/index.ts index 08b234ac..cfb31759 100644 --- a/components/video-player/controls/hooks/index.ts +++ b/components/video-player/controls/hooks/index.ts @@ -1,3 +1,4 @@ +export { useChapterNavigation } from "./useChapterNavigation"; export { useRemoteControl } from "./useRemoteControl"; export { useVideoNavigation } from "./useVideoNavigation"; export { useVideoSlider } from "./useVideoSlider"; diff --git a/components/video-player/controls/hooks/useChapterNavigation.ts b/components/video-player/controls/hooks/useChapterNavigation.ts new file mode 100644 index 00000000..00d3330c --- /dev/null +++ b/components/video-player/controls/hooks/useChapterNavigation.ts @@ -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; + /** 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, + }; +}