diff --git a/README.md b/README.md index 297c962a..c7fc551e 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # 📺 Streamyfin +Buy Me A Coffee + Welcome to Streamyfin, a simple and user-friendly Jellyfin client built with Expo. If you're looking for an alternative to other Jellyfin clients, we hope you'll find Streamyfin to be a useful addition to your media streaming toolbox.
@@ -141,10 +143,6 @@ If you have questions or need support, feel free to reach out: - GitHub Issues: Report bugs or request features here. - Email: [fredrik.burmester@gmail.com](mailto:fredrik.burmester@gmail.com) -## Support - -Buy Me A Coffee - ## 📝 Credits Streamyfin is developed by Fredrik Burmester and is not affiliated with Jellyfin. The app is built with Expo, React Native, and other open-source libraries. diff --git a/app.json b/app.json index 5d8d431c..d4e5e08f 100644 --- a/app.json +++ b/app.json @@ -2,7 +2,7 @@ "expo": { "name": "Streamyfin", "slug": "streamyfin", - "version": "0.8.2", + "version": "0.10.3", "orientation": "default", "icon": "./assets/images/icon.png", "scheme": "streamyfin", @@ -33,7 +33,7 @@ }, "android": { "jsEngine": "hermes", - "versionCode": 23, + "versionCode": 32, "adaptiveIcon": { "foregroundImage": "./assets/images/icon.png" }, @@ -85,6 +85,11 @@ "deploymentTarget": "14.0" }, "android": { + "android": { + "compileSdkVersion": 34, + "targetSdkVersion": 34, + "buildToolsVersion": "34.0.0" + }, "minSdkVersion": 24, "usesCleartextTraffic": true, "packagingOptions": { diff --git a/app/(auth)/(tabs)/(home)/downloads.tsx b/app/(auth)/(tabs)/(home)/downloads.tsx index c187c68b..706e3581 100644 --- a/app/(auth)/(tabs)/(home)/downloads.tsx +++ b/app/(auth)/(tabs)/(home)/downloads.tsx @@ -8,11 +8,12 @@ import { Ionicons } from "@expo/vector-icons"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import AsyncStorage from "@react-native-async-storage/async-storage"; import { useQuery } from "@tanstack/react-query"; -import { router, Stack } from "expo-router"; +import { router } from "expo-router"; import { FFmpegKit } from "ffmpeg-kit-react-native"; import { useAtom } from "jotai"; import { useMemo } from "react"; import { ScrollView, TouchableOpacity, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; const downloads: React.FC = () => { const [process, setProcess] = useAtom(runningProcesses); @@ -53,6 +54,8 @@ const downloads: React.FC = () => { return formatNumber(timeLeft / 10000); }, [process]); + const insets = useSafeAreaInsets(); + if (isLoading) { return ( @@ -62,7 +65,13 @@ const downloads: React.FC = () => { } return ( - + @@ -70,7 +79,9 @@ const downloads: React.FC = () => { {queue.map((q) => ( router.push(`/(auth)/items/${q.item.Id}`)} + onPress={() => + router.push(`/(auth)/items/page?id=${q.item.Id}`) + } className="relative bg-neutral-900 border border-neutral-800 p-4 rounded-2xl overflow-hidden flex flex-row items-center justify-between" > @@ -97,7 +108,9 @@ const downloads: React.FC = () => { Active download {process?.item ? ( router.push(`/(auth)/items/${process.item.Id}`)} + onPress={() => + router.push(`/(auth)/items/page?id=${process.item.Id}`) + } className="relative bg-neutral-900 border border-neutral-800 p-4 rounded-2xl overflow-hidden flex flex-row items-center justify-between" > diff --git a/app/(auth)/(tabs)/(home)/index.tsx b/app/(auth)/(tabs)/(home)/index.tsx index b894a9d8..72688d26 100644 --- a/app/(auth)/(tabs)/(home)/index.tsx +++ b/app/(auth)/(tabs)/(home)/index.tsx @@ -1,4 +1,3 @@ -import { Button } from "@/components/Button"; import { Text } from "@/components/common/Text"; import { LargeMovieCarousel } from "@/components/home/LargeMovieCarousel"; import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList"; @@ -6,7 +5,6 @@ import { Loader } from "@/components/Loader"; import { MediaListSection } from "@/components/medialists/MediaListSection"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { useSettings } from "@/utils/atoms/settings"; -import { Ionicons } from "@expo/vector-icons"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { getItemsApi, @@ -20,10 +18,28 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useRouter } from "expo-router"; import { useAtom } from "jotai"; import { useCallback, useEffect, useMemo, useState } from "react"; -import { RefreshControl, ScrollView, View } from "react-native"; +import { RefreshControl, SafeAreaView, ScrollView, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +type BaseSection = { + title: string; + queryKey: (string | undefined)[]; +}; + +type ScrollingCollectionListSection = BaseSection & { + type: "ScrollingCollectionList"; + queryFn: () => Promise; + orientation?: "horizontal" | "vertical"; +}; + +type MediaListSection = BaseSection & { + type: "MediaListSection"; + queryFn: () => Promise; +}; + +type Section = ScrollingCollectionListSection | MediaListSection; export default function index() { - const router = useRouter(); const queryClient = useQueryClient(); const [api] = useAtom(apiAtom); @@ -50,42 +66,12 @@ export default function index() { }; }, []); - const { data, isLoading, isError } = useQuery({ - queryKey: ["resumeItems", user?.Id], - queryFn: async () => - (api && - ( - await getItemsApi(api).getResumeItems({ - userId: user?.Id, - }) - ).data.Items) || - [], - enabled: !!api && !!user?.Id, - staleTime: 60 * 1000, - }); - - const { data: _nextUpData, isLoading: isLoadingNextUp } = useQuery({ - queryKey: ["nextUp-all", user?.Id], - queryFn: async () => - (api && - ( - await getTvShowsApi(api).getNextUp({ - userId: user?.Id, - fields: ["MediaSourceCount"], - limit: 20, - }) - ).data.Items) || - [], - enabled: !!api && !!user?.Id, - staleTime: 0, - }); - - const nextUpData = useMemo(() => { - return _nextUpData?.filter((i) => !data?.find((d) => d.Id === i.Id)); - }, [_nextUpData]); - - const { data: collections } = useQuery({ - queryKey: ["collectinos", user?.Id], + const { + data: userViews, + isError: e1, + isLoading: l1, + } = useQuery({ + queryKey: ["userViews", user?.Id], queryFn: async () => { if (!api || !user?.Id) { return null; @@ -101,77 +87,11 @@ export default function index() { staleTime: 60 * 1000, }); - const movieCollectionId = useMemo(() => { - return collections?.find((c) => c.CollectionType === "movies")?.Id; - }, [collections]); - - const tvShowCollectionId = useMemo(() => { - return collections?.find((c) => c.CollectionType === "tvshows")?.Id; - }, [collections]); - const { - data: recentlyAddedInMovies, - isLoading: isLoadingRecentlyAddedMovies, - } = useQuery({ - queryKey: ["recentlyAddedInMovies", user?.Id, movieCollectionId], - queryFn: async () => - (api && - ( - await getUserLibraryApi(api).getLatestMedia({ - userId: user?.Id, - limit: 50, - fields: ["PrimaryImageAspectRatio", "Path"], - imageTypeLimit: 1, - enableImageTypes: ["Primary", "Backdrop", "Thumb"], - parentId: movieCollectionId, - }) - ).data) || - [], - enabled: !!api && !!user?.Id && !!movieCollectionId, - staleTime: 60 * 1000, - }); - - const { - data: recentlyAddedInTVShows, - isLoading: isLoadingRecentlyAddedTVShows, - } = useQuery({ - queryKey: ["recentlyAddedInTVShows", user?.Id, tvShowCollectionId], - queryFn: async () => - (api && - ( - await getUserLibraryApi(api).getLatestMedia({ - userId: user?.Id, - limit: 50, - fields: ["PrimaryImageAspectRatio", "Path"], - imageTypeLimit: 1, - enableImageTypes: ["Primary", "Backdrop", "Thumb"], - parentId: tvShowCollectionId, - }) - ).data) || - [], - enabled: !!api && !!user?.Id && !!tvShowCollectionId, - staleTime: 60 * 1000, - }); - - const { data: suggestions, isLoading: isLoadingSuggestions } = useQuery< - BaseItemDto[] - >({ - queryKey: ["suggestions", user?.Id], - queryFn: async () => - (api && - ( - await getSuggestionsApi(api).getSuggestions({ - userId: user?.Id, - limit: 5, - mediaType: ["Video"], - }) - ).data.Items) || - [], - enabled: !!api && !!user?.Id, - staleTime: 60 * 1000, - }); - - const { data: mediaListCollections } = useQuery({ + data: mediaListCollections, + isError: e2, + isLoading: l2, + } = useQuery({ queryKey: ["sf_promoted", user?.Id, settings?.usePopularPlugin], queryFn: async () => { if (!api || !user?.Id) return []; @@ -187,11 +107,20 @@ export default function index() { return response.data.Items || []; }, enabled: !!api && !!user?.Id && settings?.usePopularPlugin === true, - staleTime: 0, + staleTime: 60 * 1000, }); + const movieCollectionId = useMemo(() => { + return userViews?.find((c) => c.CollectionType === "movies")?.Id; + }, [userViews]); + + const tvShowCollectionId = useMemo(() => { + return userViews?.find((c) => c.CollectionType === "tvshows")?.Id; + }, [userViews]); + const refetch = useCallback(async () => { setLoading(true); + await queryClient.refetchQueries({ queryKey: ["userViews"] }); await queryClient.refetchQueries({ queryKey: ["resumeItems"] }); await queryClient.refetchQueries({ queryKey: ["nextUp-all"] }); await queryClient.refetchQueries({ queryKey: ["recentlyAddedInMovies"] }); @@ -206,30 +135,145 @@ export default function index() { setLoading(false); }, [queryClient, user?.Id]); - if (isConnected === false) { - return ( - - No Internet - - No worries, you can still watch{"\n"}downloaded content. - - - - - - ); - } + const sections = useMemo(() => { + if (!api || !user?.Id) return []; - if (isError) + const ss: Section[] = [ + { + title: "Continue Watching", + queryKey: ["resumeItems", user.Id], + queryFn: async () => + ( + await getItemsApi(api).getResumeItems({ + userId: user.Id, + enableImageTypes: ["Primary", "Backdrop", "Thumb"], + }) + ).data.Items || [], + type: "ScrollingCollectionList", + orientation: "horizontal", + }, + { + title: "Next Up", + queryKey: ["nextUp-all", user?.Id], + queryFn: async () => + ( + await getTvShowsApi(api).getNextUp({ + userId: user?.Id, + fields: ["MediaSourceCount"], + limit: 20, + enableImageTypes: ["Primary", "Backdrop", "Thumb"], + }) + ).data.Items || [], + type: "ScrollingCollectionList", + orientation: "horizontal", + }, + ...(mediaListCollections?.map( + (ml) => + ({ + title: ml.Name || "", + queryKey: ["mediaList", ml.Id], + queryFn: async () => ml, + type: "MediaListSection", + } as MediaListSection) + ) || []), + { + title: "Recently Added in Movies", + queryKey: ["recentlyAddedInMovies", user?.Id, movieCollectionId], + queryFn: async () => + ( + await getUserLibraryApi(api).getLatestMedia({ + userId: user?.Id, + limit: 50, + fields: ["PrimaryImageAspectRatio", "Path"], + imageTypeLimit: 1, + enableImageTypes: ["Primary", "Backdrop", "Thumb"], + parentId: movieCollectionId, + }) + ).data || [], + type: "ScrollingCollectionList", + }, + { + title: "Recently Added in TV-Shows", + queryKey: ["recentlyAddedInTVShows", user?.Id, tvShowCollectionId], + queryFn: async () => + ( + await getUserLibraryApi(api).getLatestMedia({ + userId: user?.Id, + limit: 50, + fields: ["PrimaryImageAspectRatio", "Path"], + imageTypeLimit: 1, + enableImageTypes: ["Primary", "Backdrop", "Thumb"], + parentId: tvShowCollectionId, + }) + ).data || [], + type: "ScrollingCollectionList", + }, + { + title: "Suggested Movies", + queryKey: ["suggestedMovies", user?.Id], + queryFn: async () => + ( + await getSuggestionsApi(api).getSuggestions({ + userId: user?.Id, + limit: 10, + mediaType: ["Video"], + type: ["Movie"], + }) + ).data.Items || [], + type: "ScrollingCollectionList", + orientation: "vertical", + }, + { + title: "Suggested Episodes", + queryKey: ["suggestedEpisodes", user?.Id], + queryFn: async () => + ( + await getSuggestionsApi(api).getSuggestions({ + userId: user?.Id, + limit: 10, + mediaType: ["Video"], + type: ["Episode"], + }) + ).data.Items || [], + type: "ScrollingCollectionList", + orientation: "horizontal", + }, + ]; + return ss; + }, [ + api, + user?.Id, + movieCollectionId, + tvShowCollectionId, + mediaListCollections, + ]); + + // if (isConnected === false) { + // return ( + // + // No Internet + // + // No worries, you can still watch{"\n"}downloaded content. + // + // + // + // + // + // ); + // } + + const insets = useSafeAreaInsets(); + + if (e1 || e2) return ( Oops! @@ -239,7 +283,7 @@ export default function index() { ); - if (isLoading) + if (l1 || l2) return ( @@ -254,45 +298,37 @@ export default function index() { } > - + - - - - - {mediaListCollections?.map((ml) => ( - - ))} - - - - - - + {sections.map((section, index) => { + if (section.type === "ScrollingCollectionList") { + return ( + + ); + } else if (section.type === "MediaListSection") { + return ( + + ); + } + return null; + })} ); diff --git a/app/(auth)/(tabs)/(home)/settings.tsx b/app/(auth)/(tabs)/(home)/settings.tsx index ce8d5df5..c688f9b2 100644 --- a/app/(auth)/(tabs)/(home)/settings.tsx +++ b/app/(auth)/(tabs)/(home)/settings.tsx @@ -9,6 +9,7 @@ import { useQuery } from "@tanstack/react-query"; import * as Haptics from "expo-haptics"; import { useAtom } from "jotai"; import { ScrollView, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; export default function settings() { const { logout } = useJellyfin(); @@ -23,9 +24,17 @@ export default function settings() { refetchInterval: 1000, }); + const insets = useSafeAreaInsets(); + return ( - - + + Information diff --git a/app/(auth)/(tabs)/(home,libraries,search)/albums/[albumId].tsx b/app/(auth)/(tabs)/(home,libraries,search)/albums/[albumId].tsx index 3e17610d..565f84c8 100644 --- a/app/(auth)/(tabs)/(home,libraries,search)/albums/[albumId].tsx +++ b/app/(auth)/(tabs)/(home,libraries,search)/albums/[albumId].tsx @@ -1,7 +1,9 @@ import { Chromecast } from "@/components/Chromecast"; +import { ItemImage } from "@/components/common/ItemImage"; import { Text } from "@/components/common/Text"; import { TouchableItemRouter } from "@/components/common/TouchableItemRouter"; import { SongsList } from "@/components/music/SongsList"; +import { ParallaxScrollView } from "@/components/ParallaxPage"; import ArtistPoster from "@/components/posters/ArtistPoster"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; @@ -11,6 +13,7 @@ import { router, useLocalSearchParams, useNavigation } from "expo-router"; import { useAtom } from "jotai"; import { useEffect, useState } from "react"; import { ScrollView, TouchableOpacity, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; export default function page() { const searchParams = useLocalSearchParams(); @@ -88,30 +91,31 @@ export default function page() { enabled: !!api && !!user?.Id, }); + const insets = useSafeAreaInsets(); + if (!album) return null; return ( - - - - - - - - {album?.Name} - {album?.ProductionYear} - - - {album.AlbumArtists?.map((a) => ( - - - {album?.AlbumArtist} - - - ))} - - - + + } + > + + {album?.Name} + + {songs?.TotalRecordCount} songs + + + - + ); } diff --git a/app/(auth)/(tabs)/(home,libraries,search)/artists/[artistId].tsx b/app/(auth)/(tabs)/(home,libraries,search)/artists/[artistId].tsx index 4a60fb06..8d82d205 100644 --- a/app/(auth)/(tabs)/(home,libraries,search)/artists/[artistId].tsx +++ b/app/(auth)/(tabs)/(home,libraries,search)/artists/[artistId].tsx @@ -8,6 +8,10 @@ import { router, useLocalSearchParams, useNavigation } from "expo-router"; import { useAtom } from "jotai"; import { useEffect, useState } from "react"; import { FlatList, ScrollView, TouchableOpacity, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { ItemImage } from "@/components/common/ItemImage"; +import { ParallaxScrollView } from "@/components/ParallaxPage"; +import { TouchableItemRouter } from "@/components/common/TouchableItemRouter"; export default function page() { const searchParams = useLocalSearchParams(); @@ -82,50 +86,45 @@ export default function page() { enabled: !!api && !!user?.Id, }); - useEffect(() => { - navigation.setOptions({ - title: albums?.Items?.[0]?.AlbumArtist || "", - }); - }, [albums]); + const insets = useSafeAreaInsets(); if (!artist || !albums) return null; return ( - - - - - Albums - - } - nestedScrollEnabled - data={albums.Items} - numColumns={3} - columnWrapperStyle={{ - justifyContent: "space-between", - }} - renderItem={({ item, index }) => ( - { - router.push(`/albums/${item.Id}`); + - - - {item.Name} - {item.ProductionYear} - - - )} - keyExtractor={(item) => item.Id || ""} - /> + /> + } + > + + {artist?.Name} + + {albums.TotalRecordCount} albums + + + + {albums.Items.map((item, idx) => ( + + + + {item.Name} + {item.ProductionYear} + + + ))} + + ); } diff --git a/app/(auth)/(tabs)/(home,libraries,search)/items/[id].tsx b/app/(auth)/(tabs)/(home,libraries,search)/items/[id].tsx deleted file mode 100644 index 84958a90..00000000 --- a/app/(auth)/(tabs)/(home,libraries,search)/items/[id].tsx +++ /dev/null @@ -1,246 +0,0 @@ -import { AudioTrackSelector } from "@/components/AudioTrackSelector"; -import { Bitrate, BitrateSelector } from "@/components/BitrateSelector"; -import { DownloadItem } from "@/components/DownloadItem"; -import { Loader } from "@/components/Loader"; -import { OverviewText } from "@/components/OverviewText"; -import { ParallaxScrollView } from "@/components/ParallaxPage"; -import { PlayButton } from "@/components/PlayButton"; -import { PlayedStatus } from "@/components/PlayedStatus"; -import { Ratings } from "@/components/Ratings"; -import { SimilarItems } from "@/components/SimilarItems"; -import { SubtitleTrackSelector } from "@/components/SubtitleTrackSelector"; -import { Text } from "@/components/common/Text"; -import { MoviesTitleHeader } from "@/components/movies/MoviesTitleHeader"; -import { CastAndCrew } from "@/components/series/CastAndCrew"; -import { CurrentSeries } from "@/components/series/CurrentSeries"; -import { NextEpisodeButton } from "@/components/series/NextEpisodeButton"; -import { SeriesTitleHeader } from "@/components/series/SeriesTitleHeader"; -import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; -import { useSettings } from "@/utils/atoms/settings"; -import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl"; -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 native from "@/utils/profiles/native"; -import old from "@/utils/profiles/old"; -import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api"; -import { useQuery } from "@tanstack/react-query"; -import { Image } from "expo-image"; -import { useLocalSearchParams } from "expo-router"; -import { useAtom } from "jotai"; -import { useMemo, useState } from "react"; -import { View } from "react-native"; -import { useCastDevice } from "react-native-google-cast"; - -const page: React.FC = () => { - const local = useLocalSearchParams(); - const { id } = local as { id: string }; - - const [api] = useAtom(apiAtom); - const [user] = useAtom(userAtom); - - const [settings] = useSettings(); - - const castDevice = useCastDevice(); - - const [selectedAudioStream, setSelectedAudioStream] = useState(-1); - const [selectedSubtitleStream, setSelectedSubtitleStream] = - useState(0); - const [maxBitrate, setMaxBitrate] = useState({ - key: "Max", - value: undefined, - }); - - const { data: item, isLoading: l1 } = useQuery({ - queryKey: ["item", id], - queryFn: async () => - await getUserItemData({ - api, - userId: user?.Id, - itemId: id, - }), - enabled: !!id && !!api, - staleTime: 60, - }); - - 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, - }); - - return playbackData.data; - }, - enabled: !!item?.Id && !!api && !!user?.Id, - staleTime: 0, - }); - - const { data: playbackUrl } = useQuery({ - queryKey: [ - "playbackUrl", - item?.Id, - maxBitrate, - castDevice, - selectedAudioStream, - selectedSubtitleStream, - settings, - ], - queryFn: async () => { - if (!api || !user?.Id || !sessionData) return null; - - let deviceProfile: any = ios; - - if (castDevice?.deviceId) { - deviceProfile = chromecastProfile; - } else if (settings?.deviceProfile === "Native") { - deviceProfile = native; - } else if (settings?.deviceProfile === "Old") { - deviceProfile = old; - } - - 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, - }); - - console.log("Transcode URL: ", url); - - return url; - }, - enabled: !!sessionData && !!api && !!user?.Id && !!item?.Id, - staleTime: 0, - }); - - const backdropUrl = useMemo( - () => - getBackdropUrl({ - api, - item, - quality: 90, - width: 1000, - }), - [item] - ); - - const logoUrl = useMemo( - () => (item?.Type === "Movie" ? getLogoImageUrlById({ api, item }) : null), - [item] - ); - - if (l1) - return ( - - - - ); - - if (!item?.Id || !backdropUrl) return null; - - return ( - - } - logo={ - <> - {logoUrl ? ( - - ) : null} - - } - > - - - {item.Type === "Episode" ? ( - - ) : ( - <> - - - )} - {item?.ProductionYear} - - - - - {playbackUrl ? ( - - ) : ( - - )} - - - - - - - - setMaxBitrate(val)} - selected={maxBitrate} - /> - - - - - - - - - - - - - {item.Type === "Episode" && ( - - - - )} - - - - - - ); -}; - -export default page; diff --git a/app/(auth)/(tabs)/(home,libraries,search)/items/page.tsx b/app/(auth)/(tabs)/(home,libraries,search)/items/page.tsx new file mode 100644 index 00000000..fa358ddf --- /dev/null +++ b/app/(auth)/(tabs)/(home,libraries,search)/items/page.tsx @@ -0,0 +1,13 @@ +import { ItemContent } from "@/components/ItemContent"; +import { useLocalSearchParams } from "expo-router"; +import React, { useMemo } from "react"; + +const Page: React.FC = () => { + const { id } = useLocalSearchParams() as { id: string }; + + const memoizedContent = useMemo(() => , [id]); + + return memoizedContent; +}; + +export default React.memo(Page); diff --git a/app/(auth)/(tabs)/(home,libraries,search)/series/[id].tsx b/app/(auth)/(tabs)/(home,libraries,search)/series/[id].tsx index c7c99103..73870886 100644 --- a/app/(auth)/(tabs)/(home,libraries,search)/series/[id].tsx +++ b/app/(auth)/(tabs)/(home,libraries,search)/series/[id].tsx @@ -20,6 +20,8 @@ const page: React.FC = () => { seasonIndex: string; }; + console.log("seasonIndex", seasonIndex); + const [api] = useAtom(apiAtom); const [user] = useAtom(userAtom); @@ -59,6 +61,7 @@ const page: React.FC = () => { return ( { - + ); diff --git a/app/(auth)/(tabs)/(home,libraries,search)/songs/[songId].tsx b/app/(auth)/(tabs)/(home,libraries,search)/songs/[songId].tsx deleted file mode 100644 index 8a93d33d..00000000 --- a/app/(auth)/(tabs)/(home,libraries,search)/songs/[songId].tsx +++ /dev/null @@ -1,271 +0,0 @@ -import { AudioTrackSelector } from "@/components/AudioTrackSelector"; -import { Bitrate, BitrateSelector } from "@/components/BitrateSelector"; -import { Chromecast } from "@/components/Chromecast"; -import { Text } from "@/components/common/Text"; -import { DownloadItem } from "@/components/DownloadItem"; -import { Loader } from "@/components/Loader"; -import { MoviesTitleHeader } from "@/components/movies/MoviesTitleHeader"; -import { ParallaxScrollView } from "@/components/ParallaxPage"; -import { PlayButton } from "@/components/PlayButton"; -import { NextEpisodeButton } from "@/components/series/NextEpisodeButton"; -import { SimilarItems } from "@/components/SimilarItems"; -import { SubtitleTrackSelector } from "@/components/SubtitleTrackSelector"; -import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; -import { usePlayback } from "@/providers/PlaybackProvider"; -import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl"; -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 { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api"; -import { useQuery } from "@tanstack/react-query"; -import { Image } from "expo-image"; -import { useLocalSearchParams, useNavigation } from "expo-router"; -import { useAtom } from "jotai"; -import { useCallback, useEffect, useMemo, useState } from "react"; -import { ScrollView, View } from "react-native"; -import CastContext, { - PlayServicesState, - useCastDevice, - useRemoteMediaClient, -} from "react-native-google-cast"; - -const page: React.FC = () => { - const local = useLocalSearchParams(); - const { songId: id } = local as { songId: string }; - - const [api] = useAtom(apiAtom); - const [user] = useAtom(userAtom); - - const { setCurrentlyPlayingState } = usePlayback(); - - const castDevice = useCastDevice(); - const navigation = useNavigation(); - - useEffect(() => { - navigation.setOptions({ - headerRight: () => ( - - - - ), - }); - }); - - const chromecastReady = useMemo(() => !!castDevice?.deviceId, [castDevice]); - const [selectedAudioStream, setSelectedAudioStream] = useState(-1); - const [selectedSubtitleStream, setSelectedSubtitleStream] = - useState(0); - const [maxBitrate, setMaxBitrate] = useState({ - key: "Max", - value: undefined, - }); - - const { data: item, isLoading: l1 } = useQuery({ - queryKey: ["item", id], - queryFn: async () => - await getUserItemData({ - api, - userId: user?.Id, - itemId: id, - }), - enabled: !!id && !!api, - staleTime: 60 * 1000, - }); - - const backdropUrl = useMemo( - () => - getBackdropUrl({ - api, - item, - quality: 90, - width: 1000, - }), - [item] - ); - - const logoUrl = useMemo( - () => (item?.Type === "Movie" ? getLogoImageUrlById({ api, item }) : null), - [item] - ); - - 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, - }); - - return playbackData.data; - }, - enabled: !!item?.Id && !!api && !!user?.Id, - staleTime: 0, - }); - - const { data: playbackUrl } = useQuery({ - queryKey: [ - "playbackUrl", - item?.Id, - maxBitrate, - castDevice, - selectedAudioStream, - selectedSubtitleStream, - ], - queryFn: async () => { - if (!api || !user?.Id || !sessionData) return null; - - const url = await getStreamUrl({ - api, - userId: user.Id, - item, - startTimeTicks: item?.UserData?.PlaybackPositionTicks || 0, - maxStreamingBitrate: maxBitrate.value, - sessionData, - deviceProfile: castDevice?.deviceId ? chromecastProfile : ios, - audioStreamIndex: selectedAudioStream, - subtitleStreamIndex: selectedSubtitleStream, - }); - - console.log("Transcode URL: ", url); - - return url; - }, - enabled: !!sessionData, - staleTime: 0, - }); - - const client = useRemoteMediaClient(); - - const onPressPlay = useCallback( - async (type: "device" | "cast" = "device") => { - if (!playbackUrl || !item) return; - - if (type === "cast" && client) { - await CastContext.getPlayServicesState().then((state) => { - if (state && state !== PlayServicesState.SUCCESS) - CastContext.showPlayServicesErrorDialog(state); - else { - client.loadMedia({ - mediaInfo: { - contentUrl: playbackUrl, - contentType: "video/mp4", - metadata: { - type: item.Type === "Episode" ? "tvShow" : "movie", - title: item.Name || "", - subtitle: item.Overview || "", - }, - }, - startTime: 0, - }); - } - }); - } else { - setCurrentlyPlayingState({ - item, - url: playbackUrl, - }); - } - }, - [playbackUrl, item] - ); - - if (l1) - return ( - - - - ); - - if (!item?.Id || !backdropUrl) return null; - - return ( - - } - logo={ - <> - {logoUrl ? ( - - ) : null} - - } - > - - - - {item?.ProductionYear} - - - - {playbackUrl ? ( - - ) : ( - - )} - - - - - setMaxBitrate(val)} - selected={maxBitrate} - /> - - - - - - - - - - - - - Audio - - - - {item.MediaStreams?.find((i) => i.Type === "Audio")?.DisplayTitle} - - - - - - - - - - ); -}; - -export default page; diff --git a/app/(auth)/(tabs)/(libraries)/[libraryId].tsx b/app/(auth)/(tabs)/(libraries)/[libraryId].tsx index 3f96282e..77b2e3ff 100644 --- a/app/(auth)/(tabs)/(libraries)/[libraryId].tsx +++ b/app/(auth)/(tabs)/(libraries)/[libraryId].tsx @@ -9,7 +9,12 @@ import React, { useMemo, useState, } from "react"; -import { FlatList, RefreshControl, View } from "react-native"; +import { + FlatList, + RefreshControl, + useWindowDimensions, + View, +} from "react-native"; import { Text } from "@/components/common/Text"; import { TouchableItemRouter } from "@/components/common/TouchableItemRouter"; @@ -39,6 +44,8 @@ import { } from "@jellyfin/sdk/lib/utils/api"; import { FlashList } from "@shopify/flash-list"; import { Loader } from "@/components/Loader"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { orientationAtom } from "@/utils/atoms/orientation"; const MemoizedTouchableItemRouter = React.memo(TouchableItemRouter); @@ -49,6 +56,7 @@ const Page = () => { const [api] = useAtom(apiAtom); const [user] = useAtom(userAtom); const navigation = useNavigation(); + const { width: screenWidth } = useWindowDimensions(); const [selectedGenres, setSelectedGenres] = useAtom(genreFilterAtom); const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom); @@ -56,9 +64,15 @@ const Page = () => { const [sortBy, setSortBy] = useAtom(sortByAtom); const [sortOrder, setSortOrder] = useAtom(sortOrderAtom); - const [orientation, setOrientation] = useState( - ScreenOrientation.Orientation.PORTRAIT_UP - ); + const [orientation, setOrientation] = useAtom(orientationAtom); + + const getNumberOfColumns = useCallback(() => { + if (orientation === ScreenOrientation.Orientation.PORTRAIT_UP) return 3; + if (screenWidth < 600) return 5; + if (screenWidth < 960) return 6; + if (screenWidth < 1280) return 7; + return 6; + }, [screenWidth, orientation]); useLayoutEffect(() => { setSortBy([ @@ -75,22 +89,6 @@ const Page = () => { ]); }, []); - useEffect(() => { - const subscription = ScreenOrientation.addOrientationChangeListener( - (event) => { - setOrientation(event.orientationInfo.orientation); - } - ); - - ScreenOrientation.getOrientationAsync().then((initialOrientation) => { - setOrientation(initialOrientation); - }); - - return () => { - ScreenOrientation.removeOrientationChangeListener(subscription); - }; - }, []); - const { data: library, isLoading: isLibraryLoading } = useQuery({ queryKey: ["library", libraryId], queryFn: async () => { @@ -193,18 +191,19 @@ const Page = () => { key={item.Id} style={{ width: "100%", - marginBottom: - orientation === ScreenOrientation.Orientation.PORTRAIT_UP ? 4 : 16, + marginBottom: 4, }} item={item} > { ] ); + const insets = useSafeAreaInsets(); + if (isLoading || isLibraryLoading) return ( @@ -399,11 +400,10 @@ const Page = () => { contentInsetAdjustmentBehavior="automatic" data={flatData} renderItem={renderItem} + extraData={orientation} keyExtractor={keyExtractor} estimatedItemSize={244} - numColumns={ - orientation === ScreenOrientation.Orientation.PORTRAIT_UP ? 3 : 5 - } + numColumns={getNumberOfColumns()} onEndReached={() => { if (hasNextPage) { fetchNextPage(); @@ -411,7 +411,11 @@ const Page = () => { }} onEndReachedThreshold={1} ListHeaderComponent={ListHeaderComponent} - contentContainerStyle={{ paddingBottom: 24 }} + contentContainerStyle={{ + paddingBottom: 24, + paddingLeft: insets.left, + paddingRight: insets.right, + }} ItemSeparatorComponent={() => ( { @@ -54,6 +55,8 @@ export default function index() { } }, [data]); + const insets = useSafeAreaInsets(); + if (isLoading) return ( @@ -76,6 +79,8 @@ export default function index() { paddingTop: 17, paddingHorizontal: settings?.libraryOptions?.display === "row" ? 0 : 17, paddingBottom: 150, + paddingLeft: insets.left, + paddingRight: insets.right, }} data={data} renderItem={({ item }) => } diff --git a/app/(auth)/(tabs)/(search)/index.tsx b/app/(auth)/(tabs)/(search)/index.tsx index 9fadddbb..5975c9e6 100644 --- a/app/(auth)/(tabs)/(search)/index.tsx +++ b/app/(auth)/(tabs)/(search)/index.tsx @@ -1,4 +1,3 @@ -import { Button } from "@/components/Button"; import { HorizontalScroll } from "@/components/common/HorrizontalScroll"; import { Input } from "@/components/common/Input"; import { Text } from "@/components/common/Text"; @@ -12,8 +11,6 @@ import SeriesPoster from "@/components/posters/SeriesPoster"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { useSettings } from "@/utils/atoms/settings"; import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData"; -import { Ionicons } from "@expo/vector-icons"; -import { Api } from "@jellyfin/sdk"; import { BaseItemDto, BaseItemKind, @@ -21,13 +18,7 @@ import { import { getItemsApi, getSearchApi } from "@jellyfin/sdk/lib/utils/api"; import { useQuery } from "@tanstack/react-query"; import axios from "axios"; -import { - Href, - router, - useLocalSearchParams, - useNavigation, - usePathname, -} from "expo-router"; +import { Href, router, useLocalSearchParams, useNavigation } from "expo-router"; import { useAtom } from "jotai"; import React, { useCallback, @@ -37,6 +28,7 @@ import React, { useState, } from "react"; import { Platform, ScrollView, TouchableOpacity, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useDebounce } from "use-debounce"; const exampleSearches = [ @@ -50,6 +42,7 @@ const exampleSearches = [ export default function search() { const params = useLocalSearchParams(); + const insets = useSafeAreaInsets(); const { q, prev } = params as { q: string; prev: Href }; @@ -229,6 +222,10 @@ export default function search() { {Platform.OS === "android" && ( @@ -254,7 +251,7 @@ export default function search() { header="Movies" ids={movies?.map((m) => m.Id!)} renderItem={(data) => ( - + ( m.Id!)} header="Series" renderItem={(data) => ( - + ( m.Id!)} header="Episodes" renderItem={(data) => ( - + ( router.push(`/items/${item.Id}`)} + onPress={() => router.push(`/items/page?id=${item.Id}`)} className="flex flex-col w-44" > @@ -321,7 +318,7 @@ export default function search() { ids={collections?.map((m) => m.Id!)} header="Collections" renderItem={(data) => ( - + ( m.Id!)} header="Actors" renderItem={(data) => ( - + ( m.Id!)} header="Artists" renderItem={(data) => ( - + ( m.Id!)} header="Albums" renderItem={(data) => ( - + ( m.Id!)} header="Songs" renderItem={(data) => ( - + ( { + const subscription = ScreenOrientation.addOrientationChangeListener( + (event) => { + console.log(event.orientationInfo.orientation); + setOrientation(event.orientationInfo.orientation); + } + ); + + ScreenOrientation.getOrientationAsync().then((initialOrientation) => { + setOrientation(initialOrientation); + }); + + return () => { + ScreenOrientation.removeOrientationChangeListener(subscription); + }; + }, []); + + const url = Linking.useURL(); + + if (url) { + const { hostname, path, queryParams } = Linking.parse(url); + } + return ( diff --git a/app/login.tsx b/app/login.tsx index b5639ffd..9cb37339 100644 --- a/app/login.tsx +++ b/app/login.tsx @@ -3,9 +3,9 @@ import { Input } from "@/components/common/Input"; import { Text } from "@/components/common/Text"; import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider"; import { Ionicons } from "@expo/vector-icons"; -import { AxiosError } from "axios"; +import { useLocalSearchParams } from "expo-router"; import { useAtom } from "jotai"; -import React, { useMemo, useState } from "react"; +import React, { useEffect, useState } from "react"; import { Alert, KeyboardAvoidingView, @@ -21,19 +21,44 @@ const CredentialsSchema = z.object({ }); const Login: React.FC = () => { - const { setServer, login, removeServer } = useJellyfin(); + const { setServer, login, removeServer, initiateQuickConnect } = + useJellyfin(); const [api] = useAtom(apiAtom); + const params = useLocalSearchParams(); - const [serverURL, setServerURL] = useState(""); + const { + apiUrl: _apiUrl, + username: _username, + password: _password, + } = params as { apiUrl: string; username: string; password: string }; + + const [serverURL, setServerURL] = useState(_apiUrl); const [error, setError] = useState(""); const [credentials, setCredentials] = useState<{ username: string; password: string; }>({ - username: "", - password: "", + username: _username, + password: _password, }); + useEffect(() => { + (async () => { + if (_apiUrl) { + setServer({ + address: _apiUrl, + }); + + setTimeout(() => { + if (_username && _password) { + setCredentials({ username: _username, password: _password }); + login(_username, _password); + } + }, 300); + } + })(); + }, [_apiUrl, _username, _password]); + const [loading, setLoading] = useState(false); const handleLogin = async () => { @@ -62,6 +87,21 @@ const Login: React.FC = () => { setServer({ address: url.trim() }); }; + const handleQuickConnect = async () => { + try { + const code = await initiateQuickConnect(); + if (code) { + Alert.alert("Quick Connect", `Enter code ${code} to login`, [ + { + text: "Got It", + }, + ]); + } + } catch (error) { + Alert.alert("Error", "Failed to initiate Quick Connect"); + } + }; + if (api?.basePath) { return ( @@ -137,13 +177,18 @@ const Login: React.FC = () => { {error} - + + + + diff --git a/bun.lockb b/bun.lockb index 32f4452c..20d73a62 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/components/AudioTrackSelector.tsx b/components/AudioTrackSelector.tsx index d20e1184..5b3f91db 100644 --- a/components/AudioTrackSelector.tsx +++ b/components/AudioTrackSelector.tsx @@ -2,27 +2,29 @@ import { TouchableOpacity, View } from "react-native"; import * as DropdownMenu from "zeego/dropdown-menu"; import { Text } from "./common/Text"; import { atom, useAtom } from "jotai"; -import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; +import { + BaseItemDto, + MediaSourceInfo, +} from "@jellyfin/sdk/lib/generated-client/models"; import { useEffect, useMemo } from "react"; import { MediaStream } from "@jellyfin/sdk/lib/generated-client/models"; import { tc } from "@/utils/textTools"; interface Props extends React.ComponentProps { - item: BaseItemDto; + source: MediaSourceInfo; onChange: (value: number) => void; selected: number; } export const AudioTrackSelector: React.FC = ({ - item, + source, onChange, selected, ...props }) => { const audioStreams = useMemo( - () => - item.MediaSources?.[0].MediaStreams?.filter((x) => x.Type === "Audio"), - [item] + () => source.MediaStreams?.filter((x) => x.Type === "Audio"), + [source] ); const selectedAudioSteam = useMemo( @@ -31,23 +33,26 @@ export const AudioTrackSelector: React.FC = ({ ); useEffect(() => { - const index = item.MediaSources?.[0].DefaultAudioStreamIndex; + const index = source.DefaultAudioStreamIndex; if (index !== undefined && index !== null) onChange(index); }, []); return ( - + - - Audio streams - - - - {tc(selectedAudioSteam?.DisplayTitle, 13)} - - - + + Audio + + + {selectedAudioSteam?.DisplayTitle} + + { onChange: (value: Bitrate) => void; selected: Bitrate; + inverted?: boolean; } export const BitrateSelector: React.FC = ({ onChange, selected, + inverted, ...props }) => { + const sorted = useMemo(() => { + if (inverted) + return BITRATES.sort( + (a, b) => (a.value || Infinity) - (b.value || Infinity) + ); + return BITRATES.sort( + (a, b) => (b.value || Infinity) - (a.value || Infinity) + ); + }, []); + return ( - + - - Bitrate - - - - {BITRATES.find((b) => b.value === selected.value)?.key} - - - + + Quality + + + {BITRATES.find((b) => b.value === selected.value)?.key} + + Bitrates - {BITRATES?.map((b, index: number) => ( + {sorted.map((b) => ( { onChange(b); }} diff --git a/components/Button.tsx b/components/Button.tsx index 813c4222..305312d4 100644 --- a/components/Button.tsx +++ b/components/Button.tsx @@ -10,7 +10,7 @@ interface ButtonProps extends React.ComponentProps { disabled?: boolean; children?: string | ReactNode; loading?: boolean; - color?: "purple" | "red" | "black"; + color?: "purple" | "red" | "black" | "transparent"; iconRight?: ReactNode; iconLeft?: ReactNode; justify?: "center" | "between"; @@ -37,6 +37,8 @@ export const Button: React.FC> = ({ return "bg-red-600"; case "black": return "bg-neutral-900 border border-neutral-800"; + case "transparent": + return "bg-transparent"; } }, [color]); diff --git a/components/Chromecast.tsx b/components/Chromecast.tsx index 6539ce0b..0b8c9cce 100644 --- a/components/Chromecast.tsx +++ b/components/Chromecast.tsx @@ -1,6 +1,6 @@ import { BlurView } from "expo-blur"; import React, { useEffect } from "react"; -import { View } from "react-native"; +import { Platform, View, ViewProps } from "react-native"; import GoogleCast, { CastButton, useCastDevice, @@ -8,16 +8,17 @@ import GoogleCast, { useRemoteMediaClient, } from "react-native-google-cast"; -type Props = { +interface Props extends ViewProps { width?: number; height?: number; background?: "blur" | "transparent"; -}; +} export const Chromecast: React.FC = ({ width = 48, height = 48, background = "transparent", + ...props }) => { const client = useRemoteMediaClient(); const castDevice = useCastDevice(); @@ -37,7 +38,20 @@ export const Chromecast: React.FC = ({ if (background === "transparent") return ( - + + + + ); + + if (Platform.OS === "android") + return ( + ); @@ -46,6 +60,7 @@ export const Chromecast: React.FC = ({ diff --git a/components/ContinueWatchingPoster.tsx b/components/ContinueWatchingPoster.tsx index e057ee15..c93ec275 100644 --- a/components/ContinueWatchingPoster.tsx +++ b/components/ContinueWatchingPoster.tsx @@ -5,29 +5,41 @@ import { useAtom } from "jotai"; import { useMemo, useState } from "react"; import { View } from "react-native"; import { WatchedIndicator } from "./WatchedIndicator"; -import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; type ContinueWatchingPosterProps = { item: BaseItemDto; width?: number; + useEpisodePoster?: boolean; }; const ContinueWatchingPoster: React.FC = ({ item, width = 176, + useEpisodePoster = false, }) => { const [api] = useAtom(apiAtom); - const url = useMemo( - () => - getPrimaryImageUrl({ - api, - item, - quality: 80, - width: 300, - }), - [item] - ); + /** + * Get horrizontal poster for movie and episode, with failover to primary. + */ + const url = useMemo(() => { + if (!api) return; + if (item.Type === "Episode" && useEpisodePoster) { + return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`; + } + if (item.Type === "Episode") { + if (item.ParentBackdropItemId && item.ParentThumbImageTag) + return `${api?.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ParentThumbImageTag}`; + else + return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`; + } + if (item.Type === "Movie") { + if (item.ImageTags?.["Thumb"]) + return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ImageTags?.["Thumb"]}`; + else + return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`; + } + }, [item]); const [progress, setProgress] = useState( item.UserData?.PlayedPercentage || 0 diff --git a/components/CurrentlyPlayingBar.tsx b/components/CurrentlyPlayingBar.tsx index a3276692..92a938a7 100644 --- a/components/CurrentlyPlayingBar.tsx +++ b/components/CurrentlyPlayingBar.tsx @@ -24,8 +24,8 @@ export const CurrentlyPlayingBar: React.FC = () => { currentlyPlaying, pauseVideo, playVideo, - setCurrentlyPlayingState, stopPlayback, + setVolume, setIsPlaying, isPlaying, videoRef, @@ -34,7 +34,6 @@ export const CurrentlyPlayingBar: React.FC = () => { } = usePlayback(); const [api] = useAtom(apiAtom); - const [user] = useAtom(userAtom); const aBottom = useSharedValue(0); const aPadding = useSharedValue(0); @@ -64,6 +63,8 @@ export const CurrentlyPlayingBar: React.FC = () => { }; }); + const from = useMemo(() => segments[2], [segments]); + useEffect(() => { if (segments.find((s) => s.includes("tabs"))) { // Tab screen - i.e. home @@ -92,16 +93,40 @@ export const CurrentlyPlayingBar: React.FC = () => { [currentlyPlaying?.item] ); - const backdropUrl = useMemo( - () => - getBackdropUrl({ + const poster = useMemo(() => { + if (currentlyPlaying?.item.Type === "Audio") + return `${api?.basePath}/Items/${currentlyPlaying.item.AlbumId}/Images/Primary?tag=${currentlyPlaying.item.AlbumPrimaryImageTag}&quality=90&maxHeight=200&maxWidth=200`; + else + return getBackdropUrl({ api, item: currentlyPlaying?.item, quality: 70, width: 200, - }), - [currentlyPlaying?.item, api] - ); + }); + }, [currentlyPlaying?.item.Id, api]); + + const videoSource = useMemo(() => { + if (!api || !currentlyPlaying || !poster) return null; + return { + uri: currentlyPlaying.url, + isNetwork: true, + startPosition, + headers: getAuthHeaders(api), + metadata: { + artist: currentlyPlaying.item?.AlbumArtist + ? currentlyPlaying.item?.AlbumArtist + : undefined, + title: currentlyPlaying.item?.Name || "Unknown", + description: currentlyPlaying.item?.Overview + ? currentlyPlaying.item?.Overview + : undefined, + imageUri: poster, + subtitle: currentlyPlaying.item?.Album + ? currentlyPlaying.item?.Album + : undefined, + }, + }; + }, [currentlyPlaying, startPosition, api, poster]); if (!api || !currentlyPlaying) return null; @@ -137,7 +162,7 @@ export const CurrentlyPlayingBar: React.FC = () => { } `} > - {currentlyPlaying?.url && ( + {videoSource && ( - - ); - } else { - return ( - { - queueActions.enqueue(queue, setQueue, { - id: item.Id!, - execute: async () => { - // await startRemuxing(playbackUrl); - if (!settings?.downloadQuality?.value) { - throw new Error("No download quality selected"); - } - await initiateDownload(settings?.downloadQuality?.value); - }, - item, - }); + + ) : ( + + + + )} + - - - - - ); - } + + + + Download options + + + setMaxBitrate(val)} + selected={maxBitrate} + /> + + {selectedMediaSource && ( + + + + + )} + + + + + + + ); }; diff --git a/components/ItemContent.tsx b/components/ItemContent.tsx new file mode 100644 index 00000000..2722e012 --- /dev/null +++ b/components/ItemContent.tsx @@ -0,0 +1,364 @@ +import { AudioTrackSelector } from "@/components/AudioTrackSelector"; +import { Bitrate, BitrateSelector } from "@/components/BitrateSelector"; +import { DownloadItem } from "@/components/DownloadItem"; +import { OverviewText } from "@/components/OverviewText"; +import { ParallaxScrollView } from "@/components/ParallaxPage"; +import { PlayButton } from "@/components/PlayButton"; +import { PlayedStatus } from "@/components/PlayedStatus"; +import { SimilarItems } from "@/components/SimilarItems"; +import { SubtitleTrackSelector } from "@/components/SubtitleTrackSelector"; +import { ItemImage } from "@/components/common/ItemImage"; +import { CastAndCrew } from "@/components/series/CastAndCrew"; +import { CurrentSeries } from "@/components/series/CurrentSeries"; +import { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarousel"; +import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; +import { useSettings } from "@/utils/atoms/settings"; +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 native from "@/utils/profiles/native"; +import old from "@/utils/profiles/old"; +import { 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 { useNavigation } from "expo-router"; +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 { Chromecast } from "./Chromecast"; +import { ItemHeader } from "./ItemHeader"; +import { MediaSourceSelector } from "./MediaSourceSelector"; +import Animated, { + useSharedValue, + useAnimatedStyle, + withTiming, + runOnJS, +} from "react-native-reanimated"; +import { Loader } from "./Loader"; +import { set } from "lodash"; +import * as ScreenOrientation from "expo-screen-orientation"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => { + 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(0); + const [maxBitrate, setMaxBitrate] = useState({ + key: "Max", + value: undefined, + }); + + const [loadingImage, setLoadingImage] = 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); + } + ); + + 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); + + 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]); + + 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]); + + 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, + }); + + 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 || !sessionData || !selectedMediaSource?.Id) + return null; + + let deviceProfile: any = ios; + + if (castDevice?.deviceId) { + deviceProfile = chromecastProfile; + } else if (settings?.deviceProfile === "Native") { + deviceProfile = native; + } else if (settings?.deviceProfile === "Old") { + deviceProfile = old; + } + + 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: !!sessionData && !!api && !!user?.Id && !!item?.Id, + staleTime: 0, + }); + + const logoUrl = useMemo(() => getLogoImageUrlById({ api, item }), [item]); + + const loading = useMemo(() => { + return Boolean( + isLoading || isFetching || loadingImage || (logoUrl && loadingLogo) + ); + }, [isLoading, isFetching, loadingImage, loadingLogo, logoUrl]); + + const insets = useSafeAreaInsets(); + + return ( + + {loading && ( + + + + )} + + + {localItem && ( + setLoadingImage(false)} + onError={() => setLoadingImage(false)} + /> + )} + + + } + logo={ + <> + {logoUrl ? ( + setLoadingLogo(false)} + onError={() => setLoadingLogo(false)} + /> + ) : null} + + } + > + + + + + {localItem ? ( + + setMaxBitrate(val)} + selected={maxBitrate} + /> + + {selectedMediaSource && ( + <> + + + + )} + + ) : ( + + + + + )} + + + + + + {item?.Type === "Episode" && ( + + )} + + + + + + {item?.Type === "Episode" && ( + + )} + + + + + + + ); +}); diff --git a/components/ItemHeader.tsx b/components/ItemHeader.tsx new file mode 100644 index 00000000..dcffe023 --- /dev/null +++ b/components/ItemHeader.tsx @@ -0,0 +1,38 @@ +import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; +import { View, ViewProps } from "react-native"; +import { MoviesTitleHeader } from "./movies/MoviesTitleHeader"; +import { Ratings } from "./Ratings"; +import { EpisodeTitleHeader } from "./series/EpisodeTitleHeader"; + +interface Props extends ViewProps { + item?: BaseItemDto | null; +} + +export const ItemHeader: React.FC = ({ item, ...props }) => { + if (!item) + return ( + + + + + + + ); + + return ( + + + {item.Type === "Episode" && } + {item.Type === "Movie" && } + + ); +}; diff --git a/components/MediaSourceSelector.tsx b/components/MediaSourceSelector.tsx new file mode 100644 index 00000000..1478837a --- /dev/null +++ b/components/MediaSourceSelector.tsx @@ -0,0 +1,89 @@ +import { tc } from "@/utils/textTools"; +import { + BaseItemDto, + MediaSourceInfo, +} 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"; + +interface Props extends React.ComponentProps { + item: BaseItemDto; + onChange: (value: MediaSourceInfo) => void; + selected: MediaSourceInfo | null; +} + +export const MediaSourceSelector: React.FC = ({ + item, + onChange, + selected, + ...props +}) => { + const mediaSources = useMemo(() => { + return item.MediaSources; + }, [item]); + + const selectedMediaSource = useMemo( + () => + mediaSources + ?.find((x) => x.Id === selected?.Id) + ?.MediaStreams?.find((x) => x.Type === "Video")?.DisplayTitle || "", + [mediaSources, selected] + ); + + useEffect(() => { + if (mediaSources?.length) onChange(mediaSources[0]); + }, [mediaSources]); + + const name = (name?: string | null) => { + if (name && name.length > 40) + return ( + name.substring(0, 20) + " [...] " + name.substring(name.length - 20) + ); + return name; + }; + + return ( + + + + + Video + + {selectedMediaSource} + + + + + Media sources + {mediaSources?.map((source, idx: number) => ( + { + onChange(source); + }} + > + + {name(source.Name)} + + + ))} + + + + ); +}; diff --git a/components/OverviewText.tsx b/components/OverviewText.tsx index 96075f08..1d448618 100644 --- a/components/OverviewText.tsx +++ b/components/OverviewText.tsx @@ -10,35 +10,32 @@ interface Props extends ViewProps { export const OverviewText: React.FC = ({ text, - characterLimit = 140, + characterLimit = 100, ...props }) => { const [limit, setLimit] = useState(characterLimit); if (!text) return null; - if (text.length > characterLimit) - return ( + return ( + + Overview setLimit((prev) => prev === characterLimit ? text.length : characterLimit ) } - {...props} > - + {tc(text, limit)} - - {limit === characterLimit ? "Show more" : "Show less"} - + {text.length > characterLimit && ( + + {limit === characterLimit ? "Show more" : "Show less"} + + )} - ); - - return ( - - {text} ); }; diff --git a/components/ParallaxPage.tsx b/components/ParallaxPage.tsx index 4f955c6b..daebca6b 100644 --- a/components/ParallaxPage.tsx +++ b/components/ParallaxPage.tsx @@ -1,27 +1,27 @@ -import { Ionicons } from "@expo/vector-icons"; -import { router } from "expo-router"; -import type { PropsWithChildren, ReactElement } from "react"; -import { TouchableOpacity, View } from "react-native"; +import { LinearGradient } from "expo-linear-gradient"; +import { type PropsWithChildren, type ReactElement } from "react"; +import { View, ViewProps } from "react-native"; import Animated, { interpolate, useAnimatedRef, useAnimatedStyle, useScrollViewOffset, } from "react-native-reanimated"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { Chromecast } from "./Chromecast"; -const HEADER_HEIGHT = 400; - -type Props = PropsWithChildren<{ +interface Props extends ViewProps { headerImage: ReactElement; logo?: ReactElement; -}>; + episodePoster?: ReactElement; + headerHeight?: number; +} -export const ParallaxScrollView: React.FC = ({ +export const ParallaxScrollView: React.FC> = ({ children, headerImage, + episodePoster, + headerHeight = 400, logo, + ...props }: Props) => { const scrollRef = useAnimatedRef(); const scrollOffset = useScrollViewOffset(scrollRef); @@ -32,14 +32,14 @@ export const ParallaxScrollView: React.FC = ({ { translateY: interpolate( scrollOffset.value, - [-HEADER_HEIGHT, 0, HEADER_HEIGHT], - [-HEADER_HEIGHT / 2, 0, HEADER_HEIGHT * 0.75] + [-headerHeight, 0, headerHeight], + [-headerHeight / 2, 0, headerHeight * 0.75] ), }, { scale: interpolate( scrollOffset.value, - [-HEADER_HEIGHT, 0, HEADER_HEIGHT], + [-headerHeight, 0, headerHeight], [2, 1, 1] ), }, @@ -47,10 +47,8 @@ export const ParallaxScrollView: React.FC = ({ }; }); - const inset = useSafeAreaInsets(); - return ( - + = ({ scrollEventThrottle={16} > {logo && ( - + {logo} )} @@ -67,7 +71,7 @@ export const ParallaxScrollView: React.FC = ({ = ({ > {headerImage} - + + + + {children} diff --git a/components/PlayButton.tsx b/components/PlayButton.tsx index dc52976b..93211f07 100644 --- a/components/PlayButton.tsx +++ b/components/PlayButton.tsx @@ -1,14 +1,27 @@ import { usePlayback } from "@/providers/PlaybackProvider"; +import { itemThemeColorAtom } from "@/utils/atoms/primaryColor"; import { runtimeTicksToMinutes } from "@/utils/time"; import { useActionSheet } from "@expo/react-native-action-sheet"; import { Feather, Ionicons } from "@expo/vector-icons"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; -import { View } from "react-native"; +import { useAtom } from "jotai"; +import { useEffect, useMemo } from "react"; +import { TouchableOpacity, View } from "react-native"; import CastContext, { PlayServicesState, useRemoteMediaClient, useMediaStatus, } from "react-native-google-cast"; +import Animated, { + Easing, + interpolate, + interpolateColor, + useAnimatedReaction, + useAnimatedStyle, + useDerivedValue, + useSharedValue, + withTiming, +} from "react-native-reanimated"; import { Button } from "./Button"; import { isCancel } from "axios"; @@ -17,23 +30,36 @@ interface Props extends React.ComponentProps { url?: string | null; } +const ANIMATION_DURATION = 500; +const MIN_PLAYBACK_WIDTH = 15; + export const PlayButton: React.FC = ({ item, url, ...props }) => { const { showActionSheetWithOptions } = useActionSheet(); - const client = useRemoteMediaClient(); const { setCurrentlyPlayingState } = usePlayback(); const mediaStatus = useMediaStatus() + const client = useRemoteMediaClient(); + + const [colorAtom] = useAtom(itemThemeColorAtom); + + const memoizedItem = useMemo(() => item, [item?.Id]); // Memoize the item + const memoizedColor = useMemo(() => colorAtom, [colorAtom]); // Memoize the color + + const startWidth = useSharedValue(0); + const targetWidth = useSharedValue(0); + const endColor = useSharedValue(memoizedColor); + const startColor = useSharedValue(memoizedColor); + const widthProgress = useSharedValue(0); + const colorChangeProgress = useSharedValue(0); + const onPress = async () => { if (!url || !item) return; - if (!client) { setCurrentlyPlayingState({ item, url }); return; } - const options = ["Chromecast", "Device", "Cancel"]; const cancelButtonIndex = 2; - showActionSheetWithOptions( { options, @@ -87,18 +113,141 @@ export const PlayButton: React.FC = ({ item, url, ...props }) => { ); }; + const derivedTargetWidth = useDerivedValue(() => { + if (!memoizedItem || !memoizedItem.RunTimeTicks) return 0; + const userData = memoizedItem.UserData; + if (userData && userData.PlaybackPositionTicks) { + return userData.PlaybackPositionTicks > 0 + ? Math.max( + (userData.PlaybackPositionTicks / memoizedItem.RunTimeTicks) * 100, + MIN_PLAYBACK_WIDTH + ) + : 0; + } + return 0; + }, [memoizedItem]); + + useAnimatedReaction( + () => derivedTargetWidth.value, + (newWidth) => { + targetWidth.value = newWidth; + widthProgress.value = 0; + widthProgress.value = withTiming(1, { + duration: ANIMATION_DURATION, + easing: Easing.bezier(0.7, 0, 0.3, 1.0), + }); + }, + [item] + ); + + useAnimatedReaction( + () => memoizedColor, + (newColor) => { + endColor.value = newColor; + colorChangeProgress.value = 0; + colorChangeProgress.value = withTiming(1, { + duration: ANIMATION_DURATION, + easing: Easing.bezier(0.9, 0, 0.31, 0.99), + }); + }, + [memoizedColor] + ); + + useEffect(() => { + const timeout_2 = setTimeout(() => { + startColor.value = memoizedColor; + startWidth.value = targetWidth.value; + }, ANIMATION_DURATION); + + return () => { + clearTimeout(timeout_2); + }; + }, [memoizedColor, memoizedItem]); + + /** + * ANIMATED STYLES + */ + const animatedAverageStyle = useAnimatedStyle(() => ({ + backgroundColor: interpolateColor( + colorChangeProgress.value, + [0, 1], + [startColor.value.average, endColor.value.average] + ), + })); + + const animatedPrimaryStyle = useAnimatedStyle(() => ({ + backgroundColor: interpolateColor( + colorChangeProgress.value, + [0, 1], + [startColor.value.primary, endColor.value.primary] + ), + })); + + const animatedWidthStyle = useAnimatedStyle(() => ({ + width: `${interpolate( + widthProgress.value, + [0, 1], + [startWidth.value, targetWidth.value] + )}%`, + })); + + const animatedTextStyle = useAnimatedStyle(() => ({ + color: interpolateColor( + colorChangeProgress.value, + [0, 1], + [startColor.value.text, endColor.value.text] + ), + })); + /** + * ********************* + */ + return ( - + + + + + + + + + {runtimeTicksToMinutes(item?.RunTimeTicks)} + + + + + {client && ( + + + + )} + + + ); }; diff --git a/components/PlayedStatus.tsx b/components/PlayedStatus.tsx index 1cb65034..b3b55ee9 100644 --- a/components/PlayedStatus.tsx +++ b/components/PlayedStatus.tsx @@ -7,9 +7,13 @@ import { useQueryClient } from "@tanstack/react-query"; import * as Haptics from "expo-haptics"; import { useAtom } from "jotai"; import React from "react"; -import { TouchableOpacity, View } from "react-native"; +import { TouchableOpacity, View, ViewProps } from "react-native"; -export const PlayedStatus: React.FC<{ item: BaseItemDto }> = ({ item }) => { +interface Props extends ViewProps { + item: BaseItemDto; +} + +export const PlayedStatus: React.FC = ({ item, ...props }) => { const [api] = useAtom(apiAtom); const [user] = useAtom(userAtom); @@ -37,7 +41,10 @@ export const PlayedStatus: React.FC<{ item: BaseItemDto }> = ({ item }) => { }; return ( - + {item.UserData?.Played ? ( { @@ -51,7 +58,7 @@ export const PlayedStatus: React.FC<{ item: BaseItemDto }> = ({ item }) => { }} > - + ) : ( @@ -67,7 +74,7 @@ export const PlayedStatus: React.FC<{ item: BaseItemDto }> = ({ item }) => { }} > - + )} diff --git a/components/Ratings.tsx b/components/Ratings.tsx index f3d73168..51b6be3b 100644 --- a/components/Ratings.tsx +++ b/components/Ratings.tsx @@ -5,12 +5,13 @@ import { Ionicons } from "@expo/vector-icons"; import { Image } from "expo-image"; interface Props extends ViewProps { - item: BaseItemDto; + item?: BaseItemDto | null; } -export const Ratings: React.FC = ({ item }) => { +export const Ratings: React.FC = ({ item, ...props }) => { + if (!item) return null; return ( - + {item.OfficialRating && ( )} diff --git a/components/SimilarItems.tsx b/components/SimilarItems.tsx index 45624a52..6777a8d9 100644 --- a/components/SimilarItems.tsx +++ b/components/SimilarItems.tsx @@ -6,23 +6,26 @@ import { useQuery } from "@tanstack/react-query"; import { router } from "expo-router"; import { useAtom } from "jotai"; import { useMemo } from "react"; -import { ScrollView, TouchableOpacity, View } from "react-native"; +import { ScrollView, TouchableOpacity, View, ViewProps } from "react-native"; import { Text } from "./common/Text"; import { ItemCardText } from "./ItemCardText"; import { Loader } from "./Loader"; -type SimilarItemsProps = { - itemId: string; -}; +interface SimilarItemsProps extends ViewProps { + itemId?: string | null; +} -export const SimilarItems: React.FC = ({ itemId }) => { +export const SimilarItems: React.FC = ({ + itemId, + ...props +}) => { const [api] = useAtom(apiAtom); const [user] = useAtom(userAtom); const { data: similarItems, isLoading } = useQuery({ queryKey: ["similarItems", itemId], queryFn: async () => { - if (!api || !user?.Id) return []; + if (!api || !user?.Id || !itemId) return []; const response = await getLibraryApi(api).getSimilarItems({ itemId, userId: user.Id, @@ -41,8 +44,8 @@ export const SimilarItems: React.FC = ({ itemId }) => { ); return ( - - Similar items + + Similar items {isLoading ? ( @@ -53,7 +56,7 @@ export const SimilarItems: React.FC = ({ itemId }) => { {movies.map((item) => ( router.push(`/items/${item.Id}`)} + onPress={() => router.push(`/items/page?id=${item.Id}`)} className="flex flex-col w-32" > @@ -63,7 +66,9 @@ export const SimilarItems: React.FC = ({ itemId }) => { )} - {movies.length === 0 && No similar items} + {movies.length === 0 && ( + No similar items + )} ); }; diff --git a/components/SubtitleTrackSelector.tsx b/components/SubtitleTrackSelector.tsx index 35918de1..3b53a7d9 100644 --- a/components/SubtitleTrackSelector.tsx +++ b/components/SubtitleTrackSelector.tsx @@ -2,29 +2,29 @@ import { TouchableOpacity, View } from "react-native"; import * as DropdownMenu from "zeego/dropdown-menu"; import { Text } from "./common/Text"; import { atom, useAtom } from "jotai"; -import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; +import { + BaseItemDto, + MediaSourceInfo, +} from "@jellyfin/sdk/lib/generated-client/models"; import { useEffect, useMemo } from "react"; import { MediaStream } from "@jellyfin/sdk/lib/generated-client/models"; import { tc } from "@/utils/textTools"; interface Props extends React.ComponentProps { - item: BaseItemDto; + source: MediaSourceInfo; onChange: (value: number) => void; selected: number; } export const SubtitleTrackSelector: React.FC = ({ - item, + source, onChange, selected, ...props }) => { const subtitleStreams = useMemo( - () => - item.MediaSources?.[0].MediaStreams?.filter( - (x) => x.Type === "Subtitle" - ) ?? [], - [item] + () => source.MediaStreams?.filter((x) => x.Type === "Subtitle") ?? [], + [source] ); const selectedSubtitleSteam = useMemo( @@ -33,7 +33,7 @@ export const SubtitleTrackSelector: React.FC = ({ ); useEffect(() => { - const index = item.MediaSources?.[0].DefaultSubtitleStreamIndex; + const index = source.DefaultSubtitleStreamIndex; if (index !== undefined && index !== null) { onChange(index); } else { @@ -44,20 +44,24 @@ export const SubtitleTrackSelector: React.FC = ({ if (subtitleStreams.length === 0) return null; return ( - + - - Subtitles - - - - {selectedSubtitleSteam - ? tc(selectedSubtitleSteam?.DisplayTitle, 13) - : "None"} - - - + + Subtitle + + + {selectedSubtitleSteam + ? tc(selectedSubtitleSteam?.DisplayTitle, 7) + : "None"} + + = ({ collisionPadding={8} sideOffset={8} > - Subtitles + Subtitle tracks { diff --git a/components/common/HeaderBackButton.tsx b/components/common/HeaderBackButton.tsx index 4ff4c38d..023c1144 100644 --- a/components/common/HeaderBackButton.tsx +++ b/components/common/HeaderBackButton.tsx @@ -36,7 +36,7 @@ export const HeaderBackButton: React.FC = ({ className="drop-shadow-2xl" name="arrow-back" size={24} - color="#077DF2" + color="white" /> @@ -45,7 +45,7 @@ export const HeaderBackButton: React.FC = ({ return ( router.back()} - className=" bg-black rounded-full p-2 border border-neutral-900" + className=" bg-neutral-800/80 rounded-full p-2" {...touchableOpacityProps} > = Partial & Pick; +export interface HorizontalScrollRef { + scrollToIndex: (index: number, viewOffset: number) => void; +} + interface HorizontalScrollProps extends PartialExcept< Omit, "renderItem">, @@ -23,61 +21,69 @@ interface HorizontalScrollProps loadingContainerStyle?: ViewStyle; height?: number; loading?: boolean; + extraData?: any; } -export function HorizontalScroll({ - data = [], - renderItem, - containerStyle, - contentContainerStyle, - loadingContainerStyle, - loading = false, - height = 164, - ...props -}: HorizontalScrollProps): React.ReactElement { - const animatedOpacity = useSharedValue(0); - const animatedStyle1 = useAnimatedStyle(() => { - return { - opacity: withTiming(animatedOpacity.value, { duration: 250 }), - }; - }); +export const HorizontalScroll = forwardRef< + HorizontalScrollRef, + HorizontalScrollProps +>( + ( + { + data = [], + renderItem, + containerStyle, + contentContainerStyle, + loadingContainerStyle, + loading = false, + height = 164, + extraData, + ...props + }: HorizontalScrollProps, + ref: React.ForwardedRef + ) => { + const flashListRef = useRef>(null); - useEffect(() => { - if (data) { - animatedOpacity.value = 1; - } - }, [data]); + useImperativeHandle(ref!, () => ({ + scrollToIndex: (index: number, viewOffset: number) => { + flashListRef.current?.scrollToIndex({ + index, + animated: true, + viewPosition: 0, + viewOffset, + }); + }, + })); - if (data === undefined || data === null || loading) { - return ( - - + const renderFlashListItem = ({ + item, + index, + }: { + item: T; + index: number; + }) => ( + + {renderItem(item, index)} ); - } - const renderFlashListItem = ({ item, index }: { item: T; index: number }) => ( - - {renderItem(item, index)} - - ); + if (!data || loading) { + return ( + + + + + ); + } - return ( - - + ref={flashListRef} data={data} + extraData={extraData} renderItem={renderFlashListItem} horizontal - estimatedItemSize={100} + estimatedItemSize={200} showsHorizontalScrollIndicator={false} contentContainerStyle={{ paddingHorizontal: 16, @@ -90,6 +96,6 @@ export function HorizontalScroll({ )} {...props} /> - - ); -} + ); + } +); diff --git a/components/common/ItemImage.tsx b/components/common/ItemImage.tsx new file mode 100644 index 00000000..c3ad2ed6 --- /dev/null +++ b/components/common/ItemImage.tsx @@ -0,0 +1,97 @@ +import { useImageColors } from "@/hooks/useImageColors"; +import { apiAtom } from "@/providers/JellyfinProvider"; +import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; +import { Image, ImageProps, ImageSource } from "expo-image"; +import { useAtom } from "jotai"; +import { useMemo } from "react"; + +interface Props extends ImageProps { + item: BaseItemDto; + variant?: "Backdrop" | "Primary" | "Thumb" | "Logo"; + quality?: number; + width?: number; +} + +export const ItemImage: React.FC = ({ + item, + variant, + quality = 90, + width = 1000, + ...props +}) => { + const [api] = useAtom(apiAtom); + + const source = useMemo(() => { + if (!api) return null; + + let tag: string | null | undefined; + let blurhash: string | null | undefined; + let src: ImageSource | null = null; + + switch (variant) { + case "Backdrop": + if (item.Type === "Episode") { + tag = item.ParentBackdropImageTags?.[0]; + if (!tag) break; + blurhash = item.ImageBlurHashes?.Backdrop?.[tag]; + src = { + uri: `${api.basePath}/Items/${item.ParentBackdropItemId}/Images/Backdrop/0?quality=${quality}&tag=${tag}`, + blurhash, + }; + break; + } + + tag = item.BackdropImageTags?.[0]; + if (!tag) break; + blurhash = item.ImageBlurHashes?.Backdrop?.[tag]; + src = { + uri: `${api.basePath}/Items/${item.Id}/Images/Backdrop/0?quality=${quality}&tag=${tag}`, + blurhash, + }; + break; + case "Primary": + tag = item.ImageTags?.["Primary"]; + if (!tag) break; + blurhash = item.ImageBlurHashes?.Primary?.[tag]; + + src = { + uri: `${api.basePath}/Items/${item.Id}/Images/Primary?quality=${quality}&tag=${tag}`, + blurhash, + }; + break; + case "Thumb": + tag = item.ImageTags?.["Thumb"]; + if (!tag) break; + blurhash = item.ImageBlurHashes?.Thumb?.[tag]; + + src = { + uri: `${api.basePath}/Items/${item.Id}/Images/Backdrop?quality=${quality}&tag=${tag}`, + blurhash, + }; + break; + default: + tag = item.ImageTags?.["Primary"]; + src = { + uri: `${api.basePath}/Items/${item.Id}/Images/Primary?quality=${quality}&tag=${tag}`, + }; + break; + } + + return src; + }, [item.ImageTags]); + + useImageColors(source?.uri); + + return ( + + ); +}; diff --git a/components/common/TouchableItemRouter.tsx b/components/common/TouchableItemRouter.tsx index 8ca06867..6e9a8122 100644 --- a/components/common/TouchableItemRouter.tsx +++ b/components/common/TouchableItemRouter.tsx @@ -22,8 +22,6 @@ export const TouchableItemRouter: React.FC> = ({ return ( { - console.log("[0]", item.Type); - Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); if (item.Type === "Series") { @@ -72,7 +70,7 @@ export const TouchableItemRouter: React.FC> = ({ // return; // } - router.push(`/(auth)/(tabs)/${from}/items/${item.Id}`); + router.push(`/(auth)/(tabs)/${from}/items/page?id=${item.Id}`); }} {...props} > diff --git a/components/downloads/SeriesCard.tsx b/components/downloads/SeriesCard.tsx index 96c63675..c010ca04 100644 --- a/components/downloads/SeriesCard.tsx +++ b/components/downloads/SeriesCard.tsx @@ -25,7 +25,7 @@ export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({ items }) => { return ( - {items[0].SeriesName} + {items[0].SeriesName} {items.length} diff --git a/components/home/LargeMovieCarousel.tsx b/components/home/LargeMovieCarousel.tsx index 63a64d9c..989a1ad8 100644 --- a/components/home/LargeMovieCarousel.tsx +++ b/components/home/LargeMovieCarousel.tsx @@ -47,7 +47,7 @@ export const LargeMovieCarousel: React.FC = ({ ...props }) => { return response.data.Items?.[0].Id || null; }, enabled: !!api && !!user?.Id && settings?.usePopularPlugin === true, - staleTime: 0, + staleTime: 60 * 1000, }); const onPressPagination = (index: number) => { @@ -75,7 +75,7 @@ export const LargeMovieCarousel: React.FC = ({ ...props }) => { return response.data.Items || []; }, enabled: !!api && !!user?.Id && !!sf_carousel, - staleTime: 0, + staleTime: 60 * 1000, }); const width = Dimensions.get("screen").width; diff --git a/components/home/ScrollingCollectionList.tsx b/components/home/ScrollingCollectionList.tsx index 35b7dac1..ab4ef0ae 100644 --- a/components/home/ScrollingCollectionList.tsx +++ b/components/home/ScrollingCollectionList.tsx @@ -6,50 +6,72 @@ import ContinueWatchingPoster from "../ContinueWatchingPoster"; import { ItemCardText } from "../ItemCardText"; import { HorizontalScroll } from "../common/HorrizontalScroll"; import { TouchableItemRouter } from "../common/TouchableItemRouter"; +import { + type QueryKey, + useQuery, + type QueryFunction, +} from "@tanstack/react-query"; +import SeriesPoster from "../posters/SeriesPoster"; +import { EpisodePoster } from "../posters/EpisodePoster"; interface Props extends ViewProps { - title: string; - loading?: boolean; + title?: string | null; orientation?: "horizontal" | "vertical"; - data?: BaseItemDto[] | null; height?: "small" | "large"; disabled?: boolean; + queryKey: QueryKey; + queryFn: QueryFunction; } export const ScrollingCollectionList: React.FC = ({ title, - data, orientation = "vertical", height = "small", - loading = false, disabled = false, + queryFn, + queryKey, ...props }) => { - if (disabled) return null; + const { data, isLoading } = useQuery({ + queryKey, + queryFn, + enabled: !disabled, + staleTime: 60 * 1000, + }); + + if (disabled || !title) return null; return ( {title} - + ( - {orientation === "vertical" ? ( - - ) : ( + {item.Type === "Episode" && orientation === "horizontal" && ( )} + {item.Type === "Episode" && orientation === "vertical" && ( + + )} + {item.Type === "Movie" && orientation === "horizontal" && ( + + )} + {item.Type === "Movie" && orientation === "vertical" && ( + + )} + {item.Type === "Series" && } diff --git a/components/library/LibraryItemCard.tsx b/components/library/LibraryItemCard.tsx index 87de1870..a5d21e11 100644 --- a/components/library/LibraryItemCard.tsx +++ b/components/library/LibraryItemCard.tsx @@ -107,7 +107,7 @@ export const LibraryItemCard: React.FC = ({ library, ...props }) => { }); }) .catch((error) => { - console.log("Error getting colors", error); + console.error("Error getting colors", error); }); } }, [url]); diff --git a/components/medialists/MediaListSection.tsx b/components/medialists/MediaListSection.tsx index 9b013e16..93b7a5c0 100644 --- a/components/medialists/MediaListSection.tsx +++ b/components/medialists/MediaListSection.tsx @@ -12,22 +12,38 @@ import { Text } from "../common/Text"; import { TouchableItemRouter } from "../common/TouchableItemRouter"; import { ItemCardText } from "../ItemCardText"; import MoviePoster from "../posters/MoviePoster"; +import { + type QueryKey, + type QueryFunction, + useQuery, +} from "@tanstack/react-query"; interface Props extends ViewProps { - collection: BaseItemDto; + queryKey: QueryKey; + queryFn: QueryFunction; } -export const MediaListSection: React.FC = ({ collection, ...props }) => { +export const MediaListSection: React.FC = ({ + queryFn, + queryKey, + ...props +}) => { const [api] = useAtom(apiAtom); const [user] = useAtom(userAtom); + const { data: collection, isLoading } = useQuery({ + queryKey, + queryFn, + staleTime: 60 * 1000, + }); + const fetchItems = useCallback( async ({ pageParam, }: { pageParam: number; }): Promise => { - if (!api || !user?.Id) return null; + if (!api || !user?.Id || !collection) return null; const response = await getItemsApi(api).getItems({ userId: user.Id, @@ -38,7 +54,7 @@ export const MediaListSection: React.FC = ({ collection, ...props }) => { return response.data; }, - [api, user?.Id, collection.Id] + [api, user?.Id, collection?.Id] ); if (!collection) return null; diff --git a/components/movies/MoviesTitleHeader.tsx b/components/movies/MoviesTitleHeader.tsx index 771cdb01..0877cf10 100644 --- a/components/movies/MoviesTitleHeader.tsx +++ b/components/movies/MoviesTitleHeader.tsx @@ -1,17 +1,16 @@ -import { TouchableOpacity, View, ViewProps } from "react-native"; import { Text } from "@/components/common/Text"; -import { useRouter } from "expo-router"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; +import { View, ViewProps } from "react-native"; interface Props extends ViewProps { item: BaseItemDto; } export const MoviesTitleHeader: React.FC = ({ item, ...props }) => { - const router = useRouter(); return ( - - {item?.Name} + + {item?.Name} + {item?.ProductionYear} ); }; diff --git a/components/music/SongsListItem.tsx b/components/music/SongsListItem.tsx index d2688b5d..76ed9f73 100644 --- a/components/music/SongsListItem.tsx +++ b/components/music/SongsListItem.tsx @@ -71,7 +71,10 @@ export const SongsListItem: React.FC = ({ }; const play = async (type: "device" | "cast") => { - if (!user?.Id || !api || !item.Id) return; + if (!user?.Id || !api || !item.Id) { + console.warn("No user, api or item", user, api, item.Id); + return; + } const response = await getMediaInfoApi(api!).getPlaybackInfo({ itemId: item?.Id, @@ -87,9 +90,13 @@ export const SongsListItem: React.FC = ({ startTimeTicks: item?.UserData?.PlaybackPositionTicks || 0, sessionData, deviceProfile: castDevice?.deviceId ? chromecastProfile : ios, + mediaSourceId: item.Id, }); - if (!url || !item) return; + if (!url || !item) { + console.warn("No url or item", url, item.Id); + return; + } if (type === "cast" && client) { await CastContext.getPlayServicesState().then((state) => { @@ -111,6 +118,7 @@ export const SongsListItem: React.FC = ({ } }); } else { + console.log("Playing on device", url, item.Id); setCurrentlyPlayingState({ item, url, diff --git a/components/posters/EpisodePoster.tsx b/components/posters/EpisodePoster.tsx new file mode 100644 index 00000000..c82464d5 --- /dev/null +++ b/components/posters/EpisodePoster.tsx @@ -0,0 +1,64 @@ +import { apiAtom } from "@/providers/JellyfinProvider"; +import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; +import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; +import { Image } from "expo-image"; +import { useAtom } from "jotai"; +import { useMemo, useState } from "react"; +import { View } from "react-native"; +import { WatchedIndicator } from "@/components/WatchedIndicator"; + +type MoviePosterProps = { + item: BaseItemDto; + showProgress?: boolean; +}; + +export const EpisodePoster: React.FC = ({ + item, + showProgress = false, +}) => { + const [api] = useAtom(apiAtom); + + const url = useMemo(() => { + if (item.Type === "Episode") { + return `${api?.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ParentThumbImageTag}`; + } + }, [item]); + + const [progress, setProgress] = useState( + item.UserData?.PlayedPercentage || 0 + ); + + const blurhash = useMemo(() => { + const key = item.ImageTags?.["Primary"] as string; + return item.ImageBlurHashes?.["Primary"]?.[key]; + }, [item]); + + return ( + + + + {showProgress && progress > 0 && ( + + )} + + ); +}; diff --git a/components/posters/MoviePoster.tsx b/components/posters/MoviePoster.tsx index 972392b6..056e2c30 100644 --- a/components/posters/MoviePoster.tsx +++ b/components/posters/MoviePoster.tsx @@ -1,3 +1,4 @@ +import { WatchedIndicator } from "@/components/WatchedIndicator"; import { apiAtom } from "@/providers/JellyfinProvider"; import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; @@ -5,7 +6,6 @@ import { Image } from "expo-image"; import { useAtom } from "jotai"; import { useMemo, useState } from "react"; import { View } from "react-native"; -import { WatchedIndicator } from "@/components/WatchedIndicator"; type MoviePosterProps = { item: BaseItemDto; @@ -18,15 +18,13 @@ const MoviePoster: React.FC = ({ }) => { const [api] = useAtom(apiAtom); - const url = useMemo( - () => - getPrimaryImageUrl({ - api, - item, - width: 300, - }), - [item] - ); + const url = useMemo(() => { + return getPrimaryImageUrl({ + api, + item, + width: 300, + }); + }, [item]); const [progress, setProgress] = useState( item.UserData?.PlayedPercentage || 0 @@ -59,6 +57,7 @@ const MoviePoster: React.FC = ({ width: "100%", }} /> + {showProgress && progress > 0 && ( diff --git a/components/posters/SeriesPoster.tsx b/components/posters/SeriesPoster.tsx index d235639f..dbadcdce 100644 --- a/components/posters/SeriesPoster.tsx +++ b/components/posters/SeriesPoster.tsx @@ -15,14 +15,16 @@ type MoviePosterProps = { const SeriesPoster: React.FC = ({ item }) => { const [api] = useAtom(apiAtom); - const url = useMemo( - () => - getPrimaryImageUrl({ - api, - item, - }), - [item] - ); + const url = useMemo(() => { + if (item.Type === "Episode") { + return `${api?.basePath}/Items/${item.SeriesId}/Images/Primary?fillHeight=389&quality=80&tag=${item.SeriesPrimaryImageTag}`; + } + return getPrimaryImageUrl({ + api, + item, + width: 300, + }); + }, [item]); const blurhash = useMemo(() => { const key = item.ImageTags?.["Primary"] as string; diff --git a/components/series/CastAndCrew.tsx b/components/series/CastAndCrew.tsx index ab041bd7..16f5e0d7 100644 --- a/components/series/CastAndCrew.tsx +++ b/components/series/CastAndCrew.tsx @@ -1,30 +1,31 @@ +import { apiAtom } from "@/providers/JellyfinProvider"; +import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; import { BaseItemDto, BaseItemPerson, } from "@jellyfin/sdk/lib/generated-client/models"; +import { router } from "expo-router"; +import { useAtom } from "jotai"; import React from "react"; -import { Linking, TouchableOpacity, View } from "react-native"; +import { TouchableOpacity, View, ViewProps } from "react-native"; import { HorizontalScroll } from "../common/HorrizontalScroll"; import { Text } from "../common/Text"; import Poster from "../posters/Poster"; -import { useAtom } from "jotai"; -import { apiAtom } from "@/providers/JellyfinProvider"; -import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; -import { router, usePathname } from "expo-router"; -import { useSettings } from "@/utils/atoms/settings"; -export const CastAndCrew = ({ item }: { item: BaseItemDto }) => { +interface Props extends ViewProps { + item?: BaseItemDto | null; + loading?: boolean; +} + +export const CastAndCrew: React.FC = ({ item, loading, ...props }) => { const [api] = useAtom(apiAtom); - const [settings] = useSettings(); - - const pathname = usePathname(); - return ( - + Cast & Crew - > - data={item.People} + ( { diff --git a/components/series/CurrentSeries.tsx b/components/series/CurrentSeries.tsx index a71dea8d..f45792d0 100644 --- a/components/series/CurrentSeries.tsx +++ b/components/series/CurrentSeries.tsx @@ -3,19 +3,23 @@ import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { router } from "expo-router"; import { useAtom } from "jotai"; import React from "react"; -import { TouchableOpacity, View } from "react-native"; +import { TouchableOpacity, View, ViewProps } from "react-native"; import Poster from "../posters/Poster"; import { HorizontalScroll } from "../common/HorrizontalScroll"; import { Text } from "../common/Text"; import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById"; -export const CurrentSeries = ({ item }: { item: BaseItemDto }) => { +interface Props extends ViewProps { + item?: BaseItemDto | null; +} + +export const CurrentSeries: React.FC = ({ item, ...props }) => { const [api] = useAtom(apiAtom); return ( - + Series - + ( = ({ item, ...props }) => { + const router = useRouter(); + + return ( + + {item?.Name} + + { + router.push( + // @ts-ignore + `/(auth)/series/${item.SeriesId}?seasonIndex=${item?.ParentIndexNumber}` + ); + }} + > + {item?.SeasonName} + + + {"—"} + {`Episode ${item.IndexNumber}`} + + {item?.ProductionYear} + + ); +}; diff --git a/components/series/NextEpisodeButton.tsx b/components/series/NextEpisodeButton.tsx index 709c9bfd..11140fdd 100644 --- a/components/series/NextEpisodeButton.tsx +++ b/components/series/NextEpisodeButton.tsx @@ -23,40 +23,6 @@ export const NextEpisodeButton: React.FC = ({ const [user] = useAtom(userAtom); const [api] = useAtom(apiAtom); - // const { data: seasons } = useQuery({ - // queryKey: ["seasons", item.SeriesId], - // queryFn: async () => { - // if ( - // !api || - // !user?.Id || - // !item?.Id || - // !item?.SeriesId || - // !item?.IndexNumber - // ) - // return []; - - // const response = await getItemsApi(api).getItems({ - // parentId: item?.SeriesId, - // }); - - // console.log("seasons ~", type, response.data); - - // return (response.data.Items as BaseItemDto[]) ?? []; - // }, - // enabled: Boolean(api && user?.Id && item?.Id && item.SeasonId), - // }); - - // const nextSeason = useMemo(() => { - // if (!seasons) return null; - // const currentSeasonIndex = seasons.findIndex( - // (season) => season.Id === item.SeasonId, - // ); - - // if (currentSeasonIndex === seasons.length - 1) return null; - - // return seasons[currentSeasonIndex + 1]; - // }, [seasons]); - const { data: nextEpisode } = useQuery({ queryKey: ["nextEpisode", item.Id, item.ParentId, type], queryFn: async () => { @@ -90,7 +56,7 @@ export const NextEpisodeButton: React.FC = ({ return (