diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
index e76ebb70d..95978bab3 100644
--- a/.github/pull_request_template.md
+++ b/.github/pull_request_template.md
@@ -1,91 +1,54 @@
-
# π¦ Pull Request
-## π Summary
+
+
+
+## π Description
## π·οΈ Ticket / Issue
-## π οΈ Whatβs Changed
-
-
-- Type: feat | fix | docs | style | refactor | perf | test | chore | build | ci | revert
-- Scope (optional): e.g., auth, billing, mobile
-- Short summary: what changed and why (1β2 lines)
--->
-
-## π Details
-
-
-### β οΈ Breaking Changes
-
-
-### π Security & Privacy Impact
-
-
-### β‘ Performance Impact
-
-
### πΌοΈ Screenshots / GIFs (if UI)
-
+
## β
Checklist
- [ ] Iβve read the [contribution guidelines](CONTRIBUTING.md)
-- [ ] Code follows project style and passes lint/format (`npm|pnpm|yarn|bun` scripts)
-- [ ] Type checks pass (tsc/biome/etc.)
-- [ ] Docs updated (README/ADR/usage/API)
-- [ ] No secrets/credentials included; env vars documented
-- [ ] Release notes/CHANGELOG entry added (if applicable)
-- [ ] Verified locally that changes behave as expected
+- [ ] Verified that changes behave as expected for all platforms
+- [ ] Code passes lint/formatting and type checks (`tsc`/`biome`)
+- [ ] No secrets, hardcoded credentials, or private config files are included
+- [ ] I've declared if AI was used to assist with this PR (by uncommenting the line at the bottom, or not)
## π Testing Instructions
-## βοΈ Deployment Notes
-
-
-## π Additional Notes
-
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index d530b280b..c39e191b9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -74,3 +74,5 @@ modules/background-downloader/android/build/*
# ios:unsigned-build Artifacts
build/
.claude/
+.agents/skills/**
+skills-lock.json
diff --git a/app/(auth)/(tabs)/(home)/_layout.tsx b/app/(auth)/(tabs)/(home)/_layout.tsx
index 591759b94..de5545d62 100644
--- a/app/(auth)/(tabs)/(home)/_layout.tsx
+++ b/app/(auth)/(tabs)/(home)/_layout.tsx
@@ -9,6 +9,7 @@ import useRouter from "@/hooks/useAppRouter";
const Chromecast = Platform.isTV ? null : require("@/components/Chromecast");
import { useAtom } from "jotai";
+import { HeaderBackButton } from "@/components/common/HeaderBackButton";
import { useSessions, type useSessionsProps } from "@/hooks/useSessions";
import { userAtom } from "@/providers/JellyfinProvider";
@@ -47,15 +48,7 @@ export default function IndexLayout() {
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
title: t("home.downloads.downloads_title"),
- headerLeft: () => (
- _router.back()}
- className='pl-0.5'
- style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
- >
-
-
- ),
+ headerLeft: () => ,
}}
/>
(
- _router.back()}
- className='pl-0.5'
- style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
- >
-
-
- ),
+ headerLeft: () => ,
}}
/>
(
- _router.back()}
- className='pl-0.5'
- style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
- >
-
-
- ),
+ headerLeft: () => ,
}}
/>
(
- _router.back()}
- className='pl-0.5'
- style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
- >
-
-
- ),
+ headerLeft: () => ,
}}
/>
(
- _router.back()}
- className='pl-0.5'
- style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
- >
-
-
- ),
+ headerLeft: () => ,
}}
/>
(
- _router.back()}
- className='pl-0.5'
- style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
- >
-
-
- ),
+ headerLeft: () => ,
}}
/>
(
- _router.back()}
- className='pl-0.5'
- style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
- >
-
-
- ),
+ headerLeft: () => ,
}}
/>
(
- _router.back()}
- className='pl-0.5'
- style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
- >
-
-
- ),
+ headerLeft: () => ,
}}
/>
(
- _router.back()}
- className='pl-0.5'
- style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
- >
-
-
- ),
+ headerLeft: () => ,
}}
/>
(
- _router.back()}
- className='pl-0.5'
- style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
- >
-
-
- ),
+ headerLeft: () => ,
}}
/>
(
- _router.back()}
- className='pl-0.5'
- style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
- >
-
-
- ),
+ headerLeft: () => ,
}}
/>
(
- _router.back()}
- className='pl-0.5'
- style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
- >
-
-
- ),
+ headerLeft: () => ,
}}
/>
(
- _router.back()}
- className='pl-0.5'
- style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
- >
-
-
- ),
+ headerLeft: () => ,
}}
/>
(
- _router.back()}
- className='pl-0.5'
- style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
- >
-
-
- ),
+ headerLeft: () => ,
}}
/>
(
- _router.back()}
- className='pl-0.5'
- style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
- >
-
-
- ),
+ headerLeft: () => ,
}}
/>
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
@@ -369,11 +250,7 @@ export default function IndexLayout() {
name='collections/[collectionId]'
options={{
title: "",
- headerLeft: () => (
- _router.back()} className='pl-0.5'>
-
-
- ),
+ headerLeft: () => ,
headerShown: !Platform.isTV,
headerBlurEffect: "prominent",
headerTransparent: Platform.OS === "ios",
diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/items/page.tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/items/page.tsx
index 4c7934a7b..54fe92126 100644
--- a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/items/page.tsx
+++ b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/items/page.tsx
@@ -37,8 +37,10 @@ const Page: React.FC = () => {
ItemFields.MediaStreams,
]);
- // Lazily preload item with full media sources in background
- const { data: itemWithSources } = useItemQuery(id, isOffline, undefined, []);
+ // Lazily preload item with full media sources in background β never cache
+ const { data: itemWithSources } = useItemQuery(id, isOffline, undefined, [], {
+ gcTime: 0,
+ });
const opacity = useSharedValue(1);
const animatedStyle = useAnimatedStyle(() => {
diff --git a/app/_layout.tsx b/app/_layout.tsx
index accdd7260..43134fcc1 100644
--- a/app/_layout.tsx
+++ b/app/_layout.tsx
@@ -395,8 +395,9 @@ function Layout() {
maxAge: 1000 * 60 * 60 * 24, // 24 hours max cache age
dehydrateOptions: {
shouldDehydrateQuery: (query) => {
- // Only persist successful queries
- return query.state.status === "success";
+ return (
+ query.state.status === "success" && query.options.gcTime !== 0
+ );
},
},
}}
diff --git a/components/DownloadItem.tsx b/components/DownloadItem.tsx
index 0923dfecd..b1f759b57 100644
--- a/components/DownloadItem.tsx
+++ b/components/DownloadItem.tsx
@@ -9,6 +9,7 @@ import type {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models";
+import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
import { type Href } from "expo-router";
import { t } from "i18next";
import { useAtom } from "jotai";
@@ -199,9 +200,30 @@ export const DownloadItems: React.FC = ({
);
}
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 } =
itemsNotDownloaded.length > 1
- ? getDefaultPlaySettings(item, settings!)
+ ? getDefaultPlaySettings(itemForDownload, settings!)
: {
mediaSource: selectedOptions?.mediaSource,
audioIndex: selectedOptions?.audioIndex,
@@ -210,7 +232,7 @@ export const DownloadItems: React.FC = ({
const downloadDetails = await getDownloadUrl({
api,
- item,
+ item: itemForDownload,
userId: user.Id!,
mediaSource: mediaSource!,
audioStreamIndex: audioIndex ?? -1,
@@ -222,7 +244,7 @@ export const DownloadItems: React.FC = ({
return {
url: downloadDetails?.url,
- item,
+ item: itemForDownload,
mediaSource: downloadDetails?.mediaSource,
};
});
diff --git a/components/chapters/ChapterList.tsx b/components/chapters/ChapterList.tsx
new file mode 100644
index 000000000..42a90b89e
--- /dev/null
+++ b/components/chapters/ChapterList.tsx
@@ -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>(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; 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 (
+
+
+ e.stopPropagation()} style={styles.sheet}>
+
+ {t("chapters.title")}
+
+
+
+
+ `${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 (
+ {
+ onSeek(positionMs);
+ onClose();
+ }}
+ style={[
+ styles.row,
+ isActive && { backgroundColor: `${Colors.primary}33` },
+ ]}
+ >
+
+ {item.chapter.Name ||
+ t("chapters.chapter_number", { number: index + 1 })}
+
+
+ {formatChapterTime(positionMs)}
+
+
+ );
+ }}
+ />
+
+
+
+ );
+}
+
+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,
+ },
+});
diff --git a/components/chapters/ChapterTicks.tsx b/components/chapters/ChapterTicks.tsx
new file mode 100644
index 000000000..850c63bf0
--- /dev/null
+++ b/components/chapters/ChapterTicks.tsx
@@ -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 (
+
+ {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 (
+
+ );
+ })}
+
+ );
+}
+
+export const ChapterTicks = memo(ChapterTicksComponent);
diff --git a/components/video-player/controls/BottomControls.tsx b/components/video-player/controls/BottomControls.tsx
index 2e2fd4dd8..b0e32dad4 100644
--- a/components/video-player/controls/BottomControls.tsx
+++ b/components/video-player/controls/BottomControls.tsx
@@ -1,19 +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 } from "./ChapterMarkers";
+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;
@@ -39,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: {
@@ -65,6 +82,8 @@ interface BottomControlsProps {
export const BottomControls: FC = ({
item,
+ chapters,
+ durationMs,
showControls,
isSliding,
showRemoteBubble,
@@ -88,13 +107,39 @@ export const BottomControls: FC = ({
handleSliderChange,
handleTouchStart,
handleTouchEnd,
+ seekTo,
trickPlayUrl,
trickplayInfo,
time,
chapterPositions = [],
}) => {
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'
+ accessibilityRole='button'
+ accessibilityLabel={t("chapters.open")}
+ >
+
+
+ )}
= ({
height: 10,
justifyContent: "center",
alignItems: "stretch",
- position: "relative",
+ // 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}
@@ -209,6 +272,7 @@ export const BottomControls: FC = ({
trickPlayUrl={trickPlayUrl}
trickplayInfo={trickplayInfo}
time={time}
+ chapterName={scrubChapterName}
/>
)
}
@@ -218,7 +282,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 de6326eae..1e64f16ad 100644
--- a/components/video-player/controls/Controls.tsx
+++ b/components/video-player/controls/Controls.tsx
@@ -267,6 +267,7 @@ export const Controls: FC = ({
handleTouchEnd,
handleSliderComplete,
handleSliderChange,
+ seekTo,
} = useVideoSlider({
progress,
isSeeking,
@@ -555,6 +556,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 416bb92cd..e00a19e6a 100644
--- a/components/video-player/controls/TrickplayBubble.tsx
+++ b/components/video-player/controls/TrickplayBubble.tsx
@@ -4,7 +4,9 @@ import { View } from "react-native";
import { Text } from "@/components/common/Text";
import { CONTROLS_CONSTANTS } from "./constants";
-const BASE_IMAGE_SCALE = 1.4;
+// Slightly larger preview (scale 1.6 vs old 1.4) to give the overlay text
+// more room and feel closer to the Jellyfin web style.
+const BASE_IMAGE_SCALE = 1.6;
const BUBBLE_LEFT_OFFSET = 62;
const BUBBLE_WIDTH_MULTIPLIER = 1.5;
@@ -28,12 +30,8 @@ interface TrickplayBubbleProps {
};
/** Scale factor for the image (default 1). Does not affect timestamp text. */
imageScale?: number;
-}
-
-function formatTime(hours: number, minutes: number, seconds: number): string {
- const pad = (n: number) => (n < 10 ? `0${n}` : `${n}`);
- const prefix = hours > 0 ? `${hours}:` : "";
- return `${prefix}${pad(minutes)}:${pad(seconds)}`;
+ /** Chapter name at the scrubbed position, if any. */
+ chapterName?: string | null;
}
export const TrickplayBubble: FC = ({
@@ -41,6 +39,7 @@ export const TrickplayBubble: FC = ({
trickplayInfo,
time,
imageScale = 1,
+ chapterName,
}) => {
if (!trickPlayUrl || !trickplayInfo) {
return null;
@@ -49,19 +48,28 @@ 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}`;
+
const finalScale = BASE_IMAGE_SCALE * imageScale;
return (
= ({
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}
+
+
-
- {formatTime(time.hours, time.minutes, time.seconds)}
-
);
};
diff --git a/components/video-player/controls/hooks/useVideoSlider.ts b/components/video-player/controls/hooks/useVideoSlider.ts
index dfc1164bb..3c19ce7ad 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/hooks/useItemQuery.ts b/hooks/useItemQuery.ts
index d98f6193e..d45fe51c6 100644
--- a/hooks/useItemQuery.ts
+++ b/hooks/useItemQuery.ts
@@ -13,11 +13,17 @@ export const excludeFields = (fieldsToExclude: ItemFields[]) => {
);
};
+type ExtraQueryOptions = {
+ gcTime?: number;
+ staleTime?: number;
+};
+
export const useItemQuery = (
itemId: string | undefined,
isOffline?: boolean,
fields?: ItemFields[],
excludeFields?: ItemFields[],
+ queryOptions?: ExtraQueryOptions,
) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
@@ -56,5 +62,6 @@ export const useItemQuery = (
refetchOnWindowFocus: true,
refetchOnReconnect: true,
networkMode: "always",
+ ...queryOptions,
});
};
diff --git a/hooks/useWatchlistMutations.ts b/hooks/useWatchlistMutations.ts
index e3e39ef96..5e65ebf99 100644
--- a/hooks/useWatchlistMutations.ts
+++ b/hooks/useWatchlistMutations.ts
@@ -177,6 +177,9 @@ export const useAddToWatchlist = () => {
}
},
onSuccess: (_data, variables) => {
+ queryClient.invalidateQueries({
+ queryKey: ["streamystats", "watchlists"],
+ });
queryClient.invalidateQueries({
queryKey: ["streamystats", "watchlist", variables.watchlistId],
});
@@ -235,6 +238,9 @@ export const useRemoveFromWatchlist = () => {
}
},
onSuccess: (_data, variables) => {
+ queryClient.invalidateQueries({
+ queryKey: ["streamystats", "watchlists"],
+ });
queryClient.invalidateQueries({
queryKey: ["streamystats", "watchlist", variables.watchlistId],
});
diff --git a/modules/mpv-player/ios/PiPController.swift b/modules/mpv-player/ios/PiPController.swift
index 7a58cb38e..6ad0bec51 100644
--- a/modules/mpv-player/ios/PiPController.swift
+++ b/modules/mpv-player/ios/PiPController.swift
@@ -150,6 +150,16 @@ final class PiPController: NSObject {
CMTimebaseSetRate(tb, rate: Float64(rate))
}
}
+
+ deinit {
+ if let tb = timebase {
+ CMTimebaseSetRate(tb, rate: 0)
+ }
+ sampleBufferDisplayLayer?.controlTimebase = nil
+ timebase = nil
+ pipController?.delegate = nil
+ pipController = nil
+ }
}
// MARK: - AVPictureInPictureControllerDelegate
diff --git a/translations/en.json b/translations/en.json
index a11f2a75d..58fe4828b 100644
--- a/translations/en.json
+++ b/translations/en.json
@@ -720,6 +720,12 @@
"stopPlayingConfirm": "Are you sure you want to stop playback?",
"downloaded": "Downloaded"
},
+ "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",
diff --git a/utils/atoms/settings.ts b/utils/atoms/settings.ts
index 55b6aa523..8e2bfcf5b 100644
--- a/utils/atoms/settings.ts
+++ b/utils/atoms/settings.ts
@@ -399,6 +399,14 @@ export const pluginSettingsAtom = atom(
loadPluginSettings(),
);
+const hasMeaningfulSettingValue = (value: unknown) =>
+ value !== undefined && value !== null && value !== "";
+
+const getEffectiveSettingValue = (
+ settings: Partial | null | undefined,
+ settingsKey: K,
+) => settings?.[settingsKey] ?? defaultValues[settingsKey];
+
export const useSettings = () => {
const api = useAtomValue(apiAtom);
const [_settings, setSettings] = useAtom(settingsAtom);
@@ -439,12 +447,13 @@ export const useSettings = () => {
for (const [key, setting] of Object.entries(newPluginSettings)) {
if (setting && !setting.locked && setting.value !== undefined) {
const settingsKey = key as keyof Settings;
- // Apply if forceOverride is true, or if user hasn't explicitly set this value
- if (
- forceOverride ||
- _settings[settingsKey] === undefined ||
- _settings[settingsKey] === ""
- ) {
+ const effectiveValue = getEffectiveSettingValue(
+ _settings,
+ settingsKey,
+ );
+ // Apply if forceOverride is true, or if neither persisted settings
+ // nor app defaults provide a meaningful value.
+ if (forceOverride || !hasMeaningfulSettingValue(effectiveValue)) {
(updates as any)[settingsKey] = setting.value;
}
}
@@ -496,28 +505,22 @@ export const useSettings = () => {
// We do not want to save over users pre-existing settings in case admin ever removes/unlocks a setting.
// If admin sets locked to false but provides a value,
- // use user settings first and fallback on admin setting if required.
+ // use persisted settings first, then app defaults, and only fallback on the
+ // plugin value when neither provides a meaningful value.
const settings: Settings = useMemo(() => {
- const unlockedPluginDefaults: Partial = {};
const overrideSettings = Object.entries(pluginSettings ?? {}).reduce<
Partial
>((acc, [key, setting]) => {
if (setting) {
const { value, locked } = setting;
const settingsKey = key as keyof Settings;
-
- // Make sure we override default settings with plugin settings when they are not locked.
- if (
- !locked &&
- value !== undefined &&
- _settings?.[settingsKey] !== value
- ) {
- (unlockedPluginDefaults as any)[settingsKey] = value;
- }
+ const effectiveValue = getEffectiveSettingValue(_settings, settingsKey);
(acc as any)[settingsKey] = locked
? value
- : (_settings?.[settingsKey] ?? value);
+ : hasMeaningfulSettingValue(effectiveValue)
+ ? effectiveValue
+ : value;
}
return acc;
}, {});
diff --git a/utils/chapters.test.ts b/utils/chapters.test.ts
new file mode 100644
index 000000000..875bc7e2a
--- /dev/null
+++ b/utils/chapters.test.ts
@@ -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");
+ });
+});
diff --git a/utils/chapters.ts b/utils/chapters.ts
new file mode 100644
index 000000000..8b0e0e7bc
--- /dev/null
+++ b/utils/chapters.ts
@@ -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)}`;
+};