diff --git a/README.md b/README.md index 8bddf816..360f4949 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ Welcome to Streamyfin, a simple and user-friendly Jellyfin client built with Exp ## 🌟 Features + - 🚀 **Skp intro / credits support** - 🖼️ **Trickplay images**: The new golden standard for chapter previews when seeking. - 📺 **Picture in Picture** (iPhone only): Watch movies in PiP mode on your iPhone. @@ -61,7 +62,7 @@ Check out our [Roadmap](https://github.com/users/fredrikburmester/projects/5) to ## Get it now
- Get Streamyfin on App Store + Get Streamyfin on App Store Get the beta on Google Play
@@ -135,7 +136,7 @@ Key points of the MPL-2.0: ## 🌐 Connect with Us -Join our Discord: [https://discord.gg/zyGKHJZvv4](https://discord.gg/aJvAYeycyY) +Join our Discord: [https://discord.gg/BuGG9ZNhaE](https://discord.gg/BuGG9ZNhaE) If you have questions or need support, feel free to reach out: @@ -153,3 +154,7 @@ I'd like to thank the following people and projects for their contributions to S - [Reiverr](https://github.com/aleksilassila/reiverr) for great help with understanding the Jellyfin API. - [Jellyfin TS SDK](https://github.com/jellyfin/jellyfin-sdk-typescript) for the TypeScript SDK. - The Jellyfin devs for always being helpful in the Discord. + +## Star History + +[![Star History Chart](https://api.star-history.com/svg?repos=fredrikburmester/streamyfin&type=Date)](https://star-history.com/#fredrikburmester/streamyfin&Date) diff --git a/app.json b/app.json index c7d3db86..de37a56c 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", @@ -10,7 +10,7 @@ "splash": { "image": "./assets/images/splash.png", "resizeMode": "contain", - "backgroundColor": "#29164B" + "backgroundColor": "#2E2E2E" }, "jsEngine": "hermes", "assetBundlePatterns": ["**/*"], @@ -33,9 +33,9 @@ }, "android": { "jsEngine": "hermes", - "versionCode": 42, + "versionCode": 43, "adaptiveIcon": { - "foregroundImage": "./assets/images/icon.png" + "foregroundImage": "./assets/images/adaptive_icon.png" }, "package": "com.fredrikburmester.streamyfin", "permissions": [ diff --git a/app/(auth)/(tabs)/(home)/_layout.tsx b/app/(auth)/(tabs)/(home)/_layout.tsx index 763b89c9..f51ecbf5 100644 --- a/app/(auth)/(tabs)/(home)/_layout.tsx +++ b/app/(auth)/(tabs)/(home)/_layout.tsx @@ -1,12 +1,14 @@ 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 ( (null); const [loadingRetry, setLoadingRetry] = useState(false); + const { downloadedFiles } = useDownload(); + const navigation = useNavigation(); + + useEffect(() => { + const hasDownloads = downloadedFiles && downloadedFiles.length > 0; + navigation.setOptions({ + headerLeft: () => ( + { + router.push("/(auth)/downloads"); + }} + className="p-2" + > + + + ), + }); + }, [downloadedFiles, navigation, router]); + const checkConnection = useCallback(async () => { setLoadingRetry(true); const state = await NetInfo.fetch(); @@ -141,7 +169,7 @@ export default function index() { setLoading(true); await queryClient.invalidateQueries(); setLoading(false); - }, [queryClient, user?.Id]); + }, []); const createCollectionConfig = useCallback( ( @@ -340,33 +368,38 @@ export default function index() { contentContainerStyle={{ paddingLeft: insets.left, paddingRight: insets.right, + paddingBottom: 16, + }} + style={{ + marginBottom: TAB_HEIGHT, }} - className="flex flex-col space-y-4 mb-20" > - + + - {sections.map((section, index) => { - if (section.type === "ScrollingCollectionList") { - return ( - - ); - } else if (section.type === "MediaListSection") { - return ( - - ); - } - return null; - })} + {sections.map((section, index) => { + if (section.type === "ScrollingCollectionList") { + return ( + + ); + } else if (section.type === "MediaListSection") { + return ( + + ); + } + return null; + })} + ); } 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 ( + + +