feat(player): wire chapter UI into native player controls

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.
This commit is contained in:
Gauvain
2026-05-27 16:39:50 +02:00
parent 5f64ce49c0
commit 98b90f5bdb
5 changed files with 166 additions and 17 deletions

View File

@@ -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<BottomControlsProps> = ({
item,
chapters,
durationMs,
showControls,
isSliding,
showRemoteBubble,
@@ -84,12 +104,38 @@ export const BottomControls: FC<BottomControlsProps> = ({
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 (
<View
@@ -131,8 +177,25 @@ export const BottomControls: FC<BottomControlsProps> = ({
{item?.Type === "Audio" && (
<Text className='text-xs opacity-50'>{item?.Album}</Text>
)}
{currentChapterName ? (
<Text className='text-xs opacity-70 mt-1' numberOfLines={1}>
{currentChapterName}
</Text>
) : null}
</View>
<View className='flex flex-row space-x-2 shrink-0'>
<View className='flex flex-row items-center space-x-2 shrink-0'>
{hasChapters && (
<Pressable
onPress={() => setChapterListVisible(true)}
hitSlop={10}
className='justify-center mr-4'
style={{ marginTop: 10 }}
accessibilityRole='button'
accessibilityLabel={t("chapters.open")}
>
<Ionicons name='bookmarks' size={24} color='white' />
</Pressable>
)}
<SkipButton
showButton={showSkipButton}
onPress={skipIntro}
@@ -176,6 +239,9 @@ export const BottomControls: FC<BottomControlsProps> = ({
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<BottomControlsProps> = ({
trickPlayUrl={trickPlayUrl}
trickplayInfo={trickplayInfo}
time={time}
chapterName={scrubChapterName}
/>
)
}
@@ -212,6 +279,7 @@ export const BottomControls: FC<BottomControlsProps> = ({
minimumValue={min}
maximumValue={max}
/>
<ChapterTicks markers={chapterMarkerList} height={TICK_HEIGHT} />
</View>
<TimeDisplay
currentTime={currentTime}
@@ -219,6 +287,13 @@ export const BottomControls: FC<BottomControlsProps> = ({
/>
</View>
</View>
<ChapterList
visible={chapterListVisible}
chapters={chapters}
currentPositionMs={currentTime}
onSeek={seekTo}
onClose={() => setChapterListVisible(false)}
/>
</View>
);
};

View File

@@ -251,6 +251,7 @@ export const Controls: FC<Props> = ({
handleTouchEnd,
handleSliderComplete,
handleSliderChange,
seekTo,
} = useVideoSlider({
progress,
isSeeking,
@@ -528,6 +529,8 @@ export const Controls: FC<Props> = ({
>
<BottomControls
item={item}
chapters={item.Chapters}
durationMs={maxMs}
showControls={showControls}
isSliding={isSliding}
showRemoteBubble={showRemoteBubble}
@@ -551,6 +554,7 @@ export const Controls: FC<Props> = ({
handleSliderChange={handleSliderChange}
handleTouchStart={handleTouchStart}
handleTouchEnd={handleTouchEnd}
seekTo={seekTo}
trickPlayUrl={trickPlayUrl}
trickplayInfo={trickplayInfo}
time={isSliding || showRemoteBubble ? time : remoteTime}

View File

@@ -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<TrickplayBubbleProps> = ({
trickPlayUrl,
trickplayInfo,
time,
chapterName,
}) => {
if (!trickPlayUrl || !trickplayInfo) {
return null;
@@ -36,18 +39,29 @@ export const TrickplayBubble: FC<TrickplayBubbleProps> = ({
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 (
<View
style={{
position: "absolute",
left: -62,
bottom: 0,
paddingTop: 30,
// Drop the bubble closer to the slider — less floating-high feel.
bottom: -20,
paddingTop: 12,
paddingBottom: 5,
width: tileWidth * 1.5,
justifyContent: "center",
alignItems: "center",
// Bring the bubble in front of the player title / overlays.
zIndex: 999,
elevation: 10,
}}
>
<View
@@ -55,7 +69,7 @@ export const TrickplayBubble: FC<TrickplayBubbleProps> = ({
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<TrickplayBubbleProps> = ({
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.
*/}
<View
pointerEvents='none'
style={{
position: "absolute",
left: 4,
bottom: 3,
alignItems: "flex-start",
paddingHorizontal: 3,
paddingVertical: 1,
borderRadius: 3,
backgroundColor: "rgba(0,0,0,0.55)",
maxWidth: tileWidth - 8,
}}
>
{chapterName ? (
<Text
numberOfLines={1}
style={{
color: "#fff",
fontSize: 7,
opacity: 0.85,
lineHeight: 9,
}}
>
{chapterName}
</Text>
) : null}
<Text
style={{
color: "#fff",
fontSize: 8,
fontWeight: "600",
lineHeight: 10,
}}
>
{timeStr}
</Text>
</View>
</View>
<Text
style={{
marginTop: 30,
fontSize: 16,
}}
>
{`${time.hours > 0 ? `${time.hours}:` : ""}${
time.minutes < 10 ? `0${time.minutes}` : time.minutes
}:${time.seconds < 10 ? `0${time.seconds}` : time.seconds}`}
</Text>
</View>
);
};

View File

@@ -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,
};
}