Compare commits

..

5 Commits

Author SHA1 Message Date
Gauvain
e3f4eea132 fix(chapters): address review findings + trickplay polish
Copilot + CodeRabbit review findings:
- React.memo ChapterTicks and ChapterList (project guideline: hot-path
  components must use React.memo to cut redraw work during control
  updates).
- chapterNameAt now sorts the chapter array once instead of twice per
  call. The previous version went through currentChapterIndex
  (chapterStartsMs + sort) then sortedChapters (sort again). Runs on
  every playback tick, so the duplicate work added up.
- Import getUserLibraryApi from the public barrel
  (@jellyfin/sdk/lib/utils/api) instead of the deep internal path
  (@jellyfin/sdk/lib/utils/api/user-library-api) to match the rest of
  the codebase and avoid coupling to SDK file layout.

TrickplayBubble polish:
- Sit just above the slider (bottom: 0) so the bubble no longer overlaps
  the progress bar.
- Move the chapter-name + timestamp overlay to the bottom-left of the
  preview frame, smaller font, in front of the surrounding overlays
  (zIndex + elevation).

BottomControls cleanup:
- Drop dev-only "pick one to test" comment in favour of a one-line note
  on TICK_HEIGHT.
- Inline scrubMs into its useMemo callback so the scrub-chapter-name
  lookup only recomputes while a slide is active.
2026-05-27 20:08:21 +02:00
Gauvain
558fb41833 fix(downloads): refresh BaseItemDto for Chapters before queuing offline
Some screens fetch items with a fields filter (e.g. series detail uses
["MediaSources", "MediaStreams", "Overview", "Trickplay"]) and pass the
resulting DTO straight into the download flow. Jellyfin only returns
Chapters in that case when explicitly listed, so the snapshot we store
under DownloadedItem.item carries Chapters: undefined and the offline
player renders no ticks / list / current-chapter label.

initiateDownload now re-fetches the item via getUserLibraryApi.getItem
(no fields filter -> full DTO incl. Chapters) when Chapters is missing,
and uses the enriched item for both getDownloadUrl and the snapshot. If
the refresh call fails we log and fall back to the original item — the
download itself still proceeds.

Trickplay offline already worked (useTrickplay reads trickPlayData.path
from the downloaded sheets).
2026-05-27 16:52:08 +02:00
Gauvain
98b90f5bdb 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.
2026-05-27 16:39:50 +02:00
Gauvain
5f64ce49c0 feat(chapters): add ChapterTicks overlay and ChapterList modal
Two presentational components, both player-agnostic:

ChapterTicks — absolute overlay that draws tick marks on the progress
slider, one per chapter start (skipping the leading 0ms marker).

- Reads markers from a memoized prop computed by the caller so the
  filter/sort runs at most once per chapters change, not per render.
- Snaps tick position AND width to the device pixel grid via
  PixelRatio.roundToNearestPixel(). Without this, fractional dp values
  land at different sub-pixel fractions on non-integer density displays
  (420dpi -> 2.625x ratio) and Android anti-aliases each tick
  differently, making some look visibly thicker than others.
- Tick colour defaults to rgba(0,0,0,0.55), contrasting against both
  the filled progress (#fff) and the unfilled track so ticks stay
  visible as playback advances.
- pointerEvents="none" so the slider underneath still receives touches.
- overflow: "visible" so taller ticks can bleed past the parent track.

ChapterList — bottom-sheet modal listing chapters with their timestamps.

- Highlights the currently active row (purple primary tint).
- Falls back to a localized "Chapter N" label when a chapter has no name.
- Imperatively scrolls to the active row each time the sheet becomes
  visible. <Modal> keeps its children mounted across visible toggles,
  so FlatList.initialScrollIndex (which only fires at first mount) would
  only work on the very first open. Uses a ref + useEffect on `visible`
  + scrollToIndex inside requestAnimationFrame, with an
  onScrollToIndexFailed fallback for indices outside the render window.
- All static styles in StyleSheet.create() — only dynamic backgroundColor
  / text colour stays inline. The list re-renders on every playback tick
  so cutting the per-render style allocations is worth it.
- Colors from constants/Colors.ts (primary, background, text, icon),
  no hardcoded hex.
2026-05-27 16:39:32 +02:00
Gauvain
8d33aa3ba1 feat(chapters): add pure helpers and unit tests
Adds dependency-free helpers in utils/chapters.ts for working with
Jellyfin chapter markers:

- chapterMarkers(chapters, durationMs): markers within range, with
  precomputed percent for slider overlays.
- chapterStartsMs(chapters): sorted start positions in ms (skips
  entries without StartPositionTicks).
- currentChapterIndex(positionMs, chapters): active chapter index
  for the live playback position (-1 if before the first chapter).
- chapterNameAt(positionMs, chapters): the active chapter name, or
  null if missing/unnamed.
- sortedChapters(chapters): chapter entries paired with ms start.
- formatChapterTime(positionMs): m:ss or h:mm:ss label.

All helpers tolerate null/undefined/empty inputs.

17 unit tests under bun test cover sort order, boundary positions,
missing fields, and out-of-range inputs.
2026-05-27 16:39:05 +02:00
12 changed files with 711 additions and 22 deletions

View File

@@ -89,7 +89,7 @@
"react-native-volume-manager": "^2.0.8", "react-native-volume-manager": "^2.0.8",
"react-native-web": "^0.21.0", "react-native-web": "^0.21.0",
"react-native-worklets": "0.5.1", "react-native-worklets": "0.5.1",
"sonner-native": "0.25.1", "sonner-native": "0.21.2",
"tailwindcss": "3.3.2", "tailwindcss": "3.3.2",
"use-debounce": "^10.0.4", "use-debounce": "^10.0.4",
"zod": "4.1.13", "zod": "4.1.13",
@@ -1818,7 +1818,7 @@
"slugify": ["slugify@1.6.9", "", {}, "sha512-vZ7rfeehZui7wQs438JXBckYLkIIdfHOXsaVEUMyS5fHo1483l1bMdo0EDSWYclY0yZKFOipDy4KHuKs6ssvdg=="], "slugify": ["slugify@1.6.9", "", {}, "sha512-vZ7rfeehZui7wQs438JXBckYLkIIdfHOXsaVEUMyS5fHo1483l1bMdo0EDSWYclY0yZKFOipDy4KHuKs6ssvdg=="],
"sonner-native": ["sonner-native@0.25.1", "", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-gesture-handler": "^2.28.0", "react-native-reanimated": "^4.1.1", "react-native-safe-area-context": "^5.6.0", "react-native-screens": "^4.16.0", "react-native-svg": "^15.12.1", "react-native-worklets": "^0.6.1 || ^0.7.0 || ^0.8.0" } }, "sha512-WyE5SjyRF8f845edmMw5hB89fkSrxpPRv6AjooVkN9SufF5n5jXSjIYa3+JI5UBthE0NeurbwHP3fzV3RehP0Q=="], "sonner-native": ["sonner-native@0.21.2", "", { "peerDependencies": { "react": "*", "react-native": "*", "react-native-gesture-handler": ">=2.16.1", "react-native-reanimated": ">=3.10.1", "react-native-safe-area-context": ">=4.10.5", "react-native-screens": ">=3.31.1", "react-native-svg": ">=15.6.0" } }, "sha512-LnGPmfgzrNIwcc+FvcLJqx8aH1dEHePRzvNR8aIR4kl9spySRkXK160GmQIazjfm6mSMlPqZwRa5eycvrzg/eQ=="],
"source-map": ["source-map@0.5.7", "", {}, "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ=="], "source-map": ["source-map@0.5.7", "", {}, "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ=="],

View File

@@ -9,6 +9,7 @@ import type {
BaseItemDto, BaseItemDto,
MediaSourceInfo, MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models"; } from "@jellyfin/sdk/lib/generated-client/models";
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
import { type Href } from "expo-router"; import { type Href } from "expo-router";
import { t } from "i18next"; import { t } from "i18next";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
@@ -195,9 +196,30 @@ export const DownloadItems: React.FC<DownloadProps> = ({
); );
} }
const downloadDetailsPromises = items.map(async (item) => { const downloadDetailsPromises = items.map(async (item) => {
// Ensure the snapshot we store offline carries the Chapters array.
// Page-level fetches sometimes use a fields filter that omits it; the
// offline player would then render no chapter ticks / list.
let itemForDownload = item;
if (!itemForDownload.Chapters && itemForDownload.Id) {
try {
const enriched = await getUserLibraryApi(api).getItem({
itemId: itemForDownload.Id,
userId: user.Id!,
});
if (enriched.data) {
itemForDownload = enriched.data;
}
} catch (e) {
console.warn(
"[DownloadItem] failed to refresh item for Chapters, falling back to original",
e,
);
}
}
const { mediaSource, audioIndex, subtitleIndex } = const { mediaSource, audioIndex, subtitleIndex } =
itemsNotDownloaded.length > 1 itemsNotDownloaded.length > 1
? getDefaultPlaySettings(item, settings!) ? getDefaultPlaySettings(itemForDownload, settings!)
: { : {
mediaSource: selectedOptions?.mediaSource, mediaSource: selectedOptions?.mediaSource,
audioIndex: selectedOptions?.audioIndex, audioIndex: selectedOptions?.audioIndex,
@@ -206,7 +228,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
const downloadDetails = await getDownloadUrl({ const downloadDetails = await getDownloadUrl({
api, api,
item, item: itemForDownload,
userId: user.Id!, userId: user.Id!,
mediaSource: mediaSource!, mediaSource: mediaSource!,
audioStreamIndex: audioIndex ?? -1, audioStreamIndex: audioIndex ?? -1,
@@ -218,7 +240,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
return { return {
url: downloadDetails?.url, url: downloadDetails?.url,
item, item: itemForDownload,
mediaSource: downloadDetails?.mediaSource, mediaSource: downloadDetails?.mediaSource,
}; };
}); });

View File

@@ -0,0 +1,196 @@
/**
* A modal listing an item's chapters. Each row shows the chapter name and its
* timestamp; the current chapter is highlighted. Tapping a row seeks to that
* chapter and closes the modal. Player-agnostic — the seek is injected.
*/
import { Ionicons } from "@expo/vector-icons";
import type { ChapterInfo } from "@jellyfin/sdk/lib/generated-client/models";
import { memo, useEffect, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import { FlatList, Modal, Pressable, StyleSheet, View } from "react-native";
import { Text } from "@/components/common/Text";
import { Colors } from "@/constants/Colors";
import {
type ChapterEntry,
chapterStartsMs,
formatChapterTime,
sortedChapters,
} from "@/utils/chapters";
interface ChapterListProps {
visible: boolean;
chapters: ChapterInfo[] | null | undefined;
/** Current playback position in milliseconds (to highlight the row). */
currentPositionMs: number;
/** Seek the player to this millisecond position. */
onSeek: (positionMs: number) => void;
onClose: () => void;
}
const ROW_HEIGHT = 48;
function ChapterListComponent({
visible,
chapters,
currentPositionMs,
onSeek,
onClose,
}: ChapterListProps) {
const { t } = useTranslation();
const listRef = useRef<FlatList<ChapterEntry>>(null);
const entries = useMemo(() => sortedChapters(chapters), [chapters]);
// Memoize starts so currentChapterIndex computation doesn't re-sort/filter
// every tick — chapters is the only input that drives the underlying array.
const starts = useMemo(() => chapterStartsMs(chapters), [chapters]);
const activeIndex = useMemo(() => {
let idx = -1;
for (let i = 0; i < starts.length; i++) {
if (currentPositionMs >= starts[i]) idx = i;
else break;
}
return idx;
}, [currentPositionMs, starts]);
// FlatList.initialScrollIndex only fires at first mount; <Modal> keeps its
// children mounted across visible toggles, so subsequent opens never scroll.
// Trigger an imperative scroll each time the sheet becomes visible.
useEffect(() => {
if (!visible || activeIndex < 0 || entries.length === 0) return;
const raf = requestAnimationFrame(() => {
listRef.current?.scrollToIndex({
index: activeIndex,
animated: false,
viewPosition: 0.5,
});
});
return () => cancelAnimationFrame(raf);
}, [visible, activeIndex, entries.length]);
return (
<Modal
visible={visible}
transparent
animationType='slide'
onRequestClose={onClose}
>
<Pressable onPress={onClose} style={styles.backdrop}>
<Pressable onPress={(e) => e.stopPropagation()} style={styles.sheet}>
<View style={styles.header}>
<Text style={styles.title}>{t("chapters.title")}</Text>
<Pressable
onPress={onClose}
hitSlop={10}
accessibilityRole='button'
accessibilityLabel={t("chapters.close")}
>
<Ionicons name='close' size={24} color={Colors.text} />
</Pressable>
</View>
<FlatList
ref={listRef}
data={entries}
keyExtractor={(item, index) => `${item.positionMs}-${index}`}
getItemLayout={(_, index) => ({
length: ROW_HEIGHT,
offset: ROW_HEIGHT * index,
index,
})}
onScrollToIndexFailed={(info) => {
// Required when getItemLayout is provided and the target index
// is outside the currently rendered window. Fallback to an
// offset-based scroll, then retry the precise scroll once a
// frame has elapsed.
listRef.current?.scrollToOffset({
offset: info.averageItemLength * info.index,
animated: false,
});
setTimeout(() => {
listRef.current?.scrollToIndex({
index: info.index,
animated: false,
viewPosition: 0.5,
});
}, 50);
}}
renderItem={({ item, index }) => {
const positionMs = item.positionMs;
const isActive = index === activeIndex;
return (
<Pressable
onPress={() => {
onSeek(positionMs);
onClose();
}}
style={[
styles.row,
isActive && { backgroundColor: `${Colors.primary}33` },
]}
>
<Text
style={[
styles.rowText,
{ color: isActive ? Colors.primary : Colors.text },
]}
numberOfLines={1}
>
{item.chapter.Name ||
t("chapters.chapter_number", { number: index + 1 })}
</Text>
<Text style={styles.rowTime}>
{formatChapterTime(positionMs)}
</Text>
</Pressable>
);
}}
/>
</Pressable>
</Pressable>
</Modal>
);
}
export const ChapterList = memo(ChapterListComponent);
const styles = StyleSheet.create({
backdrop: {
flex: 1,
justifyContent: "flex-end",
backgroundColor: "rgba(0,0,0,0.6)",
},
sheet: {
backgroundColor: Colors.background,
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
maxHeight: "70%",
paddingBottom: 24,
},
header: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
padding: 16,
},
title: {
color: Colors.text,
fontSize: 17,
fontWeight: "700",
},
row: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingHorizontal: 16,
height: ROW_HEIGHT,
},
rowText: {
fontSize: 15,
flex: 1,
},
rowTime: {
color: Colors.icon,
fontSize: 13,
marginLeft: 12,
},
});

View File

@@ -0,0 +1,87 @@
/**
* Chapter tick marks drawn as an absolute overlay over a progress slider.
* Renders nothing for media with one or zero chapters. `pointerEvents: "none"`
* so the slider underneath still receives touches.
*/
import { memo, useState } from "react";
import { type LayoutChangeEvent, PixelRatio, View } from "react-native";
import type { ChapterMarker } from "@/utils/chapters";
interface ChapterTicksProps {
/** Pre-computed markers (caller memoizes — avoids double-computing here). */
markers: ChapterMarker[];
/** Tick colour. */
color?: string;
/** Tick height in px — slightly less than the slider track thickness. */
height?: number;
/** Tick width in px — integer to avoid sub-pixel anti-aliasing. */
width?: number;
}
function ChapterTicksComponent({
markers,
// Semi-transparent black contrasts against both the filled progress
// (#fff) and the unfilled track (rgba(255,255,255,0.2)) so the ticks
// stay visible across the whole bar as playback advances.
color = "rgba(0,0,0,0.55)",
height = 14,
width = 2,
}: ChapterTicksProps) {
// Hooks must run unconditionally — keep them before any early return.
const [sliderWidth, setSliderWidth] = useState(0);
const handleLayout = (e: LayoutChangeEvent) => {
setSliderWidth(e.nativeEvent.layout.width);
};
// One chapter (typically a single marker at 0) is not worth marking.
if (markers.length <= 1) return null;
return (
<View
pointerEvents='none'
onLayout={handleLayout}
style={{
position: "absolute",
left: 0,
right: 0,
top: 0,
bottom: 0,
// Let ticks taller than this container bleed beyond its bounds.
overflow: "visible",
}}
>
{sliderWidth > 0 &&
markers
// Skip the leading 0ms marker — it overlaps the slider start and
// adds visual noise at an already-rendered boundary.
.filter((marker) => marker.positionMs > 0)
.map((marker, index) => {
// Align both the position AND the width onto the device's
// physical pixel grid. Without this, fractional dp values land
// at different sub-pixel fractions per tick — Android samples
// each one differently and some ticks render visibly thicker.
const centerDp = (marker.percent / 100) * sliderWidth;
const left = PixelRatio.roundToNearestPixel(centerDp - width / 2);
const snappedWidth = PixelRatio.roundToNearestPixel(width);
return (
<View
key={`${marker.positionMs}-${index}`}
style={{
position: "absolute",
left,
top: "50%",
marginTop: -height / 2,
height,
width: snappedWidth,
backgroundColor: color,
}}
/>
);
})}
</View>
);
}
export const ChapterTicks = memo(ChapterTicksComponent);

View File

@@ -1,18 +1,34 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; import { Ionicons } from "@expo/vector-icons";
import type { FC } from "react"; import type {
import { View } from "react-native"; 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 { Slider } from "react-native-awesome-slider";
import { type SharedValue } from "react-native-reanimated"; import { type SharedValue } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context"; 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 { Text } from "@/components/common/Text";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { chapterMarkers, chapterNameAt } from "@/utils/chapters";
import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton"; import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton";
import SkipButton from "./SkipButton"; import SkipButton from "./SkipButton";
import { TimeDisplay } from "./TimeDisplay"; import { TimeDisplay } from "./TimeDisplay";
import { TrickplayBubble } from "./TrickplayBubble"; 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 { interface BottomControlsProps {
item: BaseItemDto; item: BaseItemDto;
/** Item chapters, used for the tick overlay and chapter list. */
chapters?: ChapterInfo[] | null;
/** Total media duration in milliseconds. */
durationMs: number;
showControls: boolean; showControls: boolean;
isSliding: boolean; isSliding: boolean;
showRemoteBubble: boolean; showRemoteBubble: boolean;
@@ -38,6 +54,8 @@ interface BottomControlsProps {
handleSliderChange: (value: number) => void; handleSliderChange: (value: number) => void;
handleTouchStart: () => void; handleTouchStart: () => void;
handleTouchEnd: () => void; handleTouchEnd: () => void;
/** Programmatic seek (chapter list, hotkeys) — bypasses slide gesture state. */
seekTo: (value: number) => void;
// Trickplay props // Trickplay props
trickPlayUrl: { trickPlayUrl: {
@@ -61,6 +79,8 @@ interface BottomControlsProps {
export const BottomControls: FC<BottomControlsProps> = ({ export const BottomControls: FC<BottomControlsProps> = ({
item, item,
chapters,
durationMs,
showControls, showControls,
isSliding, isSliding,
showRemoteBubble, showRemoteBubble,
@@ -84,12 +104,38 @@ export const BottomControls: FC<BottomControlsProps> = ({
handleSliderChange, handleSliderChange,
handleTouchStart, handleTouchStart,
handleTouchEnd, handleTouchEnd,
seekTo,
trickPlayUrl, trickPlayUrl,
trickplayInfo, trickplayInfo,
time, time,
}) => { }) => {
const { settings } = useSettings(); const { settings } = useSettings();
const { t } = useTranslation();
const insets = useSafeAreaInsets(); 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 ( return (
<View <View
@@ -131,8 +177,24 @@ export const BottomControls: FC<BottomControlsProps> = ({
{item?.Type === "Audio" && ( {item?.Type === "Audio" && (
<Text className='text-xs opacity-50'>{item?.Album}</Text> <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>
<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'
accessibilityRole='button'
accessibilityLabel={t("chapters.open")}
>
<Ionicons name='bookmarks' size={24} color='white' />
</Pressable>
)}
<SkipButton <SkipButton
showButton={showSkipButton} showButton={showSkipButton}
onPress={skipIntro} onPress={skipIntro}
@@ -176,6 +238,9 @@ export const BottomControls: FC<BottomControlsProps> = ({
height: 10, height: 10,
justifyContent: "center", justifyContent: "center",
alignItems: "stretch", 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} onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd} onTouchEnd={handleTouchEnd}
@@ -203,6 +268,7 @@ export const BottomControls: FC<BottomControlsProps> = ({
trickPlayUrl={trickPlayUrl} trickPlayUrl={trickPlayUrl}
trickplayInfo={trickplayInfo} trickplayInfo={trickplayInfo}
time={time} time={time}
chapterName={scrubChapterName}
/> />
) )
} }
@@ -212,6 +278,7 @@ export const BottomControls: FC<BottomControlsProps> = ({
minimumValue={min} minimumValue={min}
maximumValue={max} maximumValue={max}
/> />
<ChapterTicks markers={chapterMarkerList} height={TICK_HEIGHT} />
</View> </View>
<TimeDisplay <TimeDisplay
currentTime={currentTime} currentTime={currentTime}
@@ -219,6 +286,13 @@ export const BottomControls: FC<BottomControlsProps> = ({
/> />
</View> </View>
</View> </View>
<ChapterList
visible={chapterListVisible}
chapters={chapters}
currentPositionMs={currentTime}
onSeek={seekTo}
onClose={() => setChapterListVisible(false)}
/>
</View> </View>
); );
}; };

View File

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

View File

@@ -22,12 +22,15 @@ interface TrickplayBubbleProps {
minutes: number; minutes: number;
seconds: number; seconds: number;
}; };
/** Chapter name at the scrubbed position, if any. */
chapterName?: string | null;
} }
export const TrickplayBubble: FC<TrickplayBubbleProps> = ({ export const TrickplayBubble: FC<TrickplayBubbleProps> = ({
trickPlayUrl, trickPlayUrl,
trickplayInfo, trickplayInfo,
time, time,
chapterName,
}) => { }) => {
if (!trickPlayUrl || !trickplayInfo) { if (!trickPlayUrl || !trickplayInfo) {
return null; return null;
@@ -36,18 +39,30 @@ export const TrickplayBubble: FC<TrickplayBubbleProps> = ({
const { x, y, url } = trickPlayUrl; const { x, y, url } = trickPlayUrl;
const tileWidth = CONTROLS_CONSTANTS.TILE_WIDTH; const tileWidth = CONTROLS_CONSTANTS.TILE_WIDTH;
const tileHeight = tileWidth / trickplayInfo.aspectRatio!; 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 ( return (
<View <View
style={{ style={{
position: "absolute", position: "absolute",
left: -62, left: -62,
// Sit just above the slider — high enough not to overlap the
// progress bar, low enough to feel anchored to the thumb.
bottom: 0, bottom: 0,
paddingTop: 30, paddingTop: 12,
paddingBottom: 5, paddingBottom: 5,
width: tileWidth * 1.5, width: tileWidth * 1.5,
justifyContent: "center", justifyContent: "center",
alignItems: "center", alignItems: "center",
// Bring the bubble in front of the player title / overlays.
zIndex: 999,
elevation: 10,
}} }}
> >
<View <View
@@ -55,7 +70,7 @@ export const TrickplayBubble: FC<TrickplayBubbleProps> = ({
width: tileWidth, width: tileWidth,
height: tileHeight, height: tileHeight,
alignSelf: "center", alignSelf: "center",
transform: [{ scale: 1.4 }], transform: [{ scale: previewScale }],
borderRadius: 5, borderRadius: 5,
}} }}
className='bg-neutral-800 overflow-hidden' className='bg-neutral-800 overflow-hidden'
@@ -75,17 +90,51 @@ export const TrickplayBubble: FC<TrickplayBubbleProps> = ({
source={{ uri: url }} source={{ uri: url }}
contentFit='cover' 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> </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>
); );
}; };

View File

@@ -74,6 +74,21 @@ export function useVideoSlider({
[seek, play, progress, isSeeking], [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( const handleSliderChange = useCallback(
debounce((value: number) => { debounce((value: number) => {
// Convert ms to ticks for trickplay // Convert ms to ticks for trickplay
@@ -96,5 +111,6 @@ export function useVideoSlider({
handleTouchEnd, handleTouchEnd,
handleSliderComplete, handleSliderComplete,
handleSliderChange, handleSliderChange,
seekTo,
}; };
} }

View File

@@ -110,7 +110,7 @@
"react-native-volume-manager": "^2.0.8", "react-native-volume-manager": "^2.0.8",
"react-native-web": "^0.21.0", "react-native-web": "^0.21.0",
"react-native-worklets": "0.5.1", "react-native-worklets": "0.5.1",
"sonner-native": "0.25.1", "sonner-native": "0.21.2",
"tailwindcss": "3.3.2", "tailwindcss": "3.3.2",
"use-debounce": "^10.0.4", "use-debounce": "^10.0.4",
"zod": "4.1.13" "zod": "4.1.13"

View File

@@ -610,6 +610,12 @@
"downloaded_file_no": "No", "downloaded_file_no": "No",
"downloaded_file_cancel": "Cancel" "downloaded_file_cancel": "Cancel"
}, },
"chapters": {
"title": "Chapters",
"chapter_number": "Chapter {{number}}",
"open": "Open chapters",
"close": "Close chapters"
},
"item_card": { "item_card": {
"next_up": "Next Up", "next_up": "Next Up",
"no_items_to_display": "No Items to Display", "no_items_to_display": "No Items to Display",

138
utils/chapters.test.ts Normal file
View File

@@ -0,0 +1,138 @@
import { describe, expect, test } from "bun:test";
import {
chapterMarkers,
chapterNameAt,
chapterStartsMs,
currentChapterIndex,
formatChapterTime,
sortedChapters,
} from "./chapters";
// Helper: a ChapterInfo with a start in milliseconds.
const ch = (ms: number, name?: string) => ({
StartPositionTicks: ms * 10000,
Name: name,
});
describe("chapterMarkers", () => {
test("maps chapters to position + percent", () => {
expect(chapterMarkers([ch(0), ch(30_000), ch(60_000)], 120_000)).toEqual([
{ positionMs: 0, percent: 0 },
{ positionMs: 30_000, percent: 25 },
{ positionMs: 60_000, percent: 50 },
]);
});
test("drops chapters past the duration", () => {
expect(chapterMarkers([ch(0), ch(200_000)], 120_000)).toEqual([
{ positionMs: 0, percent: 0 },
]);
});
test("returns [] when duration is 0 or chapters missing", () => {
expect(chapterMarkers([ch(0)], 0)).toEqual([]);
expect(chapterMarkers(null, 120_000)).toEqual([]);
expect(chapterMarkers(undefined, 120_000)).toEqual([]);
});
test("excludes a chapter exactly at the duration", () => {
expect(chapterMarkers([ch(0), ch(120_000)], 120_000)).toEqual([
{ positionMs: 0, percent: 0 },
]);
});
test("skips chapters with no StartPositionTicks", () => {
expect(
chapterMarkers([{ StartPositionTicks: undefined }, ch(30_000)], 120_000),
).toEqual([{ positionMs: 30_000, percent: 25 }]);
});
});
describe("currentChapterIndex", () => {
const chapters = [ch(0), ch(30_000), ch(60_000)];
test("returns the chapter containing the position", () => {
expect(currentChapterIndex(0, chapters)).toBe(0);
expect(currentChapterIndex(15_000, chapters)).toBe(0);
expect(currentChapterIndex(30_000, chapters)).toBe(1);
expect(currentChapterIndex(90_000, chapters)).toBe(2);
});
test("returns -1 before the first chapter and for no chapters", () => {
expect(currentChapterIndex(-5, chapters)).toBe(-1);
expect(currentChapterIndex(10_000, [])).toBe(-1);
expect(currentChapterIndex(10_000, null)).toBe(-1);
});
});
describe("sortedChapters", () => {
test("pairs each chapter with its ms start, sorted ascending", () => {
const a = ch(60_000, "C");
const b = ch(0, "A");
const c = ch(30_000, "B");
expect(sortedChapters([a, b, c])).toEqual([
{ chapter: b, positionMs: 0 },
{ chapter: c, positionMs: 30_000 },
{ chapter: a, positionMs: 60_000 },
]);
});
test("returns [] for null/undefined", () => {
expect(sortedChapters(null)).toEqual([]);
expect(sortedChapters(undefined)).toEqual([]);
});
});
describe("chapterStartsMs", () => {
test("returns sorted ms positions", () => {
expect(chapterStartsMs([ch(60_000), ch(0), ch(30_000)])).toEqual([
0, 30_000, 60_000,
]);
});
test("skips entries without StartPositionTicks", () => {
expect(
chapterStartsMs([ch(30_000), { StartPositionTicks: undefined }, ch(0)]),
).toEqual([0, 30_000]);
});
test("returns [] for null/undefined/empty", () => {
expect(chapterStartsMs(null)).toEqual([]);
expect(chapterStartsMs(undefined)).toEqual([]);
expect(chapterStartsMs([])).toEqual([]);
});
});
describe("chapterNameAt", () => {
const named = [
{ StartPositionTicks: 0, Name: "Intro" },
{ StartPositionTicks: 30_000 * 10000, Name: "Action" },
{ StartPositionTicks: 60_000 * 10000, Name: "Outro" },
];
test("returns the chapter name for the active position", () => {
expect(chapterNameAt(0, named)).toBe("Intro");
expect(chapterNameAt(15_000, named)).toBe("Intro");
expect(chapterNameAt(45_000, named)).toBe("Action");
expect(chapterNameAt(90_000, named)).toBe("Outro");
});
test("returns null before the first chapter", () => {
expect(chapterNameAt(-1, named)).toBeNull();
});
test("returns null for null/undefined/empty chapters", () => {
expect(chapterNameAt(10_000, null)).toBeNull();
expect(chapterNameAt(10_000, undefined)).toBeNull();
expect(chapterNameAt(10_000, [])).toBeNull();
});
test("returns null when the active chapter has no Name", () => {
expect(chapterNameAt(15_000, [ch(0), ch(30_000)])).toBeNull();
});
});
describe("formatChapterTime", () => {
test("formats m:ss and h:mm:ss", () => {
expect(formatChapterTime(65_000)).toBe("1:05");
expect(formatChapterTime(3_725_000)).toBe("1:02:05");
expect(formatChapterTime(-100)).toBe("0:00");
});
});

97
utils/chapters.ts Normal file
View File

@@ -0,0 +1,97 @@
/**
* Pure helpers for Jellyfin chapter markers. Dependency-free so they are
* unit-testable under `bun test`.
*/
import type { ChapterInfo } from "@jellyfin/sdk/lib/generated-client/models";
import { ticksToMs } from "@/utils/time";
export interface ChapterMarker {
/** Chapter start, in milliseconds. */
positionMs: number;
/** Chapter start as a percentage (0-100) of the media duration. */
percent: number;
}
export interface ChapterEntry {
chapter: ChapterInfo;
/** Chapter start, in milliseconds. */
positionMs: number;
}
/** Chapters paired with their millisecond start, sorted ascending by start. */
export const sortedChapters = (
chapters: ChapterInfo[] | null | undefined,
): ChapterEntry[] =>
(chapters ?? [])
.filter((c) => c.StartPositionTicks != null)
.map((chapter) => ({
chapter,
positionMs: ticksToMs(chapter.StartPositionTicks),
}))
.sort((a, b) => a.positionMs - b.positionMs);
/** Chapter start positions in milliseconds, ascending. */
export const chapterStartsMs = (
chapters: ChapterInfo[] | null | undefined,
): number[] =>
(chapters ?? [])
.filter((c) => c.StartPositionTicks != null)
.map((c) => ticksToMs(c.StartPositionTicks))
.sort((a, b) => a - b);
/** Chapter markers within [0, durationMs]; empty when duration is unknown. */
export const chapterMarkers = (
chapters: ChapterInfo[] | null | undefined,
durationMs: number,
): ChapterMarker[] => {
if (durationMs <= 0) return [];
return chapterStartsMs(chapters)
.filter((ms) => ms >= 0 && ms < durationMs)
.map((ms) => ({ positionMs: ms, percent: (ms / durationMs) * 100 }));
};
/** Index of the chapter containing `positionMs`, or -1 if before the first. */
export const currentChapterIndex = (
positionMs: number,
chapters: ChapterInfo[] | null | undefined,
): number => {
const starts = chapterStartsMs(chapters);
let index = -1;
for (let i = 0; i < starts.length; i++) {
if (positionMs >= starts[i]) index = i;
else break;
}
return index;
};
/** Name of the chapter containing `positionMs`, or null if none / unnamed. */
export const chapterNameAt = (
positionMs: number,
chapters: ChapterInfo[] | null | undefined,
): string | null => {
// Sort once, derive both the active index and the entry from the same array
// — `chapterNameAt` runs on every playback tick, so paying for one `sort()`
// instead of two is worth the duplication of the index loop here.
const sorted = sortedChapters(chapters);
let idx = -1;
for (let i = 0; i < sorted.length; i++) {
if (positionMs >= sorted[i].positionMs) idx = i;
else break;
}
if (idx < 0) return null;
const name = sorted[idx]?.chapter.Name;
return name && name.length > 0 ? name : null;
};
/** `m:ss` (or `h:mm:ss` past an hour) label for a millisecond position. */
export const formatChapterTime = (positionMs: number): string => {
const total = Math.max(0, Math.floor(positionMs / 1000));
const hours = Math.floor(total / 3600);
const minutes = Math.floor((total % 3600) / 60);
const seconds = total % 60;
const pad = (n: number) => String(n).padStart(2, "0");
return hours > 0
? `${hours}:${pad(minutes)}:${pad(seconds)}`
: `${minutes}:${pad(seconds)}`;
};