From a4cd3ea6003b3b00bb328a30074096b096af4043 Mon Sep 17 00:00:00 2001 From: Alex Kim Date: Sun, 8 Dec 2024 07:15:34 +1100 Subject: [PATCH] WIP --- components/ContinueWatchingPoster.tsx | 36 +- components/series/SeasonPicker.tsx | 24 +- components/video-player/controls/Controls.tsx | 576 ++++++++++-------- .../video-player/controls/EpisodeList.tsx | 225 +++++++ .../video-player/controls/GotoEpisode.tsx | 0 5 files changed, 583 insertions(+), 278 deletions(-) create mode 100644 components/video-player/controls/EpisodeList.tsx create mode 100644 components/video-player/controls/GotoEpisode.tsx 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/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 c74c9c09..fc18d59d 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(progress.value); - }} - > - {!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(progress.value); }} - 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..0795fc6f --- /dev/null +++ b/components/video-player/controls/EpisodeList.tsx @@ -0,0 +1,225 @@ +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 { 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, MaterialCommunityIcons } 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"; + +type Props = { + item: BaseItemDto; + close: () => void; +}; + +export const EpisodeList: React.FC = ({ item, close }) => { + const [api] = useAtom(apiAtom); + const [user] = useAtom(userAtom); + const scrollViewRef = useRef(null); // Reference to the HorizontalScroll + const insets = useSafeAreaInsets(); // Get safe area insets + const [settings] = useSettings(); + const SeasonId = item.ParentId; + + const { data: episodes, isFetching } = useQuery({ + queryKey: ["episodes", SeasonId], + queryFn: async () => { + if (!api || !user?.Id || !item.Id || !SeasonId) return []; + const res = await getTvShowsApi(api).getEpisodes({ + seriesId: item.Id, + userId: user.Id, + seasonId: SeasonId, + enableUserData: true, + fields: ["MediaSources", "MediaStreams", "Overview"], + }); + + return res.data.Items; + }, + enabled: !!api && !!user?.Id && !!item.Id && !!SeasonId, + }); + + 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]); + + // Used for width calculation + const [nrOfEpisodes, setNrOfEpisodes] = useState(0); + useEffect(() => { + if (episodes && episodes.length > 0) { + setNrOfEpisodes(episodes.length); + } + }, [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); + console.log("Item", item); + 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 ? ( + + + + ) : ( + <> + + { + 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, + }} + /> + + + )} + + ); +}; diff --git a/components/video-player/controls/GotoEpisode.tsx b/components/video-player/controls/GotoEpisode.tsx new file mode 100644 index 00000000..e69de29b