diff --git a/app/(auth)/(tabs)/(home)/index.tsx b/app/(auth)/(tabs)/(home)/index.tsx index 7f0fd2f6..cb0b43bd 100644 --- a/app/(auth)/(tabs)/(home)/index.tsx +++ b/app/(auth)/(tabs)/(home)/index.tsx @@ -169,7 +169,7 @@ export default function index() { setLoading(true); await queryClient.invalidateQueries(); setLoading(false); - }, [queryClient, user?.Id]); + }, []); const createCollectionConfig = useCallback( ( diff --git a/app/(auth)/(tabs)/(home,libraries,search)/items/page.tsx b/app/(auth)/(tabs)/(home,libraries,search)/items/page.tsx index c496ea88..d01acb29 100644 --- a/app/(auth)/(tabs)/(home,libraries,search)/items/page.tsx +++ b/app/(auth)/(tabs)/(home,libraries,search)/items/page.tsx @@ -1,15 +1,96 @@ +import { Text } from "@/components/common/Text"; import { ItemContent } from "@/components/ItemContent"; -import { Stack, useLocalSearchParams } from "expo-router"; -import React from "react"; +import { Loader } from "@/components/Loader"; +import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; +import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData"; +import { useQuery } from "@tanstack/react-query"; +import { useLocalSearchParams } from "expo-router"; +import { useAtom } from "jotai"; +import React, { useEffect } from "react"; +import { View } from "react-native"; +import Animated, { + runOnJS, + useAnimatedStyle, + useSharedValue, + withTiming, +} from "react-native-reanimated"; +import { opacity } from "react-native-reanimated/lib/typescript/Colors"; const Page: React.FC = () => { + const [api] = useAtom(apiAtom); + const [user] = useAtom(userAtom); const { id } = useLocalSearchParams() as { id: string }; + const { data: item, isError } = useQuery({ + queryKey: ["item", id], + queryFn: async () => { + const res = await getUserItemData({ + api, + userId: user?.Id, + itemId: id, + }); + + return res; + }, + enabled: !!id && !!api, + staleTime: 60 * 1000 * 5, // 5 minutes + }); + + const opacity = useSharedValue(1); + const animatedStyle = useAnimatedStyle(() => { + return { + opacity: opacity.value, + }; + }); + + const fadeOut = (callback: any) => { + opacity.value = withTiming(0, { duration: 300 }, (finished) => { + if (finished) { + runOnJS(callback)(); + } + }); + }; + + const fadeIn = (callback: any) => { + opacity.value = withTiming(1, { duration: 300 }, (finished) => { + if (finished) { + runOnJS(callback)(); + } + }); + }; + useEffect(() => { + if (item) { + fadeOut(() => {}); + } else { + fadeIn(() => {}); + } + }, [item]); + + if (isError) + return ( + + Could not load item + + ); + return ( - <> - - - + + + + + + + + + + + + {item && } + ); }; diff --git a/app/(auth)/(tabs)/(home,libraries,search)/livetv/guide.tsx b/app/(auth)/(tabs)/(home,libraries,search)/livetv/guide.tsx index 9bbae531..24b69113 100644 --- a/app/(auth)/(tabs)/(home,libraries,search)/livetv/guide.tsx +++ b/app/(auth)/(tabs)/(home,libraries,search)/livetv/guide.tsx @@ -1,11 +1,142 @@ +import { ItemImage } from "@/components/common/ItemImage"; import { Text } from "@/components/common/Text"; -import React from "react"; -import { View } from "react-native"; +import { HourHeader } from "@/components/livetv/HourHeader"; +import { LiveTVGuideRow } from "@/components/livetv/LiveTVGuideRow"; +import { TAB_HEIGHT } from "@/constants/Values"; +import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; +import { + BaseItemDto, + BaseItemDtoQueryResult, +} from "@jellyfin/sdk/lib/generated-client"; +import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api"; +import { useQuery } from "@tanstack/react-query"; +import { useAtom } from "jotai"; +import React, { useState } from "react"; +import { Dimensions, ScrollView, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +const HOUR_HEIGHT = 30 export default function page() { + const [api] = useAtom(apiAtom); + const [user] = useAtom(userAtom); + const insets = useSafeAreaInsets(); + const [date, setDate] = useState(new Date()); + + const { data: guideInfo } = useQuery({ + queryKey: ["livetv", "guideInfo"], + queryFn: async () => { + const res = await getLiveTvApi(api!).getGuideInfo(); + return res.data; + }, + }); + const { data: channels } = useQuery({ + queryKey: ["livetv", "channels"], + queryFn: async () => { + const res = await getLiveTvApi(api!).getLiveTvChannels({ + startIndex: 0, + limit: 500, + enableFavoriteSorting: true, + userId: user?.Id, + addCurrentProgram: false, + enableUserData: false, + enableImageTypes: ["Primary"], + }); + return res.data; + }, + }); + + const { data: programs } = useQuery({ + queryKey: ["livetv", "programs", date], + queryFn: async () => { + const startOfDay = new Date(date); + startOfDay.setHours(0, 0, 0, 0); + const endOfDay = new Date(date); + endOfDay.setHours(23, 59, 59, 999); + + const now = new Date(); + const isToday = startOfDay.toDateString() === now.toDateString(); + + const res = await getLiveTvApi(api!).getPrograms({ + getProgramsDto: { + MaxStartDate: endOfDay.toISOString(), + MinEndDate: isToday ? now.toISOString() : startOfDay.toISOString(), + ChannelIds: channels?.Items?.map((c) => c.Id).filter( + Boolean + ) as string[], + ImageTypeLimit: 1, + EnableImages: false, + SortBy: ["StartDate"], + EnableTotalRecordCount: false, + EnableUserData: false, + }, + }); + return res.data; + }, + enabled: !!channels, + }); + + const screenWidth = Dimensions.get("window").width; + + const [scrollX, setScrollX] = useState(0); + return ( - - Not implemented - + + + + + {channels?.Items?.map((c, i) => ( + + + + ))} + + { + setScrollX(e.nativeEvent.contentOffset.x); + }} + > + + + {channels?.Items?.map((c, i) => ( + + ))} + + + + ); } diff --git a/app/_layout.tsx b/app/_layout.tsx index 9629f22d..828f4005 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -337,6 +337,7 @@ function Layout() { name="(auth)/play" options={{ headerShown: false, + autoHideHomeIndicator: true, title: "", animation: "fade", }} @@ -345,6 +346,7 @@ function Layout() { name="(auth)/play-music" options={{ headerShown: false, + autoHideHomeIndicator: true, title: "", animation: "fade", }} diff --git a/components/ItemContent.tsx b/components/ItemContent.tsx index 270829f7..e9d8a004 100644 --- a/components/ItemContent.tsx +++ b/components/ItemContent.tsx @@ -14,322 +14,242 @@ import { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarous import { useImageColors } from "@/hooks/useImageColors"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { useSettings } from "@/utils/atoms/settings"; -import { getItemImage } from "@/utils/getItemImage"; import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById"; import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl"; -import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData"; import { chromecastProfile } from "@/utils/profiles/chromecast"; -import ios from "@/utils/profiles/ios"; import iosFmp4 from "@/utils/profiles/iosFmp4"; import native from "@/utils/profiles/native"; import old from "@/utils/profiles/old"; -import { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models"; +import { + BaseItemDto, + MediaSourceInfo, +} from "@jellyfin/sdk/lib/generated-client/models"; import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api"; import { useQuery } from "@tanstack/react-query"; import { Image } from "expo-image"; -import { Stack, useNavigation } from "expo-router"; +import { useNavigation } from "expo-router"; import * as ScreenOrientation from "expo-screen-orientation"; import { useAtom } from "jotai"; import React, { useEffect, useMemo, useRef, useState } from "react"; import { View } from "react-native"; import { useCastDevice } from "react-native-google-cast"; -import Animated, { - runOnJS, - useAnimatedStyle, - useSharedValue, - withTiming, -} from "react-native-reanimated"; +import Animated from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { Chromecast } from "./Chromecast"; import { ItemHeader } from "./ItemHeader"; -import { Loader } from "./Loader"; import { MediaSourceSelector } from "./MediaSourceSelector"; import { MoreMoviesWithActor } from "./MoreMoviesWithActor"; -export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => { - const [api] = useAtom(apiAtom); - const [user] = useAtom(userAtom); +export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo( + ({ item }) => { + const [api] = useAtom(apiAtom); + const [user] = useAtom(userAtom); - const opacity = useSharedValue(0); - const castDevice = useCastDevice(); - const navigation = useNavigation(); - const [settings] = useSettings(); - const [selectedMediaSource, setSelectedMediaSource] = - useState(null); - const [selectedAudioStream, setSelectedAudioStream] = useState(-1); - const [selectedSubtitleStream, setSelectedSubtitleStream] = - useState(-1); - const [maxBitrate, setMaxBitrate] = useState({ - key: "Max", - value: undefined, - }); + const castDevice = useCastDevice(); + const navigation = useNavigation(); + const [settings] = useSettings(); + const [selectedMediaSource, setSelectedMediaSource] = + useState(null); + const [selectedAudioStream, setSelectedAudioStream] = useState(-1); + const [selectedSubtitleStream, setSelectedSubtitleStream] = + useState(-1); + const [maxBitrate, setMaxBitrate] = useState({ + key: "Max", + value: undefined, + }); - const [loadingLogo, setLoadingLogo] = useState(true); + const [loadingLogo, setLoadingLogo] = useState(true); - const [orientation, setOrientation] = useState( - ScreenOrientation.Orientation.PORTRAIT_UP - ); - - useEffect(() => { - const subscription = ScreenOrientation.addOrientationChangeListener( - (event) => { - setOrientation(event.orientationInfo.orientation); - } + const [orientation, setOrientation] = useState( + ScreenOrientation.Orientation.PORTRAIT_UP ); - ScreenOrientation.getOrientationAsync().then((initialOrientation) => { - setOrientation(initialOrientation); - }); - - return () => { - ScreenOrientation.removeOrientationChangeListener(subscription); - }; - }, []); - - const animatedStyle = useAnimatedStyle(() => { - return { - opacity: opacity.value, - }; - }); - - const fadeIn = () => { - opacity.value = withTiming(1, { duration: 300 }); - }; - - const fadeOut = (callback: any) => { - opacity.value = withTiming(0, { duration: 300 }, (finished) => { - if (finished) { - runOnJS(callback)(); - } - }); - }; - - const headerHeightRef = useRef(400); - - const { - data: item, - isLoading, - isFetching, - } = useQuery({ - queryKey: ["item", id], - queryFn: async () => { - const res = await getUserItemData({ - api, - userId: user?.Id, - itemId: id, - }); - - return res; - }, - enabled: !!id && !!api, - staleTime: 60 * 1000 * 5, - }); - - const [localItem, setLocalItem] = useState(item); - useImageColors({ item }); - - useEffect(() => { - if (item) { - if (localItem) { - // Fade out current item - fadeOut(() => { - // Update local item after fade out - setLocalItem(item); - // Then fade in - fadeIn(); - }); - } else { - // If there's no current item, just set and fade in - setLocalItem(item); - fadeIn(); - } - } else { - // If item is null, fade out and clear local item - fadeOut(() => setLocalItem(null)); - } - }, [item]); - - useEffect(() => { - navigation.setOptions({ - headerRight: () => - item && ( - - - {item.Type !== "Program" && ( - <> - - - - )} - - ), - }); - }, [item]); - - useEffect(() => { - if (orientation !== ScreenOrientation.Orientation.PORTRAIT_UP) { - headerHeightRef.current = 230; - return; - } - if (item?.Type === "Episode") headerHeightRef.current = 400; - else if (item?.Type === "Movie") headerHeightRef.current = 500; - else headerHeightRef.current = 400; - }, [item, orientation]); - - const { data: sessionData } = useQuery({ - queryKey: ["sessionData", item?.Id], - queryFn: async () => { - if (!api || !user?.Id || !item?.Id) return null; - const playbackData = await getMediaInfoApi(api!).getPlaybackInfo( - { - itemId: item?.Id, - userId: user?.Id, - }, - { - method: "POST", + useEffect(() => { + const subscription = ScreenOrientation.addOrientationChangeListener( + (event) => { + setOrientation(event.orientationInfo.orientation); } ); - return playbackData.data; - }, - enabled: !!item?.Id && !!api && !!user?.Id, - staleTime: 0, - }); - - const { data: playbackUrl } = useQuery({ - queryKey: [ - "playbackUrl", - item?.Id, - maxBitrate, - castDevice, - selectedMediaSource, - selectedAudioStream, - selectedSubtitleStream, - settings, - ], - queryFn: async () => { - if (!api || !user?.Id) { - console.warn("No api, userid or selected media source", { - api: api, - user: user, - }); - return null; - } - - if ( - item?.Type !== "Program" && - (!sessionData || !selectedMediaSource?.Id) - ) { - console.warn("No session data or media source", { - sessionData: sessionData, - selectedMediaSource: selectedMediaSource, - }); - return null; - } - - let deviceProfile: any = iosFmp4; - - if (castDevice?.deviceId) { - deviceProfile = chromecastProfile; - } else if (settings?.deviceProfile === "Native") { - deviceProfile = native; - } else if (settings?.deviceProfile === "Old") { - deviceProfile = old; - } - - console.log("playbackUrl..."); - - const url = await getStreamUrl({ - api, - userId: user.Id, - item, - startTimeTicks: item?.UserData?.PlaybackPositionTicks || 0, - maxStreamingBitrate: maxBitrate.value, - sessionData, - deviceProfile, - audioStreamIndex: selectedAudioStream, - subtitleStreamIndex: selectedSubtitleStream, - forceDirectPlay: settings?.forceDirectPlay, - height: maxBitrate.height, - mediaSourceId: selectedMediaSource?.Id, + ScreenOrientation.getOrientationAsync().then((initialOrientation) => { + setOrientation(initialOrientation); }); - console.info("Stream URL:", url); + return () => { + ScreenOrientation.removeOrientationChangeListener(subscription); + }; + }, []); - return url; - }, - enabled: !!api && !!user?.Id && !!item?.Id, - staleTime: 0, - }); + const headerHeightRef = useRef(400); - const logoUrl = useMemo(() => getLogoImageUrlById({ api, item }), [item]); + useImageColors({ item }); - const loading = useMemo(() => { - return Boolean(isLoading || isFetching || (logoUrl && loadingLogo)); - }, [isLoading, isFetching, loadingLogo, logoUrl]); + useEffect(() => { + navigation.setOptions({ + headerRight: () => + item && ( + + + {item.Type !== "Program" && ( + <> + + + + )} + + ), + }); + }, [item]); - const insets = useSafeAreaInsets(); + useEffect(() => { + if (orientation !== ScreenOrientation.Orientation.PORTRAIT_UP) { + headerHeightRef.current = 230; + return; + } + if (item.Type === "Episode") headerHeightRef.current = 400; + else if (item.Type === "Movie") headerHeightRef.current = 500; + else headerHeightRef.current = 400; + }, [item, orientation]); - return ( - - {loading && ( - - - - )} - - - {localItem && ( + const { data: sessionData } = useQuery({ + queryKey: ["sessionData", item.Id], + queryFn: async () => { + if (!api || !user?.Id || !item.Id) { + return null; + } + const playbackData = await getMediaInfoApi(api!).getPlaybackInfo( + { + itemId: item.Id, + userId: user?.Id, + }, + { + method: "POST", + } + ); + + return playbackData.data; + }, + enabled: !!item.Id && !!api && !!user?.Id, + staleTime: 0, + }); + + const { data: playbackUrl } = useQuery({ + queryKey: [ + "playbackUrl", + item.Id, + maxBitrate, + castDevice?.deviceId, + selectedMediaSource?.Id, + selectedAudioStream, + selectedSubtitleStream, + settings, + sessionData?.PlaySessionId, + ], + queryFn: async () => { + if (!api || !user?.Id) { + return null; + } + + if ( + item.Type !== "Program" && + (!sessionData || !selectedMediaSource?.Id) + ) { + return null; + } + + let deviceProfile: any = iosFmp4; + + if (castDevice?.deviceId) { + deviceProfile = chromecastProfile; + } else if (settings?.deviceProfile === "Native") { + deviceProfile = native; + } else if (settings?.deviceProfile === "Old") { + deviceProfile = old; + } + + console.log("playbackUrl..."); + + const url = await getStreamUrl({ + api, + userId: user.Id, + item, + startTimeTicks: item.UserData?.PlaybackPositionTicks || 0, + maxStreamingBitrate: maxBitrate.value, + sessionData, + deviceProfile, + audioStreamIndex: selectedAudioStream, + subtitleStreamIndex: selectedSubtitleStream, + forceDirectPlay: settings?.forceDirectPlay, + height: maxBitrate.height, + mediaSourceId: selectedMediaSource?.Id, + }); + + console.info("Stream URL:", url); + + return url; + }, + enabled: !!api && !!user?.Id && !!item.Id, + staleTime: 0, + }); + + const logoUrl = useMemo(() => getLogoImageUrlById({ api, item }), [item]); + + const loading = useMemo(() => { + return Boolean(logoUrl && loadingLogo); + }, [loadingLogo, logoUrl]); + + const insets = useSafeAreaInsets(); + + return ( + + + - )} - - - } - logo={ - <> - {logoUrl ? ( - setLoadingLogo(false)} - onError={() => setLoadingLogo(false)} - /> - ) : null} - - } - > - - - - - {localItem ? ( + + + } + logo={ + <> + {logoUrl ? ( + setLoadingLogo(false)} + onError={() => setLoadingLogo(false)} + /> + ) : null} + + } + > + + + + = React.memo(({ id }) => { /> @@ -358,46 +278,42 @@ export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => { )} - ) : ( - - - - - )} - + - - - - {item?.Type === "Episode" && ( - - )} - - - - - - {item?.People && item.People.length > 0 && ( - - {item.People.slice(0, 3).map((person) => ( - - ))} + - )} - {item?.Type === "Episode" && ( - - )} - + {item.Type === "Episode" && ( + + )} - - - - - ); -}); + + + + + {item.People && item.People.length > 0 && ( + + {item.People.slice(0, 3).map((person) => ( + + ))} + + )} + + {item.Type === "Episode" && ( + + )} + + + + + + + + ); + } +); diff --git a/components/livetv/HourHeader.tsx b/components/livetv/HourHeader.tsx new file mode 100644 index 00000000..00a51d9e --- /dev/null +++ b/components/livetv/HourHeader.tsx @@ -0,0 +1,43 @@ +import React from "react"; +import { View } from "react-native"; +import { Text } from "../common/Text"; + +export const HourHeader = ({ height }: { height: number }) => { + const now = new Date(); + const currentHour = now.getHours(); + const hoursRemaining = 24 - currentHour; + const hours = generateHours(currentHour, hoursRemaining); + + return ( + + {hours.map((hour, index) => ( + + ))} + + ); +}; + +const HourCell = ({ hour }: { hour: Date }) => ( + + + {hour.toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + })} + + +); + +const generateHours = (startHour: number, count: number): Date[] => { + const now = new Date(); + return Array.from({ length: count }, (_, i) => { + const hour = new Date(now); + hour.setHours(startHour + i, 0, 0, 0); + return hour; + }); +}; diff --git a/components/livetv/LiveTVGuideRow.tsx b/components/livetv/LiveTVGuideRow.tsx new file mode 100644 index 00000000..59842e1f --- /dev/null +++ b/components/livetv/LiveTVGuideRow.tsx @@ -0,0 +1,91 @@ +import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; +import { View, ScrollView, Dimensions } from "react-native"; +import { ItemImage } from "../common/ItemImage"; +import { Text } from "../common/Text"; +import { useCallback, useMemo, useRef, useState } from "react"; +import { TouchableItemRouter } from "../common/TouchableItemRouter"; + +export const LiveTVGuideRow = ({ + channel, + programs, + scrollX = 0, +}: { + channel: BaseItemDto; + programs?: BaseItemDto[] | null; + scrollX?: number; +}) => { + const positionRefs = useRef<{ [key: string]: number }>({}); + const screenWidth = Dimensions.get("window").width; + + const calculateWidth = (s?: string | null, e?: string | null) => { + if (!s || !e) return 0; + const start = new Date(s); + const end = new Date(e); + const duration = end.getTime() - start.getTime(); + const minutes = duration / 60000; + const width = (minutes / 60) * 200; + return width; + }; + + const programsWithPositions = useMemo(() => { + let cumulativeWidth = 0; + return programs + ?.filter((p) => p.ChannelId === channel.Id) + .map((p) => { + const width = calculateWidth(p.StartDate, p.EndDate); + const position = cumulativeWidth; + cumulativeWidth += width; + return { ...p, width, position }; + }); + }, [programs, channel.Id]); + + const isCurrentlyLive = (program: BaseItemDto) => { + if (!program.StartDate || !program.EndDate) return false; + const now = new Date(); + const start = new Date(program.StartDate); + const end = new Date(program.EndDate); + return now >= start && now <= end; + }; + + return ( + + {programsWithPositions?.map((p) => ( + + + {(() => { + return ( + screenWidth && scrollX > p.position + ? scrollX - p.position + : 0, + }} + className="px-4 self-start" + > + + {p.Name} + + + ); + })()} + + + ))} + + ); +}; diff --git a/components/series/SeasonEpisodesCarousel.tsx b/components/series/SeasonEpisodesCarousel.tsx index 43bd6780..e03d590d 100644 --- a/components/series/SeasonEpisodesCarousel.tsx +++ b/components/series/SeasonEpisodesCarousel.tsx @@ -85,7 +85,7 @@ export const SeasonEpisodesCarousel: React.FC = ({ userId: user?.Id, itemId: previousId, }), - staleTime: 60 * 1000, + staleTime: 60 * 1000 * 5, }); } @@ -101,7 +101,7 @@ export const SeasonEpisodesCarousel: React.FC = ({ userId: user?.Id, itemId: nextId, }), - staleTime: 60 * 1000, + staleTime: 60 * 1000 * 5, }); } }, [episodes, api, user?.Id, item]);