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)}`; +};