mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-05-28 17:48:26 +01:00
Threads chapters + duration through Controls -> BottomControls so the ChapterTicks overlay, ChapterList modal and current-chapter label have the data they need. BottomControls - Memoizes chapterMarkerList (markers within the media duration) once per (chapters, durationMs) change and feeds it to ChapterTicks. - hasChapters gates the bookmark icon + list modal; nothing renders when chapters are missing or below two real markers. - Current chapter name shown as a small label below the title/year during playback; the same helper feeds the trickplay bubble while scrubbing. Both labels disappear gracefully when chapters are absent. useVideoSlider - Adds seekTo(value): a programmatic seek for non-gesture entry points (chapter list, hot-keys). Reads isPlaying directly instead of wasPlayingRef — which is only populated inside handleSliderStart, so a tap-to-seek on the chapter list previously either stranded paused playback or auto-resumed against a manual pause. TrickplayBubble - Adds an optional chapterName prop; renders a small left-aligned overlay inside the preview frame (Jellyfin web style) showing chapter name above the timestamp. Hides the chapter line entirely when null. - zIndex + elevation so the bubble lands in front of the title / surrounding overlays. - Slight reposition (bottom -20, paddingTop 12) brings the bubble closer to the slider. translations/en.json - chapters.title / chapters.chapter_number / chapters.open / chapters.close keys for the list modal and the bookmark a11y label.
117 lines
3.1 KiB
TypeScript
117 lines
3.1 KiB
TypeScript
import { debounce } from "lodash";
|
|
import { useCallback, useRef, useState } from "react";
|
|
import type { SharedValue } from "react-native-reanimated";
|
|
import { msToTicks, ticksToSeconds } from "@/utils/time";
|
|
import { CONTROLS_CONSTANTS } from "../constants";
|
|
|
|
interface UseVideoSliderProps {
|
|
progress: SharedValue<number>;
|
|
isSeeking: SharedValue<boolean>;
|
|
isPlaying: boolean;
|
|
seek: (value: number) => void;
|
|
play: () => void;
|
|
pause: () => void;
|
|
calculateTrickplayUrl: (progressInTicks: number) => void;
|
|
showControls: boolean;
|
|
}
|
|
|
|
/**
|
|
* Hook to manage video slider interactions.
|
|
* MPV player uses milliseconds for time values.
|
|
*/
|
|
export function useVideoSlider({
|
|
progress,
|
|
isSeeking,
|
|
isPlaying,
|
|
seek,
|
|
play,
|
|
pause,
|
|
calculateTrickplayUrl,
|
|
showControls,
|
|
}: UseVideoSliderProps) {
|
|
const [isSliding, setIsSliding] = useState(false);
|
|
const [time, setTime] = useState({ hours: 0, minutes: 0, seconds: 0 });
|
|
const wasPlayingRef = useRef(false);
|
|
const lastProgressRef = useRef<number>(0);
|
|
|
|
const handleSliderStart = useCallback(() => {
|
|
if (!showControls) {
|
|
return;
|
|
}
|
|
|
|
setIsSliding(true);
|
|
wasPlayingRef.current = isPlaying;
|
|
lastProgressRef.current = progress.value;
|
|
|
|
pause();
|
|
isSeeking.value = true;
|
|
}, [showControls, isPlaying, pause, progress, isSeeking]);
|
|
|
|
const handleTouchStart = useCallback(() => {
|
|
if (!showControls) {
|
|
return;
|
|
}
|
|
}, [showControls]);
|
|
|
|
const handleTouchEnd = useCallback(() => {
|
|
if (!showControls) {
|
|
return;
|
|
}
|
|
}, [showControls]);
|
|
|
|
const handleSliderComplete = useCallback(
|
|
async (value: number) => {
|
|
setIsSliding(false);
|
|
isSeeking.value = false;
|
|
progress.value = value;
|
|
// MPV uses ms, seek expects ms
|
|
const seekValue = Math.max(0, Math.floor(value));
|
|
seek(seekValue);
|
|
if (wasPlayingRef.current) {
|
|
play();
|
|
}
|
|
},
|
|
[seek, play, progress, isSeeking],
|
|
);
|
|
|
|
// Programmatic seek (chapter list, hotkeys) that bypasses the slide gesture.
|
|
// Reads `isPlaying` directly instead of `wasPlayingRef`, which is only set
|
|
// during a real slide and would carry stale state on a tap-to-seek.
|
|
const seekTo = useCallback(
|
|
(value: number) => {
|
|
const seekValue = Math.max(0, Math.floor(value));
|
|
progress.value = seekValue;
|
|
seek(seekValue);
|
|
if (isPlaying) {
|
|
play();
|
|
}
|
|
},
|
|
[seek, play, progress, isPlaying],
|
|
);
|
|
|
|
const handleSliderChange = useCallback(
|
|
debounce((value: number) => {
|
|
// Convert ms to ticks for trickplay
|
|
const progressInTicks = msToTicks(value);
|
|
calculateTrickplayUrl(progressInTicks);
|
|
const progressInSeconds = Math.floor(ticksToSeconds(progressInTicks));
|
|
const hours = Math.floor(progressInSeconds / 3600);
|
|
const minutes = Math.floor((progressInSeconds % 3600) / 60);
|
|
const seconds = progressInSeconds % 60;
|
|
setTime({ hours, minutes, seconds });
|
|
}, CONTROLS_CONSTANTS.SLIDER_DEBOUNCE_MS),
|
|
[calculateTrickplayUrl],
|
|
);
|
|
|
|
return {
|
|
isSliding,
|
|
time,
|
|
handleSliderStart,
|
|
handleTouchStart,
|
|
handleTouchEnd,
|
|
handleSliderComplete,
|
|
handleSliderChange,
|
|
seekTo,
|
|
};
|
|
}
|