diff --git a/app.json b/app.json index 3eb47c3b..af017f18 100644 --- a/app.json +++ b/app.json @@ -2,7 +2,7 @@ "expo": { "name": "Streamyfin", "slug": "streamyfin", - "version": "0.16.0", + "version": "0.17.0", "orientation": "default", "icon": "./assets/images/icon.png", "scheme": "streamyfin", 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..071d9127 100644 --- a/app/(auth)/(tabs)/(home,libraries,search)/items/page.tsx +++ b/app/(auth)/(tabs)/(home,libraries,search)/items/page.tsx @@ -1,15 +1,94 @@ +import { Text } from "@/components/common/Text"; import { ItemContent } from "@/components/ItemContent"; -import { Stack, useLocalSearchParams } from "expo-router"; -import React from "react"; +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"; 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/_layout.tsx b/app/(auth)/(tabs)/(home,libraries,search)/livetv/_layout.tsx new file mode 100644 index 00000000..7225e677 --- /dev/null +++ b/app/(auth)/(tabs)/(home,libraries,search)/livetv/_layout.tsx @@ -0,0 +1,49 @@ +import type { + MaterialTopTabNavigationEventMap, + MaterialTopTabNavigationOptions, +} from "@react-navigation/material-top-tabs"; +import { createMaterialTopTabNavigator } from "@react-navigation/material-top-tabs"; +import { ParamListBase, TabNavigationState } from "@react-navigation/native"; +import { Stack, withLayoutContext } from "expo-router"; +import React from "react"; + +const { Navigator } = createMaterialTopTabNavigator(); + +export const Tab = withLayoutContext< + MaterialTopTabNavigationOptions, + typeof Navigator, + TabNavigationState, + MaterialTopTabNavigationEventMap +>(Navigator); + +const Layout = () => { + return ( + <> + + + + + + + + + ); +}; + +export default Layout; diff --git a/app/(auth)/(tabs)/(home,libraries,search)/livetv/channels.tsx b/app/(auth)/(tabs)/(home,libraries,search)/livetv/channels.tsx new file mode 100644 index 00000000..dd1c1f85 --- /dev/null +++ b/app/(auth)/(tabs)/(home,libraries,search)/livetv/channels.tsx @@ -0,0 +1,56 @@ +import { ItemImage } from "@/components/common/ItemImage"; +import { Text } from "@/components/common/Text"; +import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; +import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api"; +import { FlashList } from "@shopify/flash-list"; +import { useQuery } from "@tanstack/react-query"; +import { useAtom } from "jotai"; +import React from "react"; +import { View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +export default function page() { + const [api] = useAtom(apiAtom); + const [user] = useAtom(userAtom); + const insets = useSafeAreaInsets(); + + 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; + }, + }); + + return ( + + ( + + + + + {item.Name} + + )} + /> + + ); +} diff --git a/app/(auth)/(tabs)/(home,libraries,search)/livetv/guide.tsx b/app/(auth)/(tabs)/(home,libraries,search)/livetv/guide.tsx new file mode 100644 index 00000000..101b3fe8 --- /dev/null +++ b/app/(auth)/(tabs)/(home,libraries,search)/livetv/guide.tsx @@ -0,0 +1,168 @@ +import { ItemImage } from "@/components/common/ItemImage"; +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 { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api"; +import { useQuery } from "@tanstack/react-query"; +import { useAtom } from "jotai"; +import React, { useCallback, useMemo, useState } from "react"; +import { Button, Dimensions, ScrollView, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +const HOUR_HEIGHT = 30; +const ITEMS_PER_PAGE = 20; + +const MemoizedLiveTVGuideRow = React.memo(LiveTVGuideRow); + +export default function page() { + const [api] = useAtom(apiAtom); + const [user] = useAtom(userAtom); + const insets = useSafeAreaInsets(); + const [date, setDate] = useState(new Date()); + const [currentPage, setCurrentPage] = useState(1); + + 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", currentPage], + queryFn: async () => { + const res = await getLiveTvApi(api!).getLiveTvChannels({ + startIndex: (currentPage - 1) * ITEMS_PER_PAGE, + limit: ITEMS_PER_PAGE, + enableFavoriteSorting: true, + userId: user?.Id, + addCurrentProgram: false, + enableUserData: false, + enableImageTypes: ["Primary"], + }); + return res.data; + }, + }); + + const { data: programs } = useQuery({ + queryKey: ["livetv", "programs", date, currentPage], + 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 memoizedChannels = useMemo(() => channels?.Items || [], [channels]); + + const [scrollX, setScrollX] = useState(0); + + const handleNextPage = useCallback(() => { + setCurrentPage((prev) => prev + 1); + }, []); + + const handlePrevPage = useCallback(() => { + setCurrentPage((prev) => Math.max(1, prev - 1)); + }, []); + + return ( + + +