diff --git a/components/ContinueWatchingPoster.tsx b/components/ContinueWatchingPoster.tsx index 641927f2..eb000d45 100644 --- a/components/ContinueWatchingPoster.tsx +++ b/components/ContinueWatchingPoster.tsx @@ -1,27 +1,30 @@ import { apiAtom } from "@/providers/JellyfinProvider"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { Image } from "expo-image"; -import { useAtom, useAtomValue } from "jotai"; -import { useMemo, useState } from "react"; +import { useAtomValue } from "jotai"; +import { useMemo } from "react"; import { View } from "react-native"; import { WatchedIndicator } from "./WatchedIndicator"; import React from "react"; +import { Ionicons } from "@expo/vector-icons"; type ContinueWatchingPosterProps = { item: BaseItemDto; useEpisodePoster?: boolean; size?: "small" | "normal"; + showPlayButton?: boolean; }; const ContinueWatchingPoster: React.FC = ({ item, useEpisodePoster = false, size = "normal", + showPlayButton = false, }) => { const api = useAtomValue(apiAtom); /** - * Get horrizontal poster for movie and episode, with failover to primary. + * Get horizontal poster for movie and episode, with failover to primary. */ const url = useMemo(() => { if (!api) return; @@ -73,16 +76,23 @@ const ContinueWatchingPoster: React.FC = ({ ${size === "small" ? "w-32" : "w-44"} `} > - + + + {showPlayButton && ( + + + + )} + {!progress && } {progress > 0 && ( <> diff --git a/components/series/SeasonDropdown.tsx b/components/series/SeasonDropdown.tsx index f34609ea..1d007c64 100644 --- a/components/series/SeasonDropdown.tsx +++ b/components/series/SeasonDropdown.tsx @@ -1,22 +1,22 @@ -import {BaseItemDto} from "@jellyfin/sdk/lib/generated-client/models"; -import {useEffect, useMemo} from "react"; -import {TouchableOpacity, View} from "react-native"; +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"; +import { Text } from "../common/Text"; type Props = { item: BaseItemDto; seasons: BaseItemDto[]; initialSeasonIndex?: number; state: SeasonIndexState; - onSelect: (season: BaseItemDto) => void + onSelect: (season: BaseItemDto) => void; }; type SeasonKeys = { - id: keyof BaseItemDto, - title: keyof BaseItemDto, - index: keyof BaseItemDto -} + id: keyof BaseItemDto; + title: keyof BaseItemDto; + index: keyof BaseItemDto; +}; export type SeasonIndexState = { [seriesId: string]: number | null | undefined; @@ -27,19 +27,22 @@ export const SeasonDropdown: React.FC = ({ seasons, initialSeasonIndex, state, - onSelect + onSelect, }) => { - const keys = useMemo(() => - item.Type === "Episode" ? { - id: "ParentId", - title: "SeasonName", - index: "ParentIndexNumber" - } - : { - id: "Id", - title: "Name", - index: "IndexNumber" - }, [item] + 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]); @@ -62,28 +65,28 @@ export const SeasonDropdown: React.FC = ({ 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) + onSelect(firstSeason); } if (initialIndex !== undefined) { - const initialSeason = seasons.find((season: any) => - season[keys.index] === initialIndex - ) + const initialSeason = seasons.find( + (season: any) => season[keys.index] === initialIndex + ); - if (initialSeason) onSelect(initialSeason!) - else throw Error("Initial index could not be found!") + 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]; + const sortByIndex = (a: BaseItemDto, b: BaseItemDto) => + a[keys.index] - b[keys.index]; return ( - + Season {seasonIndex} @@ -103,7 +106,9 @@ export const SeasonDropdown: React.FC = ({ key={season[keys.title]} onSelect={() => onSelect(season)} > - {season[keys.title]} + + {season[keys.title]} + ))} diff --git a/components/series/SeasonPicker.tsx b/components/series/SeasonPicker.tsx index d968532b..28ca9ccb 100644 --- a/components/series/SeasonPicker.tsx +++ b/components/series/SeasonPicker.tsx @@ -6,14 +6,17 @@ import { atom, useAtom } from "jotai"; import { useEffect, useMemo, useState } from "react"; import { View } from "react-native"; import ContinueWatchingPoster from "../ContinueWatchingPoster"; -import {DownloadItems, DownloadSingleItem} from "../DownloadItem"; +import { DownloadItems, DownloadSingleItem } 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 { TouchableItemRouter } from "../common/TouchableItemRouter"; -import {SeasonDropdown, SeasonIndexState} from "@/components/series/SeasonDropdown"; -import {Ionicons, MaterialCommunityIcons} from "@expo/vector-icons"; +import { + SeasonDropdown, + SeasonIndexState, +} from "@/components/series/SeasonDropdown"; +import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons"; type Props = { item: BaseItemDto; @@ -119,14 +122,23 @@ export const SeasonPicker: React.FC = ({ item, initialSeasonIndex }) => { ...prev, [item.Id ?? ""]: season.IndexNumber, })); - }} /> + }} + /> ( - + )} DownloadedIconComponent={() => ( - + )} /> diff --git a/components/video-player/controls/Controls.tsx b/components/video-player/controls/Controls.tsx index d57e4931..f7b7940d 100644 --- a/components/video-player/controls/Controls.tsx +++ b/components/video-player/controls/Controls.tsx @@ -53,6 +53,11 @@ import DropdownViewTranscoding from "./dropdown/DropdownViewTranscoding"; import BrightnessSlider from "./BrightnessSlider"; import SkipButton from "./SkipButton"; import { debounce } from "lodash"; +import { EpisodeList } from "./EpisodeList"; +import { BlurView } from "expo-blur"; +import { getItemById } from "@/utils/jellyfin/user-library/getItemById"; +import { useAtom } from "jotai"; +import { apiAtom } from "@/providers/JellyfinProvider"; interface Props { item: BaseItemDto; @@ -113,6 +118,7 @@ export const Controls: React.FC = ({ const [settings] = useSettings(); const router = useRouter(); const insets = useSafeAreaInsets(); + const [api] = useAtom(apiAtom); const { previousItem, nextItem } = useAdjacentItems({ item }); const { @@ -397,292 +403,344 @@ export const Controls: React.FC = ({ ); }, [trickPlayUrl, trickplayInfo, time]); + const [EpisodeView, setEpisodeView] = useState(false); + + const switchOnEpisodeMode = () => { + setEpisodeView(true); + if (isPlaying) togglePlay(progress.value); + }; + + const gotoEpisode = async (itemId: string) => { + const item = await getItemById(api, itemId); + console.log("Item", item); + if (!settings || !item) return; + + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + + const { bitrate, mediaSource, audioIndex, subtitleIndex } = + getDefaultPlaySettings(item, settings); + + const queryParams = new URLSearchParams({ + itemId: item.Id ?? "", // Ensure itemId is a string + audioIndex: audioIndex?.toString() ?? "", + subtitleIndex: subtitleIndex?.toString() ?? "", + mediaSourceId: mediaSource?.Id ?? "", // Ensure mediaSourceId is a string + bitrateValue: bitrate.toString(), + }).toString(); + + if (!bitrate.value) { + // @ts-expect-error + router.replace(`player/direct-player?${queryParams}`); + return; + } + // @ts-expect-error + router.replace(`player/transcoding-player?${queryParams}`); + }; + return ( - - {!mediaSource?.TranscodingUrl ? ( - - ) : ( - - )} - - - { - toggleControls(); - }} - style={{ - position: "absolute", - width: Dimensions.get("window").width, - height: Dimensions.get("window").height, - }} - > - - - {previousItem && ( - setEpisodeView(false)} /> + ) : ( + <> + - - - )} + {!mediaSource?.TranscodingUrl ? ( + + ) : ( + + )} + - {nextItem && ( - - - - )} - - {mediaSource?.TranscodingUrl && ( - - - - )} - { - if (stop) await stop(); - router.back(); - }} - className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2" - > - - - - - - - - - - { + toggleControls(); }} - > - - + + - {settings?.rewindSkipTime} - - - - - { - togglePlay(); - }} - > - {!isBuffering ? ( - - ) : ( - - )} - - - - - - - {settings?.forwardSkipTime} - - - - - - - - - {item?.Name} {item?.Type === "Episode" && ( - {item.SeriesName} + { + switchOnEpisodeMode(); + }} + className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2" + > + + )} - {item?.Type === "Movie" && ( - {item?.ProductionYear} + {previousItem && ( + + + )} - {item?.Type === "Audio" && ( - {item?.Album} + + {nextItem && ( + + + )} + + {mediaSource?.TranscodingUrl && ( + + + + )} + { + if (stop) await stop(); + router.back(); + }} + className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2" + > + + + - - - - - - - ( - + + + + + - )} - cache={cacheProgress} - onSlidingStart={handleSliderStart} - onSlidingComplete={handleSliderComplete} - onValueChange={handleSliderChange} - containerStyle={{ - borderRadius: 100, + + {settings?.rewindSkipTime} + + + + + { + togglePlay(); }} - renderBubble={() => isSliding && memoizedRenderBubble()} - sliderHeight={10} - thumbWidth={0} - progress={progress} - minimumValue={min} - maximumValue={max} - /> - - - {formatTimeString(currentTime, isVlc ? "ms" : "s")} - - - -{formatTimeString(remainingTime, isVlc ? "ms" : "s")} - + > + {!isBuffering ? ( + + ) : ( + + )} + + + + + + + {settings?.forwardSkipTime} + + + + + + + + + {item?.Name} + {item?.Type === "Episode" && ( + {item.SeriesName} + )} + {item?.Type === "Movie" && ( + + {item?.ProductionYear} + + )} + {item?.Type === "Audio" && ( + {item?.Album} + )} + + + + + + + + + ( + + )} + cache={cacheProgress} + onSlidingStart={handleSliderStart} + onSlidingComplete={handleSliderComplete} + onValueChange={handleSliderChange} + containerStyle={{ + borderRadius: 100, + }} + renderBubble={() => isSliding && memoizedRenderBubble()} + sliderHeight={10} + thumbWidth={0} + progress={progress} + minimumValue={min} + maximumValue={max} + /> + + + {formatTimeString(currentTime, isVlc ? "ms" : "s")} + + + -{formatTimeString(remainingTime, isVlc ? "ms" : "s")} + + + - - + + )} ); }; diff --git a/components/video-player/controls/EpisodeList.tsx b/components/video-player/controls/EpisodeList.tsx new file mode 100644 index 00000000..f3ddfbc9 --- /dev/null +++ b/components/video-player/controls/EpisodeList.tsx @@ -0,0 +1,294 @@ +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 { atom, useAtom } from "jotai"; +import { useEffect, useMemo, useState, useRef } from "react"; +import { View, TouchableOpacity } from "react-native"; +import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api"; +import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData"; +import { Ionicons } from "@expo/vector-icons"; +import { Loader } from "@/components/Loader"; +import ContinueWatchingPoster from "@/components/ContinueWatchingPoster"; +import { Text } from "@/components/common/Text"; +import { DownloadSingleItem } from "@/components/DownloadItem"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { + HorizontalScroll, + HorizontalScrollRef, +} from "@/components/common/HorrizontalScroll"; +import { router } from "expo-router"; +import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings"; +import { getItemById } from "@/utils/jellyfin/user-library/getItemById"; +import { useSettings } from "@/utils/atoms/settings"; +import { + SeasonDropdown, + SeasonIndexState, +} from "@/components/series/SeasonDropdown"; +import { Item } from "zeego/dropdown-menu"; + +type Props = { + item: BaseItemDto; + close: () => void; +}; + +export const seasonIndexAtom = atom({}); + +export const EpisodeList: React.FC = ({ item, close }) => { + const [api] = useAtom(apiAtom); + const [user] = useAtom(userAtom); + const insets = useSafeAreaInsets(); // Get safe area insets + const [settings] = useSettings(); + const [seasonIndexState, setSeasonIndexState] = useAtom(seasonIndexAtom); + const scrollViewRef = useRef(null); // Reference to the HorizontalScroll + const scrollToIndex = (index: number) => { + scrollViewRef.current?.scrollToIndex(index, 100); + }; + + // Set the initial season index + useEffect(() => { + if (item.SeriesId) { + setSeasonIndexState((prev) => ({ + ...prev, + [item.SeriesId ?? ""]: item.ParentIndexNumber ?? 0, + })); + } + }, []); + + const seasonIndex = seasonIndexState[item.SeriesId ?? ""]; + const [seriesItem, setSeriesItem] = useState(null); + + // This effect fetches the series item data/ + useEffect(() => { + if (item.SeriesId) { + getUserItemData({ api, userId: user?.Id, itemId: item.SeriesId }).then( + (res) => { + setSeriesItem(res); + } + ); + } + }, [item.SeriesId]); + + const { data: seasons } = useQuery({ + queryKey: ["seasons", item.SeriesId], + queryFn: async () => { + if (!api || !user?.Id || !item.SeriesId) return []; + const response = await api.axiosInstance.get( + `${api.basePath}/Shows/${item.SeriesId}/Seasons`, + { + params: { + userId: user?.Id, + itemId: item.SeriesId, + Fields: + "ItemCounts,PrimaryImageAspectRatio,CanDelete,MediaSourceCount", + }, + headers: { + Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`, + }, + } + ); + return response.data.Items; + }, + enabled: !!api && !!user?.Id && !!item.SeasonId, + }); + + const selectedSeasonId: string | null = useMemo( + () => + seasons?.find((season: any) => season.IndexNumber === seasonIndex)?.Id, + [seasons, seasonIndex] + ); + + const { data: episodes, isFetching } = useQuery({ + queryKey: ["episodes", item.SeriesId, selectedSeasonId], + queryFn: async () => { + if (!api || !user?.Id || !item.Id || !selectedSeasonId) return []; + const res = await getTvShowsApi(api).getEpisodes({ + seriesId: item.SeriesId || "", + userId: user.Id, + seasonId: selectedSeasonId || undefined, + enableUserData: true, + fields: ["MediaSources", "MediaStreams", "Overview"], + }); + + return res.data.Items; + }, + enabled: !!api && !!user?.Id && !!selectedSeasonId, + }); + + useEffect(() => { + if (item?.Type === "Episode" && item.Id) { + const index = episodes?.findIndex((ep) => ep.Id === item.Id); + if (index !== undefined && index !== -1) { + setTimeout(() => { + scrollToIndex(index); + }, 400); + } + } + }, [episodes, item]); + + const queryClient = useQueryClient(); + useEffect(() => { + for (let e of episodes || []) { + queryClient.prefetchQuery({ + queryKey: ["item", e.Id], + queryFn: async () => { + if (!e.Id) return; + const res = await getUserItemData({ + api, + userId: user?.Id, + itemId: e.Id, + }); + return res; + }, + staleTime: 60 * 5 * 1000, + }); + } + }, [episodes]); + + // Scroll to the current item when episodes are fetched + useEffect(() => { + if (episodes && scrollViewRef.current) { + const currentItemIndex = episodes.findIndex((e) => e.Id === item.Id); + if (currentItemIndex !== -1) { + scrollViewRef.current.scrollToIndex(currentItemIndex, 16); // Adjust the scroll position based on item width + } + } + }, [episodes, item.Id]); + + const gotoEpisode = async (itemId: string) => { + const item = await getItemById(api, itemId); + if (!settings || !item) return; + + const { bitrate, mediaSource, audioIndex, subtitleIndex } = + getDefaultPlaySettings(item, settings); + + const queryParams = new URLSearchParams({ + itemId: item.Id ?? "", // Ensure itemId is a string + audioIndex: audioIndex?.toString() ?? "", + subtitleIndex: subtitleIndex?.toString() ?? "", + mediaSourceId: mediaSource?.Id ?? "", // Ensure mediaSourceId is a string + bitrateValue: bitrate.toString(), + }).toString(); + + if (!bitrate.value) { + // @ts-expect-error + router.replace(`player/direct-player?${queryParams}`); + return; + } + // @ts-expect-error + router.replace(`player/transcoding-player?${queryParams}`); + }; + + return ( + + {isFetching ? ( + + + + ) : ( + <> + + {seriesItem && ( + { + setSeasonIndexState((prev) => ({ + ...prev, + [item.SeriesId ?? ""]: season.IndexNumber, + })); + }} + /> + )} + { + close(); + }} + className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2" + > + + + + + ( + + { + gotoEpisode(_item.Id); + }} + > + + + + + {_item.Name} + + + {`S${_item.ParentIndexNumber?.toString()}:E${_item.IndexNumber?.toString()}`} + + + {runtimeTicksToSeconds(_item.RunTimeTicks)} + + + + + + + {_item.Overview} + + + )} + keyExtractor={(e: BaseItemDto) => e.Id ?? ""} + estimatedItemSize={200} + showsHorizontalScrollIndicator={false} + contentContainerStyle={{ + paddingHorizontal: 16, + }} + /> + + + )} + + ); +};