From 7eb7d17fa9baf158da9ac33af192485fbacb4ae0 Mon Sep 17 00:00:00 2001 From: herrrta <@> Date: Sat, 30 Nov 2024 13:35:10 -0500 Subject: [PATCH] # New downloads page for downloaded TV-Series - Renamed downloads.tsx to index.tsx - Added new downloads/series.tsx page - Downloading now saves series primary image - Downloads index page now shows series primary image with downloaded episode counter - Updated EpisodeCard.tsx to display more information - Moved season dropdown from SeasonPicker.tsx into its own component SeasonDropdown.tsx - Updated navigation in DownloadItem.tsx to direct to series page when a downloaded episode is clicked --- app/(auth)/(tabs)/(home)/_layout.tsx | 11 +- .../(tabs)/(home)/downloads/[seriesId].tsx | 94 +++++++++++++++ .../{downloads.tsx => downloads/index.tsx} | 28 ++++- components/DownloadItem.tsx | 13 +- components/downloads/EpisodeCard.tsx | 69 ++++++----- components/downloads/SeriesCard.tsx | 88 +++++++------- components/series/SeasonDropdown.tsx | 112 ++++++++++++++++++ components/series/SeasonPicker.tsx | 87 ++------------ hooks/useRemuxHlsToMp4.ts | 3 + providers/DownloadProvider.tsx | 3 + utils/download.ts | 21 ++++ 11 files changed, 369 insertions(+), 160 deletions(-) create mode 100644 app/(auth)/(tabs)/(home)/downloads/[seriesId].tsx rename app/(auth)/(tabs)/(home)/{downloads.tsx => downloads/index.tsx} (84%) create mode 100644 components/series/SeasonDropdown.tsx create mode 100644 utils/download.ts diff --git a/app/(auth)/(tabs)/(home)/_layout.tsx b/app/(auth)/(tabs)/(home)/_layout.tsx index 83a4472e..d3ef928a 100644 --- a/app/(auth)/(tabs)/(home)/_layout.tsx +++ b/app/(auth)/(tabs)/(home)/_layout.tsx @@ -1,14 +1,11 @@ import { Chromecast } from "@/components/Chromecast"; -import { HeaderBackButton } from "@/components/common/HeaderBackButton"; import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack"; -import { useDownload } from "@/providers/DownloadProvider"; import { Feather } from "@expo/vector-icons"; import { Stack, useRouter } from "expo-router"; import { Platform, TouchableOpacity, View } from "react-native"; export default function IndexLayout() { const router = useRouter(); - return ( + ({}); + const {downloadedFiles} = useDownload(); + + const series = useMemo(() => { + try { + return downloadedFiles + ?.filter((f) => f.item.SeriesId == seriesId) + ?.sort((a, b) => a?.item.ParentIndexNumber! - b.item.ParentIndexNumber!) + || []; + } catch { + return []; + } + }, [downloadedFiles]); + + 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]); + + const initialSeasonIndex = useMemo(() => + Object.values(groupBySeason)?.[0]?.ParentIndexNumber ?? series?.[0]?.item?.ParentIndexNumber, + [groupBySeason] + ); + + useEffect(() => { + if (series.length > 0) { + navigation.setOptions({ + title: series[0].item.SeriesName, + }); + } + else { + storage.delete(seriesId); + router.back(); + } + }, [series]); + + return ( + <> + {series.length > 0 && + s.item)} + state={seasonIndexState} + initialSeasonIndex={initialSeasonIndex!} + onSelect={(season) => { + setSeasonIndexState((prev) => ({ + ...prev, + [series[0].item.ParentId ?? ""]: season.ParentIndexNumber, + })); + }}/> + + {groupBySeason.length} + + } + + {groupBySeason.map((episode) => ( + + + + ))} + + + ); +} \ No newline at end of file diff --git a/app/(auth)/(tabs)/(home)/downloads.tsx b/app/(auth)/(tabs)/(home)/downloads/index.tsx similarity index 84% rename from app/(auth)/(tabs)/(home)/downloads.tsx rename to app/(auth)/(tabs)/(home)/downloads/index.tsx index 02109e99..1f1fa74f 100644 --- a/app/(auth)/(tabs)/(home)/downloads.tsx +++ b/app/(auth)/(tabs)/(home)/downloads/index.tsx @@ -117,12 +117,28 @@ export default function page() { )} - {groupedBySeries?.map((items, index) => ( - i.item)} - key={items[0].item.SeriesId} - /> - ))} + {groupedBySeries.length > 0 && ( + + + TV-Series + + {groupedBySeries?.length} + + + + + {groupedBySeries?.map((items) => ( + + i.item)} + key={items[0].item.SeriesId} + /> + + ))} + + + + )} {downloadedFiles?.length === 0 && ( No downloaded items diff --git a/components/DownloadItem.tsx b/components/DownloadItem.tsx index 71409048..c7371010 100644 --- a/components/DownloadItem.tsx +++ b/components/DownloadItem.tsx @@ -19,7 +19,7 @@ import { BaseItemDto, MediaSourceInfo, } from "@jellyfin/sdk/lib/generated-client/models"; -import { router, useFocusEffect } from "expo-router"; +import {Href, router, useFocusEffect} from "expo-router"; import { useAtom } from "jotai"; import { useCallback, useMemo, useRef, useState } from "react"; import { Alert, TouchableOpacity, View, ViewProps } from "react-native"; @@ -206,7 +206,16 @@ export const DownloadItem: React.FC = ({ item, ...props }) => { ) : isDownloaded ? ( { - router.push("/downloads"); + router.push( + item.Type !== "Episode" + ? "/downloads" + : { + pathname: `/downloads/${item.SeriesId}`, + params: { + episodeSeasonIndex: item.ParentIndexNumber + } + } as Href + ); }} > diff --git a/components/downloads/EpisodeCard.tsx b/components/downloads/EpisodeCard.tsx index 80a50d3d..9b821df7 100644 --- a/components/downloads/EpisodeCard.tsx +++ b/components/downloads/EpisodeCard.tsx @@ -1,6 +1,6 @@ import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import * as Haptics from "expo-haptics"; -import React, { useCallback, useMemo, useRef } from "react"; +import React, { useCallback, useMemo } from "react"; import { TouchableOpacity, View } from "react-native"; import { ActionSheetProvider, @@ -8,12 +8,12 @@ import { } from "@expo/react-native-action-sheet"; import { useDownloadedFileOpener } from "@/hooks/useDownloadedFileOpener"; -import { Text } from "../common/Text"; import { useDownload } from "@/providers/DownloadProvider"; import { storage } from "@/utils/mmkv"; import { Image } from "expo-image"; -import { ItemCardText } from "../ItemCardText"; import { Ionicons } from "@expo/vector-icons"; +import {Text} from "@/components/common/Text"; +import {runtimeTicksToSeconds} from "@/utils/time"; interface EpisodeCardProps { item: BaseItemDto; @@ -31,7 +31,7 @@ export const EpisodeCard: React.FC = ({ item }) => { const base64Image = useMemo(() => { return storage.getString(item.Id!); - }, []); + }, [item]); const handleOpenFile = useCallback(() => { openFile(item); @@ -76,32 +76,47 @@ export const EpisodeCard: React.FC = ({ item }) => { - {base64Image ? ( - - + + + {base64Image ? ( + + + + ) : ( + + + + )} - ) : ( - - + + + {item.Name} + + + {`S${item.ParentIndexNumber?.toString()}:E${item.IndexNumber?.toString()}`} + + + {runtimeTicksToSeconds(item.RunTimeTicks)} + - )} - + + {item.Overview} ); }; diff --git a/components/downloads/SeriesCard.tsx b/components/downloads/SeriesCard.tsx index 5c46eb50..c02d3ecd 100644 --- a/components/downloads/SeriesCard.tsx +++ b/components/downloads/SeriesCard.tsx @@ -1,55 +1,51 @@ import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; -import { ScrollView, View } from "react-native"; -import { EpisodeCard } from "./EpisodeCard"; +import {TouchableOpacity, View} from "react-native"; import { Text } from "../common/Text"; -import { useMemo } from "react"; -import { SeasonPicker } from "../series/SeasonPicker"; +import React, {useMemo} from "react"; +import {storage} from "@/utils/mmkv"; +import {Image} from "expo-image"; +import {Ionicons} from "@expo/vector-icons"; +import {router} from "expo-router"; -export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({ items }) => { - const groupBySeason = useMemo(() => { - const seasons: Record = {}; - - items.forEach((item) => { - if (!seasons[item.SeasonName!]) { - seasons[item.SeasonName!] = []; - } - - seasons[item.SeasonName!].push(item); - }); - - return Object.values(seasons).sort( - (a, b) => a[0].IndexNumber! - b[0].IndexNumber! - ); - }, [items]); - - const sortByIndex = (a: BaseItemDto, b: BaseItemDto) => { - return a.IndexNumber! > b.IndexNumber! ? 1 : -1; - }; +export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({items}) => { + const base64Image = useMemo(() => { + return storage.getString(items[0].SeriesId!); + }, []); return ( - - - {items[0].SeriesName} - - {items.length} + router.push(`/downloads/${items[0].SeriesId}`)}> + {base64Image ? ( + + + + {items.length} + - + ) : ( + + + + )} - TV-Series - {groupBySeason.map((seasonItems, seasonIndex) => ( - - - {seasonItems[0].SeasonName} - - - - {seasonItems.sort(sortByIndex)?.map((item, index) => ( - - ))} - - - - ))} - + + {items[0].SeriesName} + {items[0].ProductionYear} + + ); }; diff --git a/components/series/SeasonDropdown.tsx b/components/series/SeasonDropdown.tsx new file mode 100644 index 00000000..f34609ea --- /dev/null +++ b/components/series/SeasonDropdown.tsx @@ -0,0 +1,112 @@ +import {BaseItemDto} from "@jellyfin/sdk/lib/generated-client/models"; +import {useEffect, useMemo} from "react"; +import {TouchableOpacity, View} from "react-native"; +import * as DropdownMenu from "zeego/dropdown-menu"; +import {Text} from "../common/Text"; + +type Props = { + item: BaseItemDto; + seasons: BaseItemDto[]; + initialSeasonIndex?: number; + state: SeasonIndexState; + onSelect: (season: BaseItemDto) => void +}; + +type SeasonKeys = { + id: keyof BaseItemDto, + title: keyof BaseItemDto, + index: keyof BaseItemDto +} + +export type SeasonIndexState = { + [seriesId: string]: number | null | undefined; +}; + +export const SeasonDropdown: React.FC = ({ + item, + seasons, + initialSeasonIndex, + state, + onSelect +}) => { + const keys = useMemo(() => + item.Type === "Episode" ? { + id: "ParentId", + title: "SeasonName", + index: "ParentIndexNumber" + } + : { + id: "Id", + title: "Name", + index: "IndexNumber" + }, [item] + ); + const seasonIndex = useMemo(() => state[item[keys.id] ?? ""], [state]); + + useEffect(() => { + if (seasons && seasons.length > 0 && seasonIndex === undefined) { + let initialIndex: number | undefined; + + if (initialSeasonIndex !== undefined) { + // Use the provided initialSeasonIndex if it exists in the seasons + const seasonExists = seasons.some( + (season: any) => season[keys.index] === initialSeasonIndex + ); + if (seasonExists) { + initialIndex = initialSeasonIndex; + } + } + + if (initialIndex === undefined) { + // Fall back to the previous logic if initialIndex is not set + const season1 = seasons.find((season: any) => season[keys.index] === 1); + const season0 = seasons.find((season: any) => season[keys.index] === 0); + const firstSeason = season1 || season0 || seasons[0]; + onSelect(firstSeason) + } + + if (initialIndex !== undefined) { + const initialSeason = seasons.find((season: any) => + season[keys.index] === initialIndex + ) + + if (initialSeason) onSelect(initialSeason!) + else throw Error("Initial index could not be found!") + } + } + }, [seasons, seasonIndex, item[keys.id], initialSeasonIndex]); + + const sortByIndex = (a: BaseItemDto, b: BaseItemDto) => a[keys.index] - b[keys.index]; + + return ( + + + + + Season {seasonIndex} + + + + + Seasons + {seasons?.sort(sortByIndex).map((season: any) => ( + onSelect(season)} + > + {season[keys.title]} + + ))} + + + ); +}; diff --git a/components/series/SeasonPicker.tsx b/components/series/SeasonPicker.tsx index b0da7661..1fa3b3b7 100644 --- a/components/series/SeasonPicker.tsx +++ b/components/series/SeasonPicker.tsx @@ -2,30 +2,23 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { runtimeTicksToSeconds } from "@/utils/time"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { useRouter } from "expo-router"; import { atom, useAtom } from "jotai"; import { useEffect, useMemo, useState } from "react"; -import { TouchableOpacity, View } from "react-native"; -import * as DropdownMenu from "zeego/dropdown-menu"; +import { View } from "react-native"; import ContinueWatchingPoster from "../ContinueWatchingPoster"; import { DownloadItem } from "../DownloadItem"; import { Loader } from "../Loader"; import { Text } from "../common/Text"; import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api"; import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData"; -import { Image } from "expo-image"; -import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById"; import { TouchableItemRouter } from "../common/TouchableItemRouter"; +import {SeasonDropdown, SeasonIndexState} from "@/components/series/SeasonDropdown"; type Props = { item: BaseItemDto; initialSeasonIndex?: number; }; -type SeasonIndexState = { - [seriesId: string]: number; -}; - export const seasonIndexAtom = atom({}); export const SeasonPicker: React.FC = ({ item, initialSeasonIndex }) => { @@ -35,8 +28,6 @@ export const SeasonPicker: React.FC = ({ item, initialSeasonIndex }) => { const seasonIndex = seasonIndexState[item.Id ?? ""]; - const router = useRouter(); - const { data: seasons } = useQuery({ queryKey: ["seasons", item.Id], queryFn: async () => { @@ -61,37 +52,6 @@ export const SeasonPicker: React.FC = ({ item, initialSeasonIndex }) => { enabled: !!api && !!user?.Id && !!item.Id, }); - useEffect(() => { - if (seasons && seasons.length > 0 && seasonIndex === undefined) { - let initialIndex: number | undefined; - - if (initialSeasonIndex !== undefined) { - // Use the provided initialSeasonIndex if it exists in the seasons - const seasonExists = seasons.some( - (season: any) => season.IndexNumber === initialSeasonIndex - ); - if (seasonExists) { - initialIndex = initialSeasonIndex; - } - } - - if (initialIndex === undefined) { - // Fall back to the previous logic if initialIndex is not set - const season1 = seasons.find((season: any) => season.IndexNumber === 1); - const season0 = seasons.find((season: any) => season.IndexNumber === 0); - const firstSeason = season1 || season0 || seasons[0]; - initialIndex = firstSeason.IndexNumber; - } - - if (initialIndex !== undefined) { - setSeasonIndexState((prev) => ({ - ...prev, - [item.Id ?? ""]: initialIndex, - })); - } - } - }, [seasons, seasonIndex, setSeasonIndexState, item.Id, initialSeasonIndex]); - const selectedSeasonId: string | null = useMemo( () => seasons?.find((season: any) => season.IndexNumber === seasonIndex)?.Id, @@ -148,39 +108,16 @@ export const SeasonPicker: React.FC = ({ item, initialSeasonIndex }) => { minHeight: 144 * nrOfEpisodes, }} > - - - - - Season {seasonIndex} - - - - - Seasons - {seasons?.map((season: any) => ( - { - setSeasonIndexState((prev) => ({ - ...prev, - [item.Id ?? ""]: season.IndexNumber, - })); - }} - > - {season.Name} - - ))} - - + { + setSeasonIndexState((prev) => ({ + ...prev, + [item.Id ?? ""]: season.IndexNumber, + })); + }} /> {isFetching ? ( { const { saveDownloadedItemInfo, setProcesses } = useDownload(); const router = useRouter(); const { saveImage } = useImageStorage(); + const { saveSeriesPrimaryImage } = useDownloadHelper(); const startRemuxing = useCallback( async (item: BaseItemDto, url: string, mediaSource: MediaSourceInfo) => { @@ -35,6 +37,7 @@ export const useRemuxHlsToMp4 = () => { if (!api) throw new Error("API is not defined"); if (!item.Id) throw new Error("Item must have an Id"); + await saveSeriesPrimaryImage(item); const itemImage = getItemImage({ item, api, diff --git a/providers/DownloadProvider.tsx b/providers/DownloadProvider.tsx index 6b95dcb4..c1e712bc 100644 --- a/providers/DownloadProvider.tsx +++ b/providers/DownloadProvider.tsx @@ -46,6 +46,7 @@ import * as Notifications from "expo-notifications"; import { getItemImage } from "@/utils/getItemImage"; import useImageStorage from "@/hooks/useImageStorage"; import { storage } from "@/utils/mmkv"; +import useDownloadHelper from "@/utils/download"; export type DownloadedItem = { item: Partial; @@ -66,6 +67,7 @@ function useDownloadProvider() { const router = useRouter(); const [api] = useAtom(apiAtom); + const {saveSeriesPrimaryImage} = useDownloadHelper(); const { saveImage } = useImageStorage(); const [processes, setProcesses] = useState([]); @@ -311,6 +313,7 @@ function useDownloadProvider() { const fileExtension = mediaSource.TranscodingContainer; const deviceId = await getOrSetDeviceId(); + await saveSeriesPrimaryImage(item); const itemImage = getItemImage({ item, api, diff --git a/utils/download.ts b/utils/download.ts new file mode 100644 index 00000000..4d7b9ea0 --- /dev/null +++ b/utils/download.ts @@ -0,0 +1,21 @@ +import {getPrimaryImageUrlById} from "@/utils/jellyfin/image/getPrimaryImageUrlById"; +import {BaseItemDto} from "@jellyfin/sdk/lib/generated-client"; +import useImageStorage from "@/hooks/useImageStorage"; +import {apiAtom} from "@/providers/JellyfinProvider"; +import {useAtom} from "jotai"; +import {storage} from "@/utils/mmkv"; + +const useDownloadHelper = () => { + const [api] = useAtom(apiAtom); + const {saveImage} = useImageStorage(); + + const saveSeriesPrimaryImage = async (item: BaseItemDto) => { + if (item.Type === "Episode" && item.SeriesId && !storage.getString(item.SeriesId)) { + await saveImage(item.SeriesId, getPrimaryImageUrlById({ api, id: item.SeriesId })) + } + } + + return { saveSeriesPrimaryImage } +} + +export default useDownloadHelper; \ No newline at end of file