From 98b90f5bdb22768c6a851c0eeebbe23a82dae482 Mon Sep 17 00:00:00 2001 From: Gauvain Date: Wed, 27 May 2026 16:39:50 +0200 Subject: [PATCH] feat(player): wire chapter UI into native player controls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../video-player/controls/BottomControls.tsx | 83 ++++++++++++++++++- components/video-player/controls/Controls.tsx | 4 + .../video-player/controls/TrickplayBubble.tsx | 74 ++++++++++++++--- .../controls/hooks/useVideoSlider.ts | 16 ++++ translations/en.json | 6 ++ 5 files changed, 166 insertions(+), 17 deletions(-) diff --git a/components/video-player/controls/BottomControls.tsx b/components/video-player/controls/BottomControls.tsx index 51abf68c..85e35fc8 100644 --- a/components/video-player/controls/BottomControls.tsx +++ b/components/video-player/controls/BottomControls.tsx @@ -1,18 +1,34 @@ -import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; -import type { FC } from "react"; -import { View } from "react-native"; +import { Ionicons } from "@expo/vector-icons"; +import type { + BaseItemDto, + ChapterInfo, +} from "@jellyfin/sdk/lib/generated-client"; +import { type FC, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Pressable, View } from "react-native"; import { Slider } from "react-native-awesome-slider"; import { type SharedValue } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { ChapterList } from "@/components/chapters/ChapterList"; +import { ChapterTicks } from "@/components/chapters/ChapterTicks"; import { Text } from "@/components/common/Text"; import { useSettings } from "@/utils/atoms/settings"; +import { chapterMarkers, chapterNameAt } from "@/utils/chapters"; import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton"; import SkipButton from "./SkipButton"; import { TimeDisplay } from "./TimeDisplay"; import { TrickplayBubble } from "./TrickplayBubble"; +// Chapter tick height in dp — matches the slider track height for a clean, +// flush look (no top/bottom overflow). +const TICK_HEIGHT = 10; + interface BottomControlsProps { item: BaseItemDto; + /** Item chapters, used for the tick overlay and chapter list. */ + chapters?: ChapterInfo[] | null; + /** Total media duration in milliseconds. */ + durationMs: number; showControls: boolean; isSliding: boolean; showRemoteBubble: boolean; @@ -38,6 +54,8 @@ interface BottomControlsProps { handleSliderChange: (value: number) => void; handleTouchStart: () => void; handleTouchEnd: () => void; + /** Programmatic seek (chapter list, hotkeys) — bypasses slide gesture state. */ + seekTo: (value: number) => void; // Trickplay props trickPlayUrl: { @@ -61,6 +79,8 @@ interface BottomControlsProps { export const BottomControls: FC = ({ item, + chapters, + durationMs, showControls, isSliding, showRemoteBubble, @@ -84,12 +104,38 @@ export const BottomControls: FC = ({ handleSliderChange, handleTouchStart, handleTouchEnd, + seekTo, trickPlayUrl, trickplayInfo, time, }) => { const { settings } = useSettings(); + const { t } = useTranslation(); const insets = useSafeAreaInsets(); + const [chapterListVisible, setChapterListVisible] = useState(false); + + // Only expose chapter UI when there are at least two real markers. + const chapterMarkerList = useMemo( + () => chapterMarkers(chapters, durationMs), + [chapters, durationMs], + ); + const hasChapters = chapterMarkerList.length > 1; + + // Current chapter name for the always-visible header label (live playback). + const currentChapterName = useMemo( + () => (hasChapters ? chapterNameAt(currentTime, chapters) : null), + [hasChapters, currentTime, chapters], + ); + + // Chapter name at the scrubbed position for the trickplay bubble. `time` is + // an {h,m,s} object derived from the slider's dragged value — convert back + // to ms for the lookup. Only useful while actively scrubbing. + const scrubChapterName = useMemo(() => { + if (!hasChapters) return null; + const scrubMs = + (time.hours * 3600 + time.minutes * 60 + time.seconds) * 1000; + return chapterNameAt(scrubMs, chapters); + }, [hasChapters, time.hours, time.minutes, time.seconds, chapters]); return ( = ({ {item?.Type === "Audio" && ( {item?.Album} )} + {currentChapterName ? ( + + {currentChapterName} + + ) : null} - + + {hasChapters && ( + setChapterListVisible(true)} + hitSlop={10} + className='justify-center mr-4' + style={{ marginTop: 10 }} + accessibilityRole='button' + accessibilityLabel={t("chapters.open")} + > + + + )} = ({ height: 10, justifyContent: "center", alignItems: "stretch", + // Allow chapter ticks taller than the 10px track to bleed out + // top/bottom (RN defaults to overflow: "hidden" on Android). + overflow: "visible", }} onTouchStart={handleTouchStart} onTouchEnd={handleTouchEnd} @@ -203,6 +269,7 @@ export const BottomControls: FC = ({ trickPlayUrl={trickPlayUrl} trickplayInfo={trickplayInfo} time={time} + chapterName={scrubChapterName} /> ) } @@ -212,6 +279,7 @@ export const BottomControls: FC = ({ minimumValue={min} maximumValue={max} /> + = ({ /> + setChapterListVisible(false)} + /> ); }; diff --git a/components/video-player/controls/Controls.tsx b/components/video-player/controls/Controls.tsx index 96dfad6b..a13c59a5 100644 --- a/components/video-player/controls/Controls.tsx +++ b/components/video-player/controls/Controls.tsx @@ -251,6 +251,7 @@ export const Controls: FC = ({ handleTouchEnd, handleSliderComplete, handleSliderChange, + seekTo, } = useVideoSlider({ progress, isSeeking, @@ -528,6 +529,8 @@ export const Controls: FC = ({ > = ({ handleSliderChange={handleSliderChange} handleTouchStart={handleTouchStart} handleTouchEnd={handleTouchEnd} + seekTo={seekTo} trickPlayUrl={trickPlayUrl} trickplayInfo={trickplayInfo} time={isSliding || showRemoteBubble ? time : remoteTime} diff --git a/components/video-player/controls/TrickplayBubble.tsx b/components/video-player/controls/TrickplayBubble.tsx index 49645ed2..11c702a0 100644 --- a/components/video-player/controls/TrickplayBubble.tsx +++ b/components/video-player/controls/TrickplayBubble.tsx @@ -22,12 +22,15 @@ interface TrickplayBubbleProps { minutes: number; seconds: number; }; + /** Chapter name at the scrubbed position, if any. */ + chapterName?: string | null; } export const TrickplayBubble: FC = ({ trickPlayUrl, trickplayInfo, time, + chapterName, }) => { if (!trickPlayUrl || !trickplayInfo) { return null; @@ -36,18 +39,29 @@ export const TrickplayBubble: FC = ({ const { x, y, url } = trickPlayUrl; const tileWidth = CONTROLS_CONSTANTS.TILE_WIDTH; const tileHeight = tileWidth / trickplayInfo.aspectRatio!; + const timeStr = `${time.hours > 0 ? `${time.hours}:` : ""}${ + time.minutes < 10 ? `0${time.minutes}` : time.minutes + }:${time.seconds < 10 ? `0${time.seconds}` : time.seconds}`; + + // Slightly larger preview than before (scale 1.6 vs old 1.4) to give the + // overlay text more room and feel closer to the Jellyfin web style. + const previewScale = 1.6; return ( = ({ width: tileWidth, height: tileHeight, alignSelf: "center", - transform: [{ scale: 1.4 }], + transform: [{ scale: previewScale }], borderRadius: 5, }} className='bg-neutral-800 overflow-hidden' @@ -75,17 +89,51 @@ export const TrickplayBubble: FC = ({ source={{ uri: url }} contentFit='cover' /> + {/* + * Bottom-right overlay (Jellyfin web style) — chapter name (small, + * faded) above the timestamp (small, bold). Sits on top of the + * trickplay frame inside the same overflow:hidden container so it + * always stays within the bubble bounds. + */} + + {chapterName ? ( + + {chapterName} + + ) : null} + + {timeStr} + + - - {`${time.hours > 0 ? `${time.hours}:` : ""}${ - time.minutes < 10 ? `0${time.minutes}` : time.minutes - }:${time.seconds < 10 ? `0${time.seconds}` : time.seconds}`} - ); }; diff --git a/components/video-player/controls/hooks/useVideoSlider.ts b/components/video-player/controls/hooks/useVideoSlider.ts index dfc1164b..3c19ce7a 100644 --- a/components/video-player/controls/hooks/useVideoSlider.ts +++ b/components/video-player/controls/hooks/useVideoSlider.ts @@ -74,6 +74,21 @@ export function useVideoSlider({ [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 @@ -96,5 +111,6 @@ export function useVideoSlider({ handleTouchEnd, handleSliderComplete, handleSliderChange, + seekTo, }; } diff --git a/translations/en.json b/translations/en.json index 3fe9efb6..dfad3bc0 100644 --- a/translations/en.json +++ b/translations/en.json @@ -610,6 +610,12 @@ "downloaded_file_no": "No", "downloaded_file_cancel": "Cancel" }, + "chapters": { + "title": "Chapters", + "chapter_number": "Chapter {{number}}", + "open": "Open chapters", + "close": "Close chapters" + }, "item_card": { "next_up": "Next Up", "no_items_to_display": "No Items to Display",