diff --git a/app/(auth)/(tabs)/(home)/downloads/[seriesId].tsx b/app/(auth)/(tabs)/(home)/downloads/[seriesId].tsx index 1e9d9b97..794e3005 100644 --- a/app/(auth)/(tabs)/(home)/downloads/[seriesId].tsx +++ b/app/(auth)/(tabs)/(home)/downloads/[seriesId].tsx @@ -39,26 +39,44 @@ export default function page() { } }, [getDownloadedItems]); + // Group episodes by season in a single pass + const seasonGroups = useMemo(() => { + const groups: Record = {}; + + series.forEach((episode) => { + const seasonNumber = episode.item.ParentIndexNumber; + if (seasonNumber !== undefined && seasonNumber !== null) { + if (!groups[seasonNumber]) { + groups[seasonNumber] = []; + } + groups[seasonNumber].push(episode.item); + } + }); + + // Sort episodes within each season + Object.values(groups).forEach((episodes) => { + episodes.sort((a, b) => (a.IndexNumber || 0) - (b.IndexNumber || 0)); + }); + + return groups; + }, [series]); + + // Get unique seasons (just the season numbers, sorted) + const uniqueSeasons = useMemo(() => { + const seasonNumbers = Object.keys(seasonGroups) + .map(Number) + .sort((a, b) => a - b); + return seasonNumbers.map((seasonNum) => seasonGroups[seasonNum][0]); // First episode of each season + }, [seasonGroups]); + const seasonIndex = seasonIndexState[series?.[0]?.item?.ParentId ?? ""] || episodeSeasonIndex || ""; const groupBySeason = useMemo(() => { - const seasons: Record = {}; - - series?.forEach((episode) => { - if (!seasons[episode.item.ParentIndexNumber!]) { - seasons[episode.item.ParentIndexNumber!] = []; - } - - seasons[episode.item.ParentIndexNumber!].push(episode.item); - }); - return ( - seasons[seasonIndex]?.sort((a, b) => a.IndexNumber! - b.IndexNumber!) ?? - [] - ); - }, [series, seasonIndex]); + return seasonGroups[Number(seasonIndex)] ?? []; + }, [seasonGroups, seasonIndex]); const initialSeasonIndex = useMemo( () => @@ -102,7 +120,7 @@ export default function page() { s.item)} + seasons={uniqueSeasons} state={seasonIndexState} initialSeasonIndex={initialSeasonIndex!} onSelect={(season) => { diff --git a/components/common/HorrizontalScroll.tsx b/components/common/HorrizontalScroll.tsx index 4e6e94ca..c2d4e96e 100644 --- a/components/common/HorrizontalScroll.tsx +++ b/components/common/HorrizontalScroll.tsx @@ -1,5 +1,5 @@ import { FlashList, type FlashListProps } from "@shopify/flash-list"; -import React, { forwardRef, useImperativeHandle, useRef } from "react"; +import React, { useImperativeHandle, useRef } from "react"; import { View, type ViewStyle } from "react-native"; import { Text } from "./Text"; @@ -19,64 +19,58 @@ interface HorizontalScrollProps keyExtractor?: (item: T, index: number) => string; containerStyle?: ViewStyle; contentContainerStyle?: ViewStyle; - loadingContainerStyle?: ViewStyle; height?: number; loading?: boolean; extraData?: any; noItemsText?: string; } -export const HorizontalScroll = forwardRef< - HorizontalScrollRef, - HorizontalScrollProps ->( - ( - { - data = [], - keyExtractor, - renderItem, - containerStyle, - contentContainerStyle, - loadingContainerStyle, - loading = false, - height = 164, - extraData, - noItemsText, - ...props - }: HorizontalScrollProps, - ref: React.ForwardedRef, - ) => { - const flashListRef = useRef>(null); +export const HorizontalScroll = ( + props: HorizontalScrollProps & { + ref?: React.ForwardedRef; + }, +) => { + const { + data = [], + keyExtractor, + renderItem, + containerStyle, + contentContainerStyle, + loading = false, + height = 164, + extraData, + noItemsText, + ref, + ...restProps + } = props; + const flashListRef = useRef>(null); - useImperativeHandle(ref!, () => ({ - scrollToIndex: (index: number, viewOffset: number) => { - flashListRef.current?.scrollToIndex({ - index, - animated: true, - viewPosition: 0, - viewOffset, - }); - }, - })); + useImperativeHandle(ref!, () => ({ + scrollToIndex: (index: number, viewOffset: number) => { + flashListRef.current?.scrollToIndex({ + index, + animated: true, + viewPosition: 0, + viewOffset, + }); + }, + })); - const renderFlashListItem = ({ - item, - index, - }: { - item: T; - index: number; - }) => {renderItem(item, index)}; - - if (!data || loading) { - return ( - - - - - ); - } + const renderFlashListItem = ({ item, index }: { item: T; index: number }) => ( + {renderItem(item, index)} + ); + if (!data || loading) { return ( + + + + + ); + } + + return ( + ref={flashListRef} data={data} @@ -97,8 +91,8 @@ export const HorizontalScroll = forwardRef< )} - {...props} + {...restProps} /> - ); - }, -); + + ); +}; diff --git a/components/downloads/ActiveDownloads.tsx b/components/downloads/ActiveDownloads.tsx index e95ba2d2..447f06ba 100644 --- a/components/downloads/ActiveDownloads.tsx +++ b/components/downloads/ActiveDownloads.tsx @@ -157,7 +157,7 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => { cancelJobMutation.mutate(process.id)} - className='ml-auto' + className='ml-auto p-2 rounded-full' > {cancelJobMutation.isPending ? ( diff --git a/components/video-player/controls/Controls.tsx b/components/video-player/controls/Controls.tsx index b9a1ec80..6f5ce2f9 100644 --- a/components/video-player/controls/Controls.tsx +++ b/components/video-player/controls/Controls.tsx @@ -5,7 +5,6 @@ import type { } from "@jellyfin/sdk/lib/generated-client"; import { Image } from "expo-image"; import { useLocalSearchParams, useRouter } from "expo-router"; -import { useAtom } from "jotai"; import { debounce } from "lodash"; import { type Dispatch, @@ -41,10 +40,8 @@ import { useIntroSkipper } from "@/hooks/useIntroSkipper"; import { usePlaybackManager } from "@/hooks/usePlaybackManager"; import { useTrickplay } from "@/hooks/useTrickplay"; import type { TrackInfo, VlcPlayerViewRef } from "@/modules/VlcPlayer.types"; -import { apiAtom } from "@/providers/JellyfinProvider"; import { useSettings, VideoPlayer } from "@/utils/atoms/settings"; import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings"; -import { getItemById } from "@/utils/jellyfin/user-library/getItemById"; import { writeToLog } from "@/utils/log"; import { formatTimeString, @@ -124,7 +121,6 @@ export const Controls: FC = ({ const [settings, updateSettings] = useSettings(); const router = useRouter(); const insets = useSafeAreaInsets(); - const [api] = useAtom(apiAtom); const [episodeView, setEpisodeView] = useState(false); const [isSliding, setIsSliding] = useState(false); @@ -346,7 +342,9 @@ export const Controls: FC = ({ previousIndexes, mediaSource ?? undefined, ); + const queryParams = new URLSearchParams({ + ...(offline && { offline: "true" }), itemId: item.Id ?? "", audioIndex: defaultAudioIndex?.toString() ?? "", subtitleIndex: defaultSubtitleIndex?.toString() ?? "", @@ -438,25 +436,6 @@ export const Controls: FC = ({ [goToNextItem], ); - const goToItem = useCallback( - async (itemId: string) => { - if (offline) { - const queryParams = new URLSearchParams({ - itemId: itemId, - playbackPosition: - item.UserData?.PlaybackPositionTicks?.toString() ?? "", - }).toString(); - // @ts-expect-error - router.replace(`player/direct-player?${queryParams}`); - return; - } - const gotoItem = await getItemById(api, itemId); - if (!gotoItem) return; - goToItemCommon(gotoItem); - }, - [goToItemCommon, api], - ); - const updateTimes = useCallback( (currentProgress: number, maxValue: number) => { const current = isVlc ? currentProgress : ticksToSeconds(currentProgress); @@ -715,7 +694,7 @@ export const Controls: FC = ({ setEpisodeView(false)} - goToItem={goToItem} + goToItem={goToItemCommon} /> ) : ( <> diff --git a/components/video-player/controls/EpisodeList.tsx b/components/video-player/controls/EpisodeList.tsx index b5f52e57..67635479 100644 --- a/components/video-player/controls/EpisodeList.tsx +++ b/components/video-player/controls/EpisodeList.tsx @@ -27,7 +27,7 @@ import { runtimeTicksToSeconds } from "@/utils/time"; type Props = { item: BaseItemDto; close: () => void; - goToItem: (itemId: string) => Promise; + goToItem: (item: BaseItemDto) => void; }; export const seasonIndexAtom = atom({}); @@ -221,23 +221,24 @@ export const EpisodeList: React.FC = ({ item, close, goToItem }) => { ref={scrollViewRef} data={episodes} extraData={item} - renderItem={(_item, _idx) => ( + // Note otherItem is the item that is being rendered, not the item that is currently selected + renderItem={(otherItem, _idx) => ( { - goToItem(_item.Id); + goToItem(otherItem); }} > @@ -248,20 +249,20 @@ export const EpisodeList: React.FC = ({ item, close, goToItem }) => { height: 36, // lineHeight * 2 for consistent two-line space }} > - {_item.Name} + {otherItem.Name} - {`S${_item.ParentIndexNumber?.toString()}:E${_item.IndexNumber?.toString()}`} + {`S${otherItem.ParentIndexNumber?.toString()}:E${otherItem.IndexNumber?.toString()}`} - {runtimeTicksToSeconds(_item.RunTimeTicks)} + {runtimeTicksToSeconds(otherItem.RunTimeTicks)} - {_item.Overview} + {otherItem.Overview} )} diff --git a/hooks/usePlaybackManager.ts b/hooks/usePlaybackManager.ts index ea838581..855311b1 100644 --- a/hooks/usePlaybackManager.ts +++ b/hooks/usePlaybackManager.ts @@ -152,22 +152,37 @@ export const usePlaybackManager = ({ // Handle local state update for downloaded items if (localItem) { + const runTimeTicks = localItem.item.RunTimeTicks ?? 0; + const playedPercentage = + runTimeTicks > 0 ? (positionTicks / runTimeTicks) * 100 : 0; + + // Jellyfin thresholds + const MINIMUM_PERCENTAGE = 5; // 5% minimum to save progress + const PLAYED_THRESHOLD_PERCENTAGE = 90; // 90% to mark as played + const isItemConsideredPlayed = - (localItem.item.UserData?.PlayedPercentage ?? 0) > 90; + playedPercentage > PLAYED_THRESHOLD_PERCENTAGE; + const meetsMinimumPercentage = playedPercentage >= MINIMUM_PERCENTAGE; + + const shouldSaveProgress = + meetsMinimumPercentage && !isItemConsideredPlayed; + updateDownloadedItem(itemId, { ...localItem, item: { ...localItem.item, UserData: { ...localItem.item.UserData, - PlaybackPositionTicks: isItemConsideredPlayed - ? 0 - : Math.floor(positionTicks), + PlaybackPositionTicks: + isItemConsideredPlayed || !shouldSaveProgress + ? 0 + : Math.floor(positionTicks), Played: isItemConsideredPlayed, LastPlayedDate: new Date().toISOString(), - PlayedPercentage: isItemConsideredPlayed - ? 0 - : (positionTicks / localItem.item.RunTimeTicks!) * 100, + PlayedPercentage: + isItemConsideredPlayed || !shouldSaveProgress + ? 0 + : playedPercentage, }, }, });