From b1726962c181cb47be17215ec747287c34b304fd Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sun, 25 Aug 2024 14:25:26 +0200 Subject: [PATCH 01/46] fix: prepare dynamic render method for use of server config --- app/(auth)/(tabs)/(home)/index.tsx | 345 ++++++++++---------- components/home/ScrollingCollectionList.tsx | 26 +- components/medialists/MediaListSection.tsx | 24 +- 3 files changed, 217 insertions(+), 178 deletions(-) diff --git a/app/(auth)/(tabs)/(home)/index.tsx b/app/(auth)/(tabs)/(home)/index.tsx index b894a9d8..75ae50b8 100644 --- a/app/(auth)/(tabs)/(home)/index.tsx +++ b/app/(auth)/(tabs)/(home)/index.tsx @@ -22,6 +22,24 @@ import { useAtom } from "jotai"; import { useCallback, useEffect, useMemo, useState } from "react"; import { RefreshControl, ScrollView, View } from "react-native"; +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(); @@ -50,42 +68,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 +89,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 []; @@ -190,8 +112,17 @@ export default function index() { staleTime: 0, }); + 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 +137,125 @@ 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, + }) + ).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, + }) + ).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: "Suggestions", + queryKey: ["suggestions", user?.Id], + queryFn: async () => + ( + await getSuggestionsApi(api).getSuggestions({ + userId: user?.Id, + limit: 5, + mediaType: ["Video"], + }) + ).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. + // + // + // + // + // + // ); + // } + + if (e1 || e2) return ( Oops! @@ -239,13 +265,12 @@ export default function index() { ); - if (isLoading) + if (l1 || l2) return ( ); - return ( - - - - - {mediaListCollections?.map((ml) => ( - - ))} - - - - - - + {sections.map((section, index) => { + if (section.type === "ScrollingCollectionList") { + return ( + + ); + } else if (section.type === "MediaListSection") { + return ( + + ); + } + return null; + })} ); diff --git a/components/home/ScrollingCollectionList.tsx b/components/home/ScrollingCollectionList.tsx index 35b7dac1..df86bb88 100644 --- a/components/home/ScrollingCollectionList.tsx +++ b/components/home/ScrollingCollectionList.tsx @@ -6,26 +6,38 @@ 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"; 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: 0, + }); + + if (disabled || !title) return null; return ( @@ -35,7 +47,7 @@ export const ScrollingCollectionList: React.FC = ({ data={data} height={orientation === "vertical" ? 247 : 164} - loading={loading} + loading={isLoading} renderItem={(item, index) => ( ; } -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: 0, + }); + 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; From 8d327e8835aed91b5f1f58cf70f9fd5f8bd5110f Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sun, 25 Aug 2024 16:59:24 +0200 Subject: [PATCH 02/46] fix: design and posters --- app/(auth)/(tabs)/(home)/index.tsx | 6 +- .../(home,libraries,search)/items/[id].tsx | 21 +++--- components/Button.tsx | 4 +- components/ContinueWatchingPoster.tsx | 9 +-- components/SimilarItems.tsx | 15 +++-- components/home/ScrollingCollectionList.tsx | 15 +++-- components/posters/EpisodePoster.tsx | 64 +++++++++++++++++++ components/posters/MoviePoster.tsx | 19 +++--- components/posters/SeriesPoster.tsx | 18 +++--- components/series/CastAndCrew.tsx | 23 ++++--- components/series/CurrentSeries.tsx | 10 ++- components/series/NextUp.tsx | 2 +- 12 files changed, 140 insertions(+), 66 deletions(-) create mode 100644 components/posters/EpisodePoster.tsx diff --git a/app/(auth)/(tabs)/(home)/index.tsx b/app/(auth)/(tabs)/(home)/index.tsx index 75ae50b8..ac25bacb 100644 --- a/app/(auth)/(tabs)/(home)/index.tsx +++ b/app/(auth)/(tabs)/(home)/index.tsx @@ -109,7 +109,7 @@ export default function index() { return response.data.Items || []; }, enabled: !!api && !!user?.Id && settings?.usePopularPlugin === true, - staleTime: 0, + staleTime: 60 * 1000, }); const movieCollectionId = useMemo(() => { @@ -148,6 +148,7 @@ export default function index() { ( await getItemsApi(api).getResumeItems({ userId: user.Id, + enableImageTypes: ["Primary", "Backdrop", "Thumb"], }) ).data.Items || [], type: "ScrollingCollectionList", @@ -162,6 +163,7 @@ export default function index() { userId: user?.Id, fields: ["MediaSourceCount"], limit: 20, + enableImageTypes: ["Primary", "Backdrop", "Thumb"], }) ).data.Items || [], type: "ScrollingCollectionList", @@ -220,7 +222,7 @@ export default function index() { }) ).data.Items || [], type: "ScrollingCollectionList", - orientation: "horizontal", + orientation: "vertical", }, ]; return ss; diff --git a/app/(auth)/(tabs)/(home,libraries,search)/items/[id].tsx b/app/(auth)/(tabs)/(home,libraries,search)/items/[id].tsx index 84958a90..e62f619a 100644 --- a/app/(auth)/(tabs)/(home,libraries,search)/items/[id].tsx +++ b/app/(auth)/(tabs)/(home,libraries,search)/items/[id].tsx @@ -62,7 +62,7 @@ const page: React.FC = () => { itemId: id, }), enabled: !!id && !!api, - staleTime: 60, + staleTime: 60 * 1000, }); const { data: sessionData } = useQuery({ @@ -130,8 +130,8 @@ const page: React.FC = () => { getBackdropUrl({ api, item, - quality: 90, - width: 1000, + quality: 95, + width: 1200, }), [item] ); @@ -227,16 +227,11 @@ const page: React.FC = () => { - - - - {item.Type === "Episode" && ( - - - - )} - - + + + {item.Type === "Episode" && } + + 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/ContinueWatchingPoster.tsx b/components/ContinueWatchingPoster.tsx index e057ee15..20acb45b 100644 --- a/components/ContinueWatchingPoster.tsx +++ b/components/ContinueWatchingPoster.tsx @@ -6,6 +6,8 @@ import { useMemo, useState } from "react"; import { View } from "react-native"; import { WatchedIndicator } from "./WatchedIndicator"; import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; +import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl"; +import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById"; type ContinueWatchingPosterProps = { item: BaseItemDto; @@ -20,12 +22,7 @@ const ContinueWatchingPoster: React.FC = ({ const url = useMemo( () => - getPrimaryImageUrl({ - api, - item, - quality: 80, - width: 300, - }), + `${api?.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ParentThumbImageTag}`, [item] ); diff --git a/components/SimilarItems.tsx b/components/SimilarItems.tsx index 45624a52..f0d6ff40 100644 --- a/components/SimilarItems.tsx +++ b/components/SimilarItems.tsx @@ -6,16 +6,19 @@ 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 = { +interface SimilarItemsProps extends ViewProps { itemId: string; -}; +} -export const SimilarItems: React.FC = ({ itemId }) => { +export const SimilarItems: React.FC = ({ + itemId, + ...props +}) => { const [api] = useAtom(apiAtom); const [user] = useAtom(userAtom); @@ -41,8 +44,8 @@ export const SimilarItems: React.FC = ({ itemId }) => { ); return ( - - Similar items + + Similar items {isLoading ? ( diff --git a/components/home/ScrollingCollectionList.tsx b/components/home/ScrollingCollectionList.tsx index df86bb88..3a5f3dc5 100644 --- a/components/home/ScrollingCollectionList.tsx +++ b/components/home/ScrollingCollectionList.tsx @@ -11,6 +11,8 @@ import { useQuery, type QueryFunction, } from "@tanstack/react-query"; +import SeriesPoster from "../posters/SeriesPoster"; +import { EpisodePoster } from "../posters/EpisodePoster"; interface Props extends ViewProps { title?: string | null; @@ -34,7 +36,7 @@ export const ScrollingCollectionList: React.FC = ({ queryKey, queryFn, enabled: !disabled, - staleTime: 0, + staleTime: 60 * 1000, }); if (disabled || !title) return null; @@ -53,15 +55,18 @@ export const ScrollingCollectionList: React.FC = ({ key={index} item={item} className={`flex flex-col - ${orientation === "vertical" ? "w-28" : "w-44"} + ${orientation === "horizontal" ? "w-44" : "w-28"} `} > - {orientation === "vertical" ? ( - - ) : ( + {item.Type === "Episode" && orientation === "horizontal" && ( )} + {item.Type === "Episode" && orientation === "vertical" && ( + + )} + {item.Type === "Movie" && } + {item.Type === "Series" && } 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..ff9f3458 100644 --- a/components/posters/MoviePoster.tsx +++ b/components/posters/MoviePoster.tsx @@ -18,15 +18,16 @@ const MoviePoster: React.FC = ({ }) => { const [api] = useAtom(apiAtom); - const url = useMemo( - () => - getPrimaryImageUrl({ - api, - item, - width: 300, - }), - [item] - ); + const url = useMemo(() => { + if (item.Type === "Episode") { + return `${api?.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ParentThumbImageTag}`; + } + return getPrimaryImageUrl({ + api, + item, + width: 300, + }); + }, [item]); const [progress, setProgress] = useState( item.UserData?.PlayedPercentage || 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..d0406ce6 100644 --- a/components/series/CastAndCrew.tsx +++ b/components/series/CastAndCrew.tsx @@ -1,27 +1,26 @@ +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; +} + +export const CastAndCrew: React.FC = ({ item, ...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..8d06d2e7 100644 --- a/components/series/CurrentSeries.tsx +++ b/components/series/CurrentSeries.tsx @@ -3,17 +3,21 @@ 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; +} + +export const CurrentSeries: React.FC = ({ item, ...props }) => { const [api] = useAtom(apiAtom); return ( - + Series data={[item]} diff --git a/components/series/NextUp.tsx b/components/series/NextUp.tsx index e4ac462d..fa8558fb 100644 --- a/components/series/NextUp.tsx +++ b/components/series/NextUp.tsx @@ -34,7 +34,7 @@ export const NextUp: React.FC<{ seriesId: string }> = ({ seriesId }) => { if (!items?.length) return ( - + Next up No items to display From 30658ff067bb8e669472d4c25cc0312701034950 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sun, 25 Aug 2024 16:59:33 +0200 Subject: [PATCH 03/46] feat: show playback % in play button --- components/PlayButton.tsx | 49 ++++++++++++++++++++++++++++++++------- 1 file changed, 40 insertions(+), 9 deletions(-) diff --git a/components/PlayButton.tsx b/components/PlayButton.tsx index 2a539736..810724d2 100644 --- a/components/PlayButton.tsx +++ b/components/PlayButton.tsx @@ -2,13 +2,18 @@ import { usePlayback } from "@/providers/PlaybackProvider"; 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 { + BaseItemDto, + PlaybackInfoResponse, +} from "@jellyfin/sdk/lib/generated-client/models"; import { View } from "react-native"; import CastContext, { PlayServicesState, useRemoteMediaClient, } from "react-native-google-cast"; import { Button } from "./Button"; +import { Text } from "./common/Text"; +import { useMemo } from "react"; interface Props extends React.ComponentProps { item?: BaseItemDto | null; @@ -68,18 +73,44 @@ export const PlayButton: React.FC = ({ item, url, ...props }) => { ); }; + const playbackPercent = useMemo(() => { + if (!item || !item.RunTimeTicks) return 0; + const userData = item.UserData; + if (!userData) return 0; + const PlaybackPositionTicks = userData.PlaybackPositionTicks; + if (!PlaybackPositionTicks) return 0; + return (PlaybackPositionTicks / item.RunTimeTicks) * 100; + }, [item]); + return ( - + + + ); }; From 26057ed196d3e651ccdf771cf45d6dbd4883b4ad Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sun, 25 Aug 2024 16:59:53 +0200 Subject: [PATCH 04/46] feat: more commands --- components/CurrentlyPlayingBar.tsx | 4 + providers/PlaybackProvider.tsx | 101 ++++++++++++++++--------- utils/jellyfin/session/capabilities.ts | 18 ++++- 3 files changed, 82 insertions(+), 41 deletions(-) diff --git a/components/CurrentlyPlayingBar.tsx b/components/CurrentlyPlayingBar.tsx index a3276692..de8aa32b 100644 --- a/components/CurrentlyPlayingBar.tsx +++ b/components/CurrentlyPlayingBar.tsx @@ -26,6 +26,7 @@ export const CurrentlyPlayingBar: React.FC = () => { playVideo, setCurrentlyPlayingState, stopPlayback, + setVolume, setIsPlaying, isPlaying, videoRef, @@ -202,6 +203,9 @@ export const CurrentlyPlayingBar: React.FC = () => { setIsPlaying(false); } }} + onVolumeChange={(e) => { + setVolume(e.volume); + }} progressUpdateInterval={2000} onError={(e) => { console.log(e); diff --git a/providers/PlaybackProvider.tsx b/providers/PlaybackProvider.tsx index fd07f42a..39c1ebce 100644 --- a/providers/PlaybackProvider.tsx +++ b/providers/PlaybackProvider.tsx @@ -10,6 +10,7 @@ import React, { } from "react"; import { useSettings } from "@/utils/atoms/settings"; +import { getDeviceId } from "@/utils/device"; import { reportPlaybackProgress } from "@/utils/jellyfin/playstate/reportPlaybackProgress"; import { reportPlaybackStopped } from "@/utils/jellyfin/playstate/reportPlaybackStopped"; import { @@ -17,12 +18,12 @@ import { PlaybackInfoResponse, } from "@jellyfin/sdk/lib/generated-client/models"; import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api"; +import * as Linking from "expo-linking"; import { useAtom } from "jotai"; +import { Alert, Platform } from "react-native"; import { OnProgressData, type VideoRef } from "react-native-video"; import { apiAtom, userAtom } from "./JellyfinProvider"; -import { getDeviceId } from "@/utils/device"; -import * as Linking from "expo-linking"; -import { Platform } from "react-native"; +import { postCapabilities } from "@/utils/jellyfin/session/capabilities"; type CurrentlyPlayingState = { url: string; @@ -44,6 +45,7 @@ interface PlaybackContextType { setIsFullscreen: (isFullscreen: boolean) => void; setIsPlaying: (isPlaying: boolean) => void; onProgress: (data: OnProgressData) => void; + setVolume: (volume: number) => void; setCurrentlyPlayingState: ( currentlyPlaying: CurrentlyPlayingState | null ) => void; @@ -61,9 +63,13 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({ const [settings] = useSettings(); + const previousVolume = useRef(null); + const [isPlaying, setIsPlaying] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false); const [progressTicks, setProgressTicks] = useState(0); + const [volume, _setVolume] = useState(null); + const [session, setSession] = useState(null); const [currentlyPlaying, setCurrentlyPlaying] = useState(null); @@ -71,18 +77,14 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({ const [ws, setWs] = useState(null); const [isConnected, setIsConnected] = useState(false); - const { data: sessionData } = useQuery({ - queryKey: ["sessionData", currentlyPlaying?.item.Id, user?.Id, api], - queryFn: async () => { - if (!currentlyPlaying?.item.Id) return null; - const playbackData = await getMediaInfoApi(api!).getPlaybackInfo({ - itemId: currentlyPlaying?.item.Id, - userId: user?.Id, - }); - return playbackData.data; + const setVolume = useCallback( + (newVolume: number) => { + previousVolume.current = volume; + _setVolume(newVolume); + videoRef.current?.setVolume(newVolume); }, - enabled: !!currentlyPlaying?.item.Id && !!api && !!user?.Id, - }); + [_setVolume] + ); const { data: deviceId } = useQuery({ queryKey: ["deviceId", api], @@ -90,15 +92,29 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({ }); const setCurrentlyPlayingState = useCallback( - (state: CurrentlyPlayingState | null) => { - const vlcLink = "vlc://" + state?.url; - console.log(vlcLink, settings?.openInVLC, Platform.OS === "ios"); - if (vlcLink && settings?.openInVLC) { - Linking.openURL("vlc://" + state?.url || ""); - return; - } + async (state: CurrentlyPlayingState | null) => { + if (!api) return; - if (state) { + if (state && state.item.Id && user?.Id) { + const vlcLink = "vlc://" + state?.url; + if (vlcLink && settings?.openInVLC) { + console.log(vlcLink, settings?.openInVLC, Platform.OS === "ios"); + Linking.openURL("vlc://" + state?.url || ""); + return; + } + + const res = await getMediaInfoApi(api).getPlaybackInfo({ + itemId: state.item.Id, + userId: user.Id, + }); + + await postCapabilities({ + api, + itemId: state.item.Id, + sessionId: res.data.PlaySessionId, + }); + + setSession(res.data); setCurrentlyPlaying(state); setIsPlaying(true); @@ -113,7 +129,7 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({ setIsPlaying(false); } }, - [settings] + [settings, user, api] ); // Define control methods @@ -124,15 +140,10 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({ api, itemId: currentlyPlaying?.item.Id, positionTicks: progressTicks ? progressTicks : 0, - sessionId: sessionData?.PlaySessionId, + sessionId: session?.PlaySessionId, IsPaused: true, }); - }, [ - api, - currentlyPlaying?.item.Id, - sessionData?.PlaySessionId, - progressTicks, - ]); + }, [api, currentlyPlaying?.item.Id, session?.PlaySessionId, progressTicks]); const pauseVideo = useCallback(() => { videoRef.current?.pause(); @@ -141,20 +152,20 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({ api, itemId: currentlyPlaying?.item.Id, positionTicks: progressTicks ? progressTicks : 0, - sessionId: sessionData?.PlaySessionId, + sessionId: session?.PlaySessionId, IsPaused: false, }); - }, [sessionData?.PlaySessionId, currentlyPlaying?.item.Id, progressTicks]); + }, [session?.PlaySessionId, currentlyPlaying?.item.Id, progressTicks]); const stopPlayback = useCallback(async () => { await reportPlaybackStopped({ api, itemId: currentlyPlaying?.item?.Id, - sessionId: sessionData?.PlaySessionId, + sessionId: session?.PlaySessionId, positionTicks: progressTicks ? progressTicks : 0, }); setCurrentlyPlayingState(null); - }, [currentlyPlaying, sessionData, progressTicks]); + }, [currentlyPlaying, session, progressTicks]); const onProgress = useCallback( ({ currentTime }: OnProgressData) => { @@ -164,11 +175,11 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({ api, itemId: currentlyPlaying?.item.Id, positionTicks: ticks, - sessionId: sessionData?.PlaySessionId, + sessionId: session?.PlaySessionId, IsPaused: !isPlaying, }); }, - [sessionData?.PlaySessionId, currentlyPlaying?.item.Id, isPlaying] + [session?.PlaySessionId, currentlyPlaying?.item.Id, isPlaying] ); const presentFullscreenPlayer = useCallback(() => { @@ -236,6 +247,8 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({ const json = JSON.parse(e.data); const command = json?.Data?.Command; + console.log("[WS] ~ ", json); + // On PlayPause if (command === "PlayPause") { console.log("Command ~ PlayPause"); @@ -244,6 +257,19 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({ } else if (command === "Stop") { console.log("Command ~ Stop"); stopPlayback(); + } else if (command === "Mute") { + console.log("Command ~ Mute"); + setVolume(0); + } else if (command === "Unmute") { + console.log("Command ~ Unmute"); + setVolume(previousVolume.current || 20); + } else if (command === "SetVolume") { + console.log("Command ~ SetVolume"); + } else if (json?.Data?.Name === "DisplayMessage") { + console.log("Command ~ DisplayMessage"); + const title = json?.Data?.Arguments?.Header; + const body = json?.Data?.Arguments?.Text; + Alert.alert(title, body); } }; }, [ws, stopPlayback, playVideo, pauseVideo]); @@ -253,12 +279,13 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({ value={{ onProgress, progressTicks, + setVolume, setIsPlaying, setIsFullscreen, isFullscreen, isPlaying, currentlyPlaying, - sessionData, + sessionData: session, videoRef, playVideo, setCurrentlyPlayingState, diff --git a/utils/jellyfin/session/capabilities.ts b/utils/jellyfin/session/capabilities.ts index 7149adef..d76fbeb7 100644 --- a/utils/jellyfin/session/capabilities.ts +++ b/utils/jellyfin/session/capabilities.ts @@ -4,7 +4,7 @@ import { SessionApiPostCapabilitiesRequest, } from "@jellyfin/sdk/lib/generated-client/api/session-api"; import { getSessionApi } from "@jellyfin/sdk/lib/utils/api"; -import { AxiosError } from "axios"; +import { AxiosError, AxiosResponse } from "axios"; import { getAuthHeaders } from "../jellyfin"; interface PostCapabilitiesParams { @@ -23,17 +23,26 @@ export const postCapabilities = async ({ api, itemId, sessionId, -}: PostCapabilitiesParams): Promise => { +}: PostCapabilitiesParams): Promise => { if (!api || !itemId || !sessionId) { throw new Error("Missing required parameters"); } try { - const r = await api.axiosInstance.post( + const d = api.axiosInstance.post( api.basePath + "/Sessions/Capabilities/Full", { playableMediaTypes: ["Audio", "Video", "Audio"], - supportedCommands: ["PlayState", "Play"], + supportedCommands: [ + "PlayState", + "Play", + "ToggleFullscreen", + "DisplayMessage", + "Mute", + "Unmute", + "SetVolume", + "ToggleMute", + ], supportsMediaControl: true, id: sessionId, }, @@ -41,6 +50,7 @@ export const postCapabilities = async ({ headers: getAuthHeaders(api), } ); + return d; } catch (error: any | AxiosError) { console.log("Failed to mark as not played", error); throw new Error("Failed to mark as not played"); From 55df3991f5a9cf32aa3a36dc061dbdd4fd1d0c4a Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sun, 25 Aug 2024 16:59:58 +0200 Subject: [PATCH 05/46] chore --- app/(auth)/(tabs)/(libraries)/index.tsx | 2 +- app/(auth)/(tabs)/(search)/index.tsx | 11 +---------- components/home/LargeMovieCarousel.tsx | 4 ++-- components/medialists/MediaListSection.tsx | 2 +- 4 files changed, 5 insertions(+), 14 deletions(-) diff --git a/app/(auth)/(tabs)/(libraries)/index.tsx b/app/(auth)/(tabs)/(libraries)/index.tsx index 8897c516..dc26cb0a 100644 --- a/app/(auth)/(tabs)/(libraries)/index.tsx +++ b/app/(auth)/(tabs)/(libraries)/index.tsx @@ -34,7 +34,7 @@ export default function index() { return response.data.Items || null; }, enabled: !!api && !!user?.Id, - staleTime: 60 * 1000, + staleTime: 60 * 1000 * 60, }); useEffect(() => { diff --git a/app/(auth)/(tabs)/(search)/index.tsx b/app/(auth)/(tabs)/(search)/index.tsx index 9fadddbb..081ad670 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, 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/medialists/MediaListSection.tsx b/components/medialists/MediaListSection.tsx index 4ead09fb..93b7a5c0 100644 --- a/components/medialists/MediaListSection.tsx +++ b/components/medialists/MediaListSection.tsx @@ -34,7 +34,7 @@ export const MediaListSection: React.FC = ({ const { data: collection, isLoading } = useQuery({ queryKey, queryFn, - staleTime: 0, + staleTime: 60 * 1000, }); const fetchItems = useCallback( From 7792b8a675385518297405ddd8499203e23deae9 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sun, 25 Aug 2024 17:54:42 +0200 Subject: [PATCH 06/46] feat: auto login through url --- app/_layout.tsx | 17 ++++++++++++----- app/login.tsx | 33 +++++++++++++++++++++++++++++---- 2 files changed, 41 insertions(+), 9 deletions(-) diff --git a/app/_layout.tsx b/app/_layout.tsx index 249a6986..9d551286 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -9,7 +9,7 @@ import { DarkTheme, ThemeProvider } from "@react-navigation/native"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { useFonts } from "expo-font"; import { useKeepAwake } from "expo-keep-awake"; -import { Stack } from "expo-router"; +import { Stack, useRouter } from "expo-router"; import * as ScreenOrientation from "expo-screen-orientation"; import * as SplashScreen from "expo-splash-screen"; import { StatusBar } from "expo-status-bar"; @@ -17,13 +17,10 @@ import { Provider as JotaiProvider } from "jotai"; import { useEffect, useRef } from "react"; import { GestureHandlerRootView } from "react-native-gesture-handler"; import "react-native-reanimated"; +import * as Linking from "expo-linking"; SplashScreen.preventAutoHideAsync(); -export const unstable_settings = { - initialRouteName: "/(auth)/(tabs)/(home)/", -}; - export default function RootLayout() { const [loaded] = useFonts({ SpaceMono: require("../assets/fonts/SpaceMono-Regular.ttf"), @@ -74,6 +71,16 @@ function Layout() { ); }, [settings]); + const url = Linking.useURL(); + const router = useRouter(); + + if (url) { + const { hostname, path, queryParams } = Linking.parse(url); + console.log("Linking ~ ", hostname, path, queryParams); + // @ts-ignore + // router.push("/(auth)/(home)/"); + } + return ( diff --git a/app/login.tsx b/app/login.tsx index b5639ffd..2c35112d 100644 --- a/app/login.tsx +++ b/app/login.tsx @@ -4,8 +4,9 @@ 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, useMemo, useState } from "react"; import { Alert, KeyboardAvoidingView, @@ -23,17 +24,41 @@ const CredentialsSchema = z.object({ const Login: React.FC = () => { const { setServer, login, removeServer } = 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 () => { From 57cac96df56fedf0e5b66ca67f0ab9ea7e6b35a2 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sun, 25 Aug 2024 17:58:53 +0200 Subject: [PATCH 07/46] chore --- components/home/ScrollingCollectionList.tsx | 2 +- components/posters/MoviePoster.tsx | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/components/home/ScrollingCollectionList.tsx b/components/home/ScrollingCollectionList.tsx index 3a5f3dc5..502f54bf 100644 --- a/components/home/ScrollingCollectionList.tsx +++ b/components/home/ScrollingCollectionList.tsx @@ -66,7 +66,7 @@ export const ScrollingCollectionList: React.FC = ({ )} {item.Type === "Movie" && } - {item.Type === "Series" && } + {item.Type === "Series" && } diff --git a/components/posters/MoviePoster.tsx b/components/posters/MoviePoster.tsx index ff9f3458..5484ecb1 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; @@ -60,6 +60,7 @@ const MoviePoster: React.FC = ({ width: "100%", }} /> + {showProgress && progress > 0 && ( From 75f3f483eb7929060c792f8c07100c2d07608dc0 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sun, 25 Aug 2024 17:58:57 +0200 Subject: [PATCH 08/46] chore --- app.json | 9 +++++++-- components/ParallaxPage.tsx | 6 ++---- eas.json | 4 ++-- providers/JellyfinProvider.tsx | 2 +- 4 files changed, 12 insertions(+), 9 deletions(-) diff --git a/app.json b/app.json index d0fcd699..15412a9a 100644 --- a/app.json +++ b/app.json @@ -2,7 +2,7 @@ "expo": { "name": "Streamyfin", "slug": "streamyfin", - "version": "0.8.2", + "version": "0.8.3", "orientation": "default", "icon": "./assets/images/icon.png", "scheme": "streamyfin", @@ -33,7 +33,7 @@ }, "android": { "jsEngine": "hermes", - "versionCode": 23, + "versionCode": 24, "adaptiveIcon": { "foregroundImage": "./assets/images/icon.png" }, @@ -78,6 +78,11 @@ "deploymentTarget": "14.0" }, "android": { + "android": { + "compileSdkVersion": 34, + "targetSdkVersion": 34, + "buildToolsVersion": "34.0.0" + }, "minSdkVersion": 24, "usesCleartextTraffic": true, "packagingOptions": { diff --git a/components/ParallaxPage.tsx b/components/ParallaxPage.tsx index 4f955c6b..e970f1a3 100644 --- a/components/ParallaxPage.tsx +++ b/components/ParallaxPage.tsx @@ -1,7 +1,5 @@ -import { Ionicons } from "@expo/vector-icons"; -import { router } from "expo-router"; import type { PropsWithChildren, ReactElement } from "react"; -import { TouchableOpacity, View } from "react-native"; +import { View } from "react-native"; import Animated, { interpolate, useAnimatedRef, @@ -9,7 +7,6 @@ import Animated, { useScrollViewOffset, } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { Chromecast } from "./Chromecast"; const HEADER_HEIGHT = 400; @@ -75,6 +72,7 @@ export const ParallaxScrollView: React.FC = ({ > {headerImage} + {children} diff --git a/eas.json b/eas.json index db05e9f1..bf9876c7 100644 --- a/eas.json +++ b/eas.json @@ -21,13 +21,13 @@ } }, "production": { - "channel": "0.8.2", + "channel": "0.8.3", "android": { "image": "latest" } }, "production-apk": { - "channel": "0.8.2", + "channel": "0.8.3", "android": { "buildType": "apk", "image": "latest" diff --git a/providers/JellyfinProvider.tsx b/providers/JellyfinProvider.tsx index ec6a7e77..22ca5937 100644 --- a/providers/JellyfinProvider.tsx +++ b/providers/JellyfinProvider.tsx @@ -59,7 +59,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ setJellyfin( () => new Jellyfin({ - clientInfo: { name: "Streamyfin", version: "0.8.2" }, + clientInfo: { name: "Streamyfin", version: "0.8.3" }, deviceInfo: { name: Platform.OS === "ios" ? "iOS" : "Android", id }, }) ); From 7324fe826efab3285d9e84ab1073112ccfa00405 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sun, 25 Aug 2024 18:28:48 +0200 Subject: [PATCH 09/46] fix: button not pressable --- components/PlayButton.tsx | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/components/PlayButton.tsx b/components/PlayButton.tsx index 810724d2..1c8ef122 100644 --- a/components/PlayButton.tsx +++ b/components/PlayButton.tsx @@ -6,7 +6,7 @@ import { BaseItemDto, PlaybackInfoResponse, } from "@jellyfin/sdk/lib/generated-client/models"; -import { View } from "react-native"; +import { TouchableOpacity, View } from "react-native"; import CastContext, { PlayServicesState, useRemoteMediaClient, @@ -83,7 +83,7 @@ export const PlayButton: React.FC = ({ item, url, ...props }) => { }, [item]); return ( - + = ({ item, url, ...props }) => { }} className="absolute w-full h-full top-0 left-0 rounded-xl bg-purple-500 opacity-40" > - + {runtimeTicksToMinutes(item?.RunTimeTicks)} @@ -110,7 +110,6 @@ export const PlayButton: React.FC = ({ item, url, ...props }) => { {client && } - - + ); }; From 67af14dced2d1cff2f55c9f9c9e4961473119a45 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sun, 25 Aug 2024 20:47:54 +0200 Subject: [PATCH 10/46] chore --- app.json | 4 ++-- eas.json | 4 ++-- providers/JellyfinProvider.tsx | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app.json b/app.json index 15412a9a..f0220114 100644 --- a/app.json +++ b/app.json @@ -2,7 +2,7 @@ "expo": { "name": "Streamyfin", "slug": "streamyfin", - "version": "0.8.3", + "version": "0.8.4", "orientation": "default", "icon": "./assets/images/icon.png", "scheme": "streamyfin", @@ -33,7 +33,7 @@ }, "android": { "jsEngine": "hermes", - "versionCode": 24, + "versionCode": 25, "adaptiveIcon": { "foregroundImage": "./assets/images/icon.png" }, diff --git a/eas.json b/eas.json index bf9876c7..866f72e8 100644 --- a/eas.json +++ b/eas.json @@ -21,13 +21,13 @@ } }, "production": { - "channel": "0.8.3", + "channel": "0.8.4", "android": { "image": "latest" } }, "production-apk": { - "channel": "0.8.3", + "channel": "0.8.4", "android": { "buildType": "apk", "image": "latest" diff --git a/providers/JellyfinProvider.tsx b/providers/JellyfinProvider.tsx index 22ca5937..a1d2a84f 100644 --- a/providers/JellyfinProvider.tsx +++ b/providers/JellyfinProvider.tsx @@ -59,7 +59,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ setJellyfin( () => new Jellyfin({ - clientInfo: { name: "Streamyfin", version: "0.8.3" }, + clientInfo: { name: "Streamyfin", version: "0.8.4" }, deviceInfo: { name: Platform.OS === "ios" ? "iOS" : "Android", id }, }) ); From 9b2185d29e3ecfdd42c783969e3353d6f1d76bce Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sun, 25 Aug 2024 20:47:57 +0200 Subject: [PATCH 11/46] fix: posters --- components/ContinueWatchingPoster.tsx | 15 +++++++-------- components/home/ScrollingCollectionList.tsx | 7 ++++++- components/posters/MoviePoster.tsx | 3 --- 3 files changed, 13 insertions(+), 12 deletions(-) diff --git a/components/ContinueWatchingPoster.tsx b/components/ContinueWatchingPoster.tsx index 20acb45b..a28bc706 100644 --- a/components/ContinueWatchingPoster.tsx +++ b/components/ContinueWatchingPoster.tsx @@ -5,9 +5,6 @@ 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"; -import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl"; -import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById"; type ContinueWatchingPosterProps = { item: BaseItemDto; @@ -20,11 +17,13 @@ const ContinueWatchingPoster: React.FC = ({ }) => { const [api] = useAtom(apiAtom); - const url = useMemo( - () => - `${api?.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ParentThumbImageTag}`, - [item] - ); + const url = useMemo(() => { + if (!api) return; + if (item.Type === "Episode") + return `${api?.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ParentThumbImageTag}`; + if (item.Type === "Movie") + return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ImageTags?.["Thumb"]}`; + }, [item]); const [progress, setProgress] = useState( item.UserData?.PlayedPercentage || 0 diff --git a/components/home/ScrollingCollectionList.tsx b/components/home/ScrollingCollectionList.tsx index 502f54bf..32628690 100644 --- a/components/home/ScrollingCollectionList.tsx +++ b/components/home/ScrollingCollectionList.tsx @@ -65,7 +65,12 @@ export const ScrollingCollectionList: React.FC = ({ {item.Type === "Episode" && orientation === "vertical" && ( )} - {item.Type === "Movie" && } + {item.Type === "Movie" && orientation === "horizontal" && ( + + )} + {item.Type === "Movie" && orientation === "vertical" && ( + + )} {item.Type === "Series" && } diff --git a/components/posters/MoviePoster.tsx b/components/posters/MoviePoster.tsx index 5484ecb1..056e2c30 100644 --- a/components/posters/MoviePoster.tsx +++ b/components/posters/MoviePoster.tsx @@ -19,9 +19,6 @@ const MoviePoster: React.FC = ({ 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}`; - } return getPrimaryImageUrl({ api, item, From e8944528e4d9b93324913221e5e71f91066873c2 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sun, 25 Aug 2024 20:56:52 +0200 Subject: [PATCH 12/46] fix: failover for posters --- components/ContinueWatchingPoster.tsx | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/components/ContinueWatchingPoster.tsx b/components/ContinueWatchingPoster.tsx index a28bc706..31b3f5ea 100644 --- a/components/ContinueWatchingPoster.tsx +++ b/components/ContinueWatchingPoster.tsx @@ -17,12 +17,23 @@ const ContinueWatchingPoster: React.FC = ({ }) => { const [api] = useAtom(apiAtom); + /** + * Get horrizontal poster for movie and episode, with failover to primary. + */ const url = useMemo(() => { if (!api) return; - if (item.Type === "Episode") - return `${api?.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ParentThumbImageTag}`; - if (item.Type === "Movie") - return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ImageTags?.["Thumb"]}`; + 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( From c0f45875018d532a2c875c02b3988b528a9cf744 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sun, 25 Aug 2024 21:12:20 +0200 Subject: [PATCH 13/46] fix: select season 1 --- components/series/SeasonPicker.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/components/series/SeasonPicker.tsx b/components/series/SeasonPicker.tsx index 9cef17a3..7f85e0f3 100644 --- a/components/series/SeasonPicker.tsx +++ b/components/series/SeasonPicker.tsx @@ -57,7 +57,10 @@ export const SeasonPicker: React.FC = ({ item }) => { useEffect(() => { if (seasons && seasons.length > 0 && seasonIndex === undefined) { - const firstSeason = seasons[0]; + const season1 = seasons.find((season: any) => season.IndexNumber === 1); + const season0 = seasons.find((season: any) => season.IndexNumber === 0); + const firstSeason = season1 || season0 || seasons[0]; + if (firstSeason.IndexNumber !== undefined) { setSeasonIndexState((prev) => ({ ...prev, @@ -66,6 +69,7 @@ export const SeasonPicker: React.FC = ({ item }) => { } } }, [seasons, seasonIndex, setSeasonIndexState, item.Id]); + const selectedSeasonId: string | null = useMemo( () => seasons?.find((season: any) => season.IndexNumber === seasonIndex)?.Id, From 969e68901ac1adfd3b84b4e4293cd7d3661537dc Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sun, 25 Aug 2024 22:21:42 +0200 Subject: [PATCH 14/46] fix: play flickering bug --- components/CurrentlyPlayingBar.tsx | 9 ++------- providers/PlaybackProvider.tsx | 10 +++++++++- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/components/CurrentlyPlayingBar.tsx b/components/CurrentlyPlayingBar.tsx index de8aa32b..25964c16 100644 --- a/components/CurrentlyPlayingBar.tsx +++ b/components/CurrentlyPlayingBar.tsx @@ -17,6 +17,7 @@ import Animated, { import Video from "react-native-video"; import { Text } from "./common/Text"; import { Loader } from "./Loader"; +import { debounce } from "lodash"; export const CurrentlyPlayingBar: React.FC = () => { const segments = useSegments(); @@ -195,13 +196,7 @@ export const CurrentlyPlayingBar: React.FC = () => { onFullscreenPlayerDidDismiss={() => {}} onFullscreenPlayerDidPresent={() => {}} onPlaybackStateChanged={(e) => { - if (e.isPlaying) { - setIsPlaying(true); - } else if (e.isSeeking) { - return; - } else { - setIsPlaying(false); - } + setIsPlaying(e.isPlaying); }} onVolumeChange={(e) => { setVolume(e.volume); diff --git a/providers/PlaybackProvider.tsx b/providers/PlaybackProvider.tsx index 39c1ebce..1f10909e 100644 --- a/providers/PlaybackProvider.tsx +++ b/providers/PlaybackProvider.tsx @@ -24,6 +24,7 @@ import { Alert, Platform } from "react-native"; import { OnProgressData, type VideoRef } from "react-native-video"; import { apiAtom, userAtom } from "./JellyfinProvider"; import { postCapabilities } from "@/utils/jellyfin/session/capabilities"; +import { debounce } from "lodash"; type CurrentlyPlayingState = { url: string; @@ -65,7 +66,7 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({ const previousVolume = useRef(null); - const [isPlaying, setIsPlaying] = useState(false); + const [isPlaying, _setIsPlaying] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false); const [progressTicks, setProgressTicks] = useState(0); const [volume, _setVolume] = useState(null); @@ -167,6 +168,13 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({ setCurrentlyPlayingState(null); }, [currentlyPlaying, session, progressTicks]); + const setIsPlaying = useCallback( + debounce((value: boolean) => { + _setIsPlaying(value); + }, 100), + [] + ); + const onProgress = useCallback( ({ currentTime }: OnProgressData) => { const ticks = currentTime * 10000000; From a351c8d220e99727b68cc08e785d70c598e2af54 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Mon, 26 Aug 2024 00:02:45 +0200 Subject: [PATCH 15/46] fix: trying to fix playback issues --- bun.lockb | Bin 582550 -> 582914 bytes components/CurrentlyPlayingBar.tsx | 59 ++++++------ package.json | 10 +-- providers/PlaybackProvider.tsx | 84 +++++++++++------- .../playstate/reportPlaybackProgress.ts | 6 -- 5 files changed, 90 insertions(+), 69 deletions(-) diff --git a/bun.lockb b/bun.lockb index 32f4452c81f35727d93d320e19a4bd2acf8b2cc8..b8333b9f8cb1f51a1c738f62f476498a374e9252 100755 GIT binary patch delta 22215 zcmeHvcUTl>+x^bMY*Dcn5D$U9#$pBKCp>6-yR- zFKDpWBqmYgt7wd3N$md4*_n-g@AZD~_5JnxD|>l3&wZYIo^n5BXJ^J)c%t0IQ|0Cb zR_XfuNY?e*>xO?4`&qu>;lF*dWljE$11GhgX8E-L&_!xf%kbYU27Wpxb@nrkZJsf@ zg`YjXbfo>DpJ$CrT@^zfgCV|rH^pEDN5sSoi3lIjzh6xBsF6`aMwEok{5#h!``@b#bT9uD~1ve~~`dBzPk8l3)pz3&UPqU#qxyUB%!7dl~$k4Fj`bEv9N5KIkiF$dABgLkEP97#T2XcvL;u zc@1%f_^L>-z-jt{zKL2(BXAgdkO7;KNCvYZcc*JTy`pm#*aP}LFe~mG6EirfzripW z{;V*1cyv@W?5vquJv+d~ps$>T{4gYzKwya5&(;c92IherbvC`u9PNMrFe{i2fA;h% zokyagg0LeK?H4N4FLXIcn*m3`91(}TO2ul*Q+wBneqPD~t!UKXEMg^W9{S3jRMF4c zNVJ};v+t@{sZg0k+CbRR1V8wEZ_lmhS7uU*Hhz60!-ot;PBd)Ji})p4
dDwT+< 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 delta 22716 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 { [currentlyPlaying?.item, api] ); + const videoSource = useMemo(() => { + if (!api || !currentlyPlaying || !backdropUrl) 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: backdropUrl, + subtitle: currentlyPlaying.item?.Album + ? currentlyPlaying.item?.Album + : undefined, + }, + }; + }, [currentlyPlaying, startPosition, api, backdropUrl]); + if (!api || !currentlyPlaying) return null; return ( @@ -139,7 +162,7 @@ export const CurrentlyPlayingBar: React.FC = () => { } `} > - {currentlyPlaying?.url && ( + {videoSource && (
); - if (!item?.Id || !backdropUrl) return null; + if (!item?.Id) return null; return ( + <> + {itemBackdropUrl ? ( + + ) : null} + } logo={ <> diff --git a/components/ContinueWatchingPoster.tsx b/components/ContinueWatchingPoster.tsx index 31b3f5ea..c93ec275 100644 --- a/components/ContinueWatchingPoster.tsx +++ b/components/ContinueWatchingPoster.tsx @@ -9,11 +9,13 @@ import { WatchedIndicator } from "./WatchedIndicator"; type ContinueWatchingPosterProps = { item: BaseItemDto; width?: number; + useEpisodePoster?: boolean; }; const ContinueWatchingPoster: React.FC = ({ item, width = 176, + useEpisodePoster = false, }) => { const [api] = useAtom(apiAtom); @@ -22,6 +24,9 @@ const ContinueWatchingPoster: React.FC = ({ */ 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}`; diff --git a/components/ParallaxPage.tsx b/components/ParallaxPage.tsx index e970f1a3..595de780 100644 --- a/components/ParallaxPage.tsx +++ b/components/ParallaxPage.tsx @@ -1,4 +1,4 @@ -import type { PropsWithChildren, ReactElement } from "react"; +import { useMemo, type PropsWithChildren, type ReactElement } from "react"; import { View } from "react-native"; import Animated, { interpolate, @@ -8,16 +8,18 @@ import Animated, { } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -const HEADER_HEIGHT = 400; - type Props = PropsWithChildren<{ headerImage: ReactElement; logo?: ReactElement; + episodePoster?: ReactElement; + headerHeight?: number; }>; export const ParallaxScrollView: React.FC = ({ children, headerImage, + episodePoster, + headerHeight = 400, logo, }: Props) => { const scrollRef = useAnimatedRef(); @@ -29,14 +31,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] ), }, @@ -56,15 +58,29 @@ export const ParallaxScrollView: React.FC = ({ scrollEventThrottle={16} > {logo && ( - + {logo} )} + {episodePoster && ( + + + {episodePoster} + + + )} + = ({ seriesId }) => { key={item.Id} className="flex flex-col w-44" > - + )} diff --git a/components/series/SeasonPicker.tsx b/components/series/SeasonPicker.tsx index 7f85e0f3..ef6ce3de 100644 --- a/components/series/SeasonPicker.tsx +++ b/components/series/SeasonPicker.tsx @@ -188,7 +188,11 @@ export const SeasonPicker: React.FC = ({ item }) => { > - + diff --git a/utils/jellyfin/image/getBackdropUrl.ts b/utils/jellyfin/image/getBackdropUrl.ts index 7fdf7efe..dd138aab 100644 --- a/utils/jellyfin/image/getBackdropUrl.ts +++ b/utils/jellyfin/image/getBackdropUrl.ts @@ -36,6 +36,10 @@ export const getBackdropUrl = ({ params.append("fillWidth", width.toString()); } + if (item.Type === "Episode") { + return getPrimaryImageUrl({ api, item, quality, width }); + } + if (backdropImageTags) { params.append("tag", backdropImageTags); return `${api.basePath}/Items/${ diff --git a/utils/jellyfin/image/getLogoImageUrlById.ts b/utils/jellyfin/image/getLogoImageUrlById.ts index a801b1b7..3712b888 100644 --- a/utils/jellyfin/image/getLogoImageUrlById.ts +++ b/utils/jellyfin/image/getLogoImageUrlById.ts @@ -21,15 +21,29 @@ export const getLogoImageUrlById = ({ return null; } - const imageTags = item.ImageTags?.["Logo"]; - - if (!imageTags) return null; - const params = new URLSearchParams(); - params.append("tag", imageTags); params.append("quality", "90"); params.append("fillHeight", height.toString()); + if (item.Type === "Episode") { + const imageTag = item.ParentLogoImageTag; + const parentId = item.ParentLogoItemId; + + if (!parentId || !imageTag) { + return null; + } + + params.append("tag", imageTag); + + return `${api.basePath}/Items/${parentId}/Images/Logo?${params.toString()}`; + } + + const imageTag = item.ImageTags?.["Logo"]; + + if (!imageTag) return null; + + params.append("tag", imageTag); + return `${api.basePath}/Items/${item.Id}/Images/Logo?${params.toString()}`; }; diff --git a/utils/jellyfin/image/getParentBackdropImageUrl.ts b/utils/jellyfin/image/getParentBackdropImageUrl.ts new file mode 100644 index 00000000..4a03795b --- /dev/null +++ b/utils/jellyfin/image/getParentBackdropImageUrl.ts @@ -0,0 +1,42 @@ +import { Api } from "@jellyfin/sdk"; +import { + BaseItemDto, + BaseItemPerson, +} from "@jellyfin/sdk/lib/generated-client/models"; +import { isBaseItemDto } from "../jellyfin"; + +/** + * Retrieves the primary image URL for a given item. + * + * @param api - The Jellyfin API instance. + * @param item - The media item to retrieve the backdrop image URL for. + * @param quality - The desired image quality (default: 90). + */ +export const getParentBackdropImageUrl = ({ + api, + item, + quality = 80, + width = 400, +}: { + api?: Api | null; + item?: BaseItemDto | null; + quality?: number | null; + width?: number | null; +}) => { + if (!item || !api) { + return null; + } + + const parentId = item.ParentBackdropItemId; + const tag = item.ParentBackdropImageTags?.[0] || ""; + + const params = new URLSearchParams({ + fillWidth: width ? String(width) : "500", + quality: quality ? String(quality) : "80", + tag: tag, + }); + + return `${ + api?.basePath + }/Items/${parentId}/Images/Backdrop/0?${params.toString()}`; +}; diff --git a/utils/jellyfin/image/getPrimaryParentImageUrl.ts b/utils/jellyfin/image/getPrimaryParentImageUrl.ts new file mode 100644 index 00000000..ff862624 --- /dev/null +++ b/utils/jellyfin/image/getPrimaryParentImageUrl.ts @@ -0,0 +1,42 @@ +import { Api } from "@jellyfin/sdk"; +import { + BaseItemDto, + BaseItemPerson, +} from "@jellyfin/sdk/lib/generated-client/models"; +import { isBaseItemDto } from "../jellyfin"; + +/** + * Retrieves the primary image URL for a given item. + * + * @param api - The Jellyfin API instance. + * @param item - The media item to retrieve the backdrop image URL for. + * @param quality - The desired image quality (default: 90). + */ +export const getPrimaryParentImageUrl = ({ + api, + item, + quality = 80, + width = 400, +}: { + api?: Api | null; + item?: BaseItemDto | null; + quality?: number | null; + width?: number | null; +}) => { + if (!item || !api) { + return null; + } + + const parentId = item.ParentId; + const primaryTag = item.ParentPrimaryImageTag?.[0]; + + const params = new URLSearchParams({ + fillWidth: width ? String(width) : "500", + quality: quality ? String(quality) : "80", + tag: primaryTag || "", + }); + + return `${ + api?.basePath + }/Items/${parentId}/Images/Primary?${params.toString()}`; +}; From aa60e320c5edf1e25f834ad13dcb70d24e90d5ea Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Mon, 26 Aug 2024 08:49:12 +0200 Subject: [PATCH 18/46] feat: split episodes and movies --- app/(auth)/(tabs)/(home)/index.tsx | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/app/(auth)/(tabs)/(home)/index.tsx b/app/(auth)/(tabs)/(home)/index.tsx index fc78cc50..749253f0 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, @@ -41,7 +39,6 @@ type MediaListSection = BaseSection & { type Section = ScrollingCollectionListSection | MediaListSection; export default function index() { - const router = useRouter(); const queryClient = useQueryClient(); const [api] = useAtom(apiAtom); @@ -211,14 +208,30 @@ export default function index() { type: "ScrollingCollectionList", }, { - title: "Suggestions", - queryKey: ["suggestions", user?.Id], + title: "Suggested Movies", + queryKey: ["suggestedMovies", user?.Id], queryFn: async () => ( await getSuggestionsApi(api).getSuggestions({ userId: user?.Id, - limit: 5, + 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", From 07c5c2159988722ae20da484faa0900f894dcc72 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Mon, 26 Aug 2024 08:49:42 +0200 Subject: [PATCH 19/46] fix: click on season number should take you to season fixes #110 --- .../(home,libraries,search)/series/[id].tsx | 4 ++- components/series/SeasonPicker.tsx | 32 +++++++++++++++---- components/series/SeriesTitleHeader.tsx | 9 +++++- 3 files changed, 36 insertions(+), 9 deletions(-) diff --git a/app/(auth)/(tabs)/(home,libraries,search)/series/[id].tsx b/app/(auth)/(tabs)/(home,libraries,search)/series/[id].tsx index c7c99103..4639932f 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); @@ -95,7 +97,7 @@ const page: React.FC = () => { - + ); diff --git a/components/series/SeasonPicker.tsx b/components/series/SeasonPicker.tsx index ef6ce3de..ad3af059 100644 --- a/components/series/SeasonPicker.tsx +++ b/components/series/SeasonPicker.tsx @@ -14,6 +14,7 @@ import { Text } from "../common/Text"; type Props = { item: BaseItemDto; + initialSeasonIndex?: number; }; type SeasonIndexState = { @@ -22,7 +23,7 @@ type SeasonIndexState = { export const seasonIndexAtom = atom({}); -export const SeasonPicker: React.FC = ({ item }) => { +export const SeasonPicker: React.FC = ({ item, initialSeasonIndex }) => { const [api] = useAtom(apiAtom); const [user] = useAtom(userAtom); const [seasonIndexState, setSeasonIndexState] = useAtom(seasonIndexAtom); @@ -57,18 +58,35 @@ export const SeasonPicker: React.FC = ({ item }) => { useEffect(() => { if (seasons && seasons.length > 0 && seasonIndex === undefined) { - const season1 = seasons.find((season: any) => season.IndexNumber === 1); - const season0 = seasons.find((season: any) => season.IndexNumber === 0); - const firstSeason = season1 || season0 || seasons[0]; + let initialIndex: number | undefined; - if (firstSeason.IndexNumber !== undefined) { + console.log("initialSeasonIndex", initialSeasonIndex); + if (initialSeasonIndex !== undefined) { + // Use the provided initialSeasonIndex if it exists in the seasons + const seasonExists = seasons.some( + (season: any) => season.IndexNumber === initialSeasonIndex + ); + if (seasonExists) { + initialIndex = initialSeasonIndex; + } + } + + if (initialIndex === undefined) { + // Fall back to the previous logic if initialIndex is not set + const season1 = seasons.find((season: any) => season.IndexNumber === 1); + const season0 = seasons.find((season: any) => season.IndexNumber === 0); + const firstSeason = season1 || season0 || seasons[0]; + initialIndex = firstSeason.IndexNumber; + } + + if (initialIndex !== undefined) { setSeasonIndexState((prev) => ({ ...prev, - [item.Id ?? ""]: firstSeason.IndexNumber, + [item.Id ?? ""]: initialIndex, })); } } - }, [seasons, seasonIndex, setSeasonIndexState, item.Id]); + }, [seasons, seasonIndex, setSeasonIndexState, item.Id, initialSeasonIndex]); const selectedSeasonId: string | null = useMemo( () => diff --git a/components/series/SeriesTitleHeader.tsx b/components/series/SeriesTitleHeader.tsx index 16e05ae2..c93e7081 100644 --- a/components/series/SeriesTitleHeader.tsx +++ b/components/series/SeriesTitleHeader.tsx @@ -23,7 +23,14 @@ export const SeriesTitleHeader: React.FC = ({ item, ...props }) => {
- {}}> + { + router.push( + // @ts-ignore + `/(auth)/series/${item.SeriesId}?seasonIndex=${item?.ParentIndexNumber}` + ); + }} + > {item?.SeasonName} {"—"} From 3047367ba67a969834ee1cb61875dac91a1551e9 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Mon, 26 Aug 2024 19:47:02 +0200 Subject: [PATCH 20/46] fix: better posters and item screen --- app/(auth)/(tabs)/(home)/downloads.tsx | 10 +- .../(home,libraries,search)/items/page.tsx | 13 ++ .../(home,libraries,search)/series/[id].tsx | 1 + app/(auth)/(tabs)/(search)/index.tsx | 18 +- components/AudioTrackSelector.tsx | 2 +- components/BitrateSelector.tsx | 2 +- components/CurrentlyPlayingBar.tsx | 3 +- .../[id].tsx => components/ItemContent.tsx | 187 ++++++++---------- components/ItemHeader.tsx | 30 +++ components/OverviewText.tsx | 23 +-- components/ParallaxPage.tsx | 5 +- components/PlayButton.tsx | 7 +- components/Ratings.tsx | 3 +- components/SimilarItems.tsx | 10 +- components/SubtitleTrackSelector.tsx | 2 +- components/common/HorrizontalScroll.tsx | 114 ++++++----- components/common/ItemImage.tsx | 101 ++++++++++ components/common/TouchableItemRouter.tsx | 2 +- components/home/ScrollingCollectionList.tsx | 2 +- components/movies/MoviesTitleHeader.tsx | 9 +- components/series/CastAndCrew.tsx | 12 +- components/series/CurrentSeries.tsx | 4 +- components/series/EpisodeTitleHeader.tsx | 40 ++++ components/series/NextEpisodeButton.tsx | 36 +--- components/series/NextUp.tsx | 4 +- components/series/SeasonEpisodesCarousel.tsx | 146 ++++++++++++++ components/series/SeasonPicker.tsx | 4 +- components/series/SeriesTitleHeader.tsx | 44 ----- components/stacks/NestedTabPageStack.tsx | 2 +- 29 files changed, 534 insertions(+), 302 deletions(-) create mode 100644 app/(auth)/(tabs)/(home,libraries,search)/items/page.tsx rename app/(auth)/(tabs)/(home,libraries,search)/items/[id].tsx => components/ItemContent.tsx (58%) create mode 100644 components/ItemHeader.tsx create mode 100644 components/common/ItemImage.tsx create mode 100644 components/series/EpisodeTitleHeader.tsx create mode 100644 components/series/SeasonEpisodesCarousel.tsx delete mode 100644 components/series/SeriesTitleHeader.tsx diff --git a/app/(auth)/(tabs)/(home)/downloads.tsx b/app/(auth)/(tabs)/(home)/downloads.tsx index c187c68b..69467450 100644 --- a/app/(auth)/(tabs)/(home)/downloads.tsx +++ b/app/(auth)/(tabs)/(home)/downloads.tsx @@ -8,7 +8,7 @@ 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"; @@ -70,7 +70,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 +99,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,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 4639932f..15ae2870 100644 --- a/app/(auth)/(tabs)/(home,libraries,search)/series/[id].tsx +++ b/app/(auth)/(tabs)/(home,libraries,search)/series/[id].tsx @@ -61,6 +61,7 @@ const page: React.FC = () => { return ( 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" > @@ -312,7 +312,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) => ( - + ( = ({ - + Audio streams diff --git a/components/BitrateSelector.tsx b/components/BitrateSelector.tsx index e38df8bb..43df6fc5 100644 --- a/components/BitrateSelector.tsx +++ b/components/BitrateSelector.tsx @@ -54,7 +54,7 @@ export const BitrateSelector: React.FC = ({ - + Bitrate diff --git a/components/CurrentlyPlayingBar.tsx b/components/CurrentlyPlayingBar.tsx index a16a4369..9f7c5adb 100644 --- a/components/CurrentlyPlayingBar.tsx +++ b/components/CurrentlyPlayingBar.tsx @@ -231,7 +231,8 @@ export const CurrentlyPlayingBar: React.FC = () => { onPress={() => { if (currentlyPlaying.item?.Type === "Audio") router.push(`/albums/${currentlyPlaying.item?.AlbumId}`); - else router.push(`/items/${currentlyPlaying.item?.Id}`); + else + router.push(`/items/page?id=${currentlyPlaying.item?.Id}`); }} > {currentlyPlaying.item?.Name} diff --git a/app/(auth)/(tabs)/(home,libraries,search)/items/[id].tsx b/components/ItemContent.tsx similarity index 58% rename from app/(auth)/(tabs)/(home,libraries,search)/items/[id].tsx rename to components/ItemContent.tsx index 0a517ff0..ecc0b109 100644 --- a/app/(auth)/(tabs)/(home,libraries,search)/items/[id].tsx +++ b/components/ItemContent.tsx @@ -1,7 +1,6 @@ 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"; @@ -9,18 +8,16 @@ import { PlayedStatus } from "@/components/PlayedStatus"; import { Ratings } from "@/components/Ratings"; import { SimilarItems } from "@/components/SimilarItems"; import { SubtitleTrackSelector } from "@/components/SubtitleTrackSelector"; +import { ItemImage } from "@/components/common/ItemImage"; 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 { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarousel"; +import { EpisodeTitleHeader } from "@/components/series/EpisodeTitleHeader"; 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 { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl"; -import { getPrimaryParentImageUrl } from "@/utils/jellyfin/image/getPrimaryParentImageUrl"; import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl"; import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData"; import { chromecastProfile } from "@/utils/profiles/chromecast"; @@ -30,21 +27,17 @@ 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 React, { useEffect, useMemo, useState } from "react"; import { View } from "react-native"; import { useCastDevice } from "react-native-google-cast"; +import { ItemHeader } from "./ItemHeader"; -const page: React.FC = () => { - const local = useLocalSearchParams(); - const { id } = local as { id: string }; - +export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => { const [api] = useAtom(apiAtom); const [user] = useAtom(userAtom); const [settings] = useSettings(); - const castDevice = useCastDevice(); const [selectedAudioStream, setSelectedAudioStream] = useState(-1); @@ -55,7 +48,11 @@ const page: React.FC = () => { value: undefined, }); - const { data: item, isLoading: l1 } = useQuery({ + const { + data: item, + isLoading, + isFetching, + } = useQuery({ queryKey: ["item", id], queryFn: async () => await getUserItemData({ @@ -127,63 +124,36 @@ const page: React.FC = () => { staleTime: 0, }); - const itemBackdropUrl = useMemo( - () => - getBackdropUrl({ - api, - item, - quality: 95, - width: 1200, - }), - [item] - ); - - const seriesBackdropUrl = useMemo( - () => - getParentBackdropImageUrl({ - api, - item, - quality: 95, - width: 1200, - }), - [item] - ); - const logoUrl = useMemo(() => getLogoImageUrlById({ api, item }), [item]); - const episodePoster = useMemo( - () => - item?.Type === "Episode" - ? `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80` - : null, - [item] + const loading = useMemo( + () => isLoading || isFetching, + [isLoading, isFetching] ); - if (l1) - return ( - - - - ); - - if (!item?.Id) return null; - return ( - {itemBackdropUrl ? ( - - ) : null} + ) : ( + + )} } logo={ @@ -203,62 +173,65 @@ const page: React.FC = () => { } > - - - {item.Type === "Episode" ? ( - + + + + + {item ? ( + + + + ) : ( - <> - - + + + )} - {item?.ProductionYear} - - - - {playbackUrl ? ( - + {item ? ( + + setMaxBitrate(val)} + selected={maxBitrate} + /> + {item && ( + + )} + {item && ( + + )} + ) : ( - + + + + )} - + + - - - - - setMaxBitrate(val)} - selected={maxBitrate} - /> - - - - - - - - - - - - {item.Type === "Episode" && } - - + - + + + + + {item?.Type === "Episode" && ( + + )} + + + + ); -}; - -export default page; +}); diff --git a/components/ItemHeader.tsx b/components/ItemHeader.tsx new file mode 100644 index 00000000..d4c204b1 --- /dev/null +++ b/components/ItemHeader.tsx @@ -0,0 +1,30 @@ +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/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 595de780..643f6afc 100644 --- a/components/ParallaxPage.tsx +++ b/components/ParallaxPage.tsx @@ -1,4 +1,4 @@ -import { useMemo, type PropsWithChildren, type ReactElement } from "react"; +import { type PropsWithChildren, type ReactElement } from "react"; import { View } from "react-native"; import Animated, { interpolate, @@ -6,7 +6,6 @@ import Animated, { useAnimatedStyle, useScrollViewOffset, } from "react-native-reanimated"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; type Props = PropsWithChildren<{ headerImage: ReactElement; @@ -46,8 +45,6 @@ export const ParallaxScrollView: React.FC = ({ }; }); - const inset = useSafeAreaInsets(); - return ( { item?: BaseItemDto | null; diff --git a/components/Ratings.tsx b/components/Ratings.tsx index f3d73168..65834fdf 100644 --- a/components/Ratings.tsx +++ b/components/Ratings.tsx @@ -5,10 +5,11 @@ 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 }) => { + if (!item) return null; return ( {item.OfficialRating && ( diff --git a/components/SimilarItems.tsx b/components/SimilarItems.tsx index f0d6ff40..6777a8d9 100644 --- a/components/SimilarItems.tsx +++ b/components/SimilarItems.tsx @@ -12,7 +12,7 @@ import { ItemCardText } from "./ItemCardText"; import { Loader } from "./Loader"; interface SimilarItemsProps extends ViewProps { - itemId: string; + itemId?: string | null; } export const SimilarItems: React.FC = ({ @@ -25,7 +25,7 @@ export const SimilarItems: React.FC = ({ 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, @@ -56,7 +56,7 @@ export const SimilarItems: React.FC = ({ {movies.map((item) => ( router.push(`/items/${item.Id}`)} + onPress={() => router.push(`/items/page?id=${item.Id}`)} className="flex flex-col w-32" > @@ -66,7 +66,9 @@ export const SimilarItems: React.FC = ({ )} - {movies.length === 0 && No similar items} + {movies.length === 0 && ( + No similar items + )} ); }; diff --git a/components/SubtitleTrackSelector.tsx b/components/SubtitleTrackSelector.tsx index 35918de1..a445ffeb 100644 --- a/components/SubtitleTrackSelector.tsx +++ b/components/SubtitleTrackSelector.tsx @@ -47,7 +47,7 @@ export const SubtitleTrackSelector: React.FC = ({ - + Subtitles diff --git a/components/common/HorrizontalScroll.tsx b/components/common/HorrizontalScroll.tsx index eea84aa3..a1015b37 100644 --- a/components/common/HorrizontalScroll.tsx +++ b/components/common/HorrizontalScroll.tsx @@ -1,16 +1,14 @@ import { FlashList, FlashListProps } from "@shopify/flash-list"; -import React, { useEffect } from "react"; +import React, { forwardRef, useImperativeHandle, useRef } from "react"; import { View, ViewStyle } from "react-native"; -import Animated, { - useAnimatedStyle, - useSharedValue, - withTiming, -} from "react-native-reanimated"; -import { Loader } from "../Loader"; import { Text } from "./Text"; type PartialExcept = 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..184f87c6 --- /dev/null +++ b/components/common/ItemImage.tsx @@ -0,0 +1,101 @@ +import { View, ViewProps } from "react-native"; +import { Text } from "@/components/common/Text"; +import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; +import { Image, ImageProps, ImageSource } from "expo-image"; +import { useMemo, useState } from "react"; +import { useAtom } from "jotai"; +import { apiAtom } from "@/providers/JellyfinProvider"; + +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": + console.log("case Primary"); + tag = item.ImageTags?.["Primary"]; + if (!tag) break; + blurhash = item.ImageBlurHashes?.Primary?.[tag]; + console.log("bh: ", blurhash); + + src = { + uri: `${api.basePath}/Items/${item.Id}/Images/Primary?quality=${quality}&tag=${tag}`, + blurhash, + }; + break; + case "Thumb": + console.log("case Thumb"); + tag = item.ImageTags?.["Thumb"]; + if (!tag) break; + blurhash = item.ImageBlurHashes?.Thumb?.[tag]; + console.log("bh: ", blurhash); + + src = { + uri: `${api.basePath}/Items/${item.Id}/Images/Backdrop?quality=${quality}&tag=${tag}`, + blurhash, + }; + break; + default: + console.log("case default"); + tag = item.ImageTags?.["Primary"]; + src = { + uri: `${api.basePath}/Items/${item.Id}/Images/Primary?quality=${quality}&tag=${tag}`, + }; + break; + } + + return src; + }, [item.ImageTags]); + + return ( + + ); +}; diff --git a/components/common/TouchableItemRouter.tsx b/components/common/TouchableItemRouter.tsx index 8ca06867..f5e52733 100644 --- a/components/common/TouchableItemRouter.tsx +++ b/components/common/TouchableItemRouter.tsx @@ -72,7 +72,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/home/ScrollingCollectionList.tsx b/components/home/ScrollingCollectionList.tsx index 32628690..ab4ef0ae 100644 --- a/components/home/ScrollingCollectionList.tsx +++ b/components/home/ScrollingCollectionList.tsx @@ -46,7 +46,7 @@ export const ScrollingCollectionList: React.FC = ({ {title} - + = ({ item, ...props }) => { - const router = useRouter(); return ( - - {item?.Name} + + {item?.Name} + {item?.ProductionYear} ); }; diff --git a/components/series/CastAndCrew.tsx b/components/series/CastAndCrew.tsx index d0406ce6..16f5e0d7 100644 --- a/components/series/CastAndCrew.tsx +++ b/components/series/CastAndCrew.tsx @@ -13,17 +13,19 @@ import { Text } from "../common/Text"; import Poster from "../posters/Poster"; interface Props extends ViewProps { - item: BaseItemDto; + item?: BaseItemDto | null; + loading?: boolean; } -export const CastAndCrew: React.FC = ({ item, ...props }) => { +export const CastAndCrew: React.FC = ({ item, loading, ...props }) => { const [api] = useAtom(apiAtom); return ( - + Cast & Crew - > - data={item.People} + ( { diff --git a/components/series/CurrentSeries.tsx b/components/series/CurrentSeries.tsx index 8d06d2e7..f45792d0 100644 --- a/components/series/CurrentSeries.tsx +++ b/components/series/CurrentSeries.tsx @@ -10,7 +10,7 @@ import { Text } from "../common/Text"; import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById"; interface Props extends ViewProps { - item: BaseItemDto; + item?: BaseItemDto | null; } export const CurrentSeries: React.FC = ({ item, ...props }) => { @@ -19,7 +19,7 @@ export const CurrentSeries: React.FC = ({ item, ...props }) => { return ( Series - + ( = ({ item, ...props }) => { + const router = useRouter(); + + return ( + + router.push(`/(auth)/series/${item.SeriesId}`)} + > + {item?.SeriesName} + + {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 ( + + + + diff --git a/hooks/useInterval.ts b/hooks/useInterval.ts new file mode 100644 index 00000000..850f2f48 --- /dev/null +++ b/hooks/useInterval.ts @@ -0,0 +1,19 @@ +import { useEffect, useRef } from "react"; + +export function useInterval(callback: () => void, delay: number | null) { + const savedCallback = useRef<() => void>(); + + useEffect(() => { + savedCallback.current = callback; + }, [callback]); + + useEffect(() => { + function tick() { + savedCallback.current?.(); + } + if (delay !== null) { + const id = setInterval(tick, delay); + return () => clearInterval(id); + } + }, [delay]); +} diff --git a/providers/JellyfinProvider.tsx b/providers/JellyfinProvider.tsx index a1d2a84f..b12fac27 100644 --- a/providers/JellyfinProvider.tsx +++ b/providers/JellyfinProvider.tsx @@ -1,15 +1,18 @@ +import { useInterval } from "@/hooks/useInterval"; import { Api, Jellyfin } from "@jellyfin/sdk"; import { UserDto } from "@jellyfin/sdk/lib/generated-client/models"; import AsyncStorage from "@react-native-async-storage/async-storage"; import { useMutation, useQuery } from "@tanstack/react-query"; -import axios from "axios"; +import axios, { AxiosError } from "axios"; import { router, useSegments } from "expo-router"; import { atom, useAtom } from "jotai"; import React, { createContext, ReactNode, + useCallback, useContext, useEffect, + useMemo, useState, } from "react"; import { Platform } from "react-native"; @@ -29,6 +32,7 @@ interface JellyfinContextValue { removeServer: () => void; login: (username: string, password: string) => Promise; logout: () => Promise; + initiateQuickConnect: () => Promise; } const JellyfinContext = createContext( @@ -51,7 +55,6 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ }) => { const [jellyfin, setJellyfin] = useState(undefined); const [deviceId, setDeviceId] = useState(undefined); - const [isConnected, setIsConnected] = useState(false); useEffect(() => { (async () => { @@ -69,6 +72,88 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ const [api, setApi] = useAtom(apiAtom); const [user, setUser] = useAtom(userAtom); + const [isPolling, setIsPolling] = useState(false); + const [secret, setSecret] = useState(null); + + const headers = useMemo(() => { + if (!deviceId) return {}; + return { + authorization: `MediaBrowser Client="Streamyfin", Device=${ + Platform.OS === "android" ? "Android" : "iOS" + }, DeviceId="${deviceId}", Version="0.8.4"`, + }; + }, [deviceId]); + + const initiateQuickConnect = useCallback(async () => { + if (!api || !deviceId) return; + try { + const response = await api.axiosInstance.post( + api.basePath + "/QuickConnect/Initiate", + null, + { + headers, + } + ); + if (response?.status === 200) { + setSecret(response?.data?.Secret); + setIsPolling(true); + console.log("Initiating quick connect"); + return response.data?.Code; + } else { + throw new Error("Failed to initiate quick connect"); + } + } catch (error) { + console.error(error); + throw error; + } + }, [api, deviceId, headers]); + + const pollQuickConnect = useCallback(async () => { + if (!api || !secret) return; + + try { + const response = await api.axiosInstance.get( + `${api.basePath}/QuickConnect/Connect?Secret=${secret}` + ); + + console.log("Polling quick connect"); + if (response.status === 200) { + if (response.data.Authenticated) { + setIsPolling(false); + + const authResponse = await api.axiosInstance.post( + api.basePath + "/Users/AuthenticateWithQuickConnect", + { + secret, + }, + { + headers, + } + ); + + const { AccessToken, User } = authResponse.data; + api.accessToken = AccessToken; + console.log("Quick connect authenticated", AccessToken, User.Id); + setUser(User); + await AsyncStorage.setItem("token", AccessToken); + await AsyncStorage.setItem("user", JSON.stringify(User)); + return true; + } + } + return false; + } catch (error) { + if (error instanceof AxiosError && error.response?.status === 400) { + setIsPolling(false); + setSecret(null); + throw new Error("The code has expired. Please try again."); + } else { + console.error("Error polling Quick Connect:", error); + throw error; + } + } + }, [api, secret, headers]); + + useInterval(pollQuickConnect, isPolling ? 1000 : null); const discoverServers = async (url: string): Promise => { const servers = await jellyfin?.discovery.getRecommendedServerCandidates( @@ -199,6 +284,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ login: (username, password) => loginMutation.mutateAsync({ username, password }), logout: () => logoutMutation.mutateAsync(), + initiateQuickConnect, }; useProtectedRoute(user, isLoading || isFetching); From bbc6f63089f38612dfccf7a75fdbfc2594dab0b0 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Mon, 26 Aug 2024 22:12:54 +0200 Subject: [PATCH 22/46] chore --- app.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app.json b/app.json index f0220114..1cdee583 100644 --- a/app.json +++ b/app.json @@ -33,7 +33,7 @@ }, "android": { "jsEngine": "hermes", - "versionCode": 25, + "versionCode": 26, "adaptiveIcon": { "foregroundImage": "./assets/images/icon.png" }, From 2565bf73536560704e867d5b1a3050b35858ee9d Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Mon, 26 Aug 2024 22:17:17 +0200 Subject: [PATCH 23/46] chore: remove console.log --- app/_layout.tsx | 3 --- components/CurrentlyPlayingBar.tsx | 1 - components/common/ItemImage.tsx | 5 ----- components/common/TouchableItemRouter.tsx | 2 -- components/library/LibraryItemCard.tsx | 2 +- components/series/SeasonEpisodesCarousel.tsx | 3 +-- components/series/SeasonPicker.tsx | 1 - providers/JellyfinProvider.tsx | 4 ---- providers/PlaybackProvider.tsx | 5 ----- utils/jellyfin/playstate/reportPlaybackStopped.ts | 2 -- utils/jellyfin/session/capabilities.ts | 1 - 11 files changed, 2 insertions(+), 27 deletions(-) diff --git a/app/_layout.tsx b/app/_layout.tsx index 9d551286..db1a105e 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -76,9 +76,6 @@ function Layout() { if (url) { const { hostname, path, queryParams } = Linking.parse(url); - console.log("Linking ~ ", hostname, path, queryParams); - // @ts-ignore - // router.push("/(auth)/(home)/"); } return ( diff --git a/components/CurrentlyPlayingBar.tsx b/components/CurrentlyPlayingBar.tsx index 9f7c5adb..5fbd7b53 100644 --- a/components/CurrentlyPlayingBar.tsx +++ b/components/CurrentlyPlayingBar.tsx @@ -195,7 +195,6 @@ export const CurrentlyPlayingBar: React.FC = () => { onFullscreenPlayerDidDismiss={() => {}} onFullscreenPlayerDidPresent={() => {}} onPlaybackStateChanged={(e) => { - console.log("onPlaybackStateChanged ~", e.isPlaying); if (e.isPlaying === true) { playVideo(false); } else if (e.isPlaying === false) { diff --git a/components/common/ItemImage.tsx b/components/common/ItemImage.tsx index 184f87c6..f312a16c 100644 --- a/components/common/ItemImage.tsx +++ b/components/common/ItemImage.tsx @@ -51,11 +51,9 @@ export const ItemImage: React.FC = ({ }; break; case "Primary": - console.log("case Primary"); tag = item.ImageTags?.["Primary"]; if (!tag) break; blurhash = item.ImageBlurHashes?.Primary?.[tag]; - console.log("bh: ", blurhash); src = { uri: `${api.basePath}/Items/${item.Id}/Images/Primary?quality=${quality}&tag=${tag}`, @@ -63,11 +61,9 @@ export const ItemImage: React.FC = ({ }; break; case "Thumb": - console.log("case Thumb"); tag = item.ImageTags?.["Thumb"]; if (!tag) break; blurhash = item.ImageBlurHashes?.Thumb?.[tag]; - console.log("bh: ", blurhash); src = { uri: `${api.basePath}/Items/${item.Id}/Images/Backdrop?quality=${quality}&tag=${tag}`, @@ -75,7 +71,6 @@ export const ItemImage: React.FC = ({ }; break; default: - console.log("case default"); tag = item.ImageTags?.["Primary"]; src = { uri: `${api.basePath}/Items/${item.Id}/Images/Primary?quality=${quality}&tag=${tag}`, diff --git a/components/common/TouchableItemRouter.tsx b/components/common/TouchableItemRouter.tsx index f5e52733..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") { 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/series/SeasonEpisodesCarousel.tsx b/components/series/SeasonEpisodesCarousel.tsx index 48bb0363..d9a33304 100644 --- a/components/series/SeasonEpisodesCarousel.tsx +++ b/components/series/SeasonEpisodesCarousel.tsx @@ -110,12 +110,11 @@ export const SeasonEpisodesCarousel: React.FC = ({ if (item?.Type === "Episode") { const index = episodes?.findIndex((ep) => ep.Id === item.Id); if (index !== undefined && index !== -1) { - console.log("Scrolling to index:", index); setTimeout(() => { scrollToIndex(index); }, 400); } else { - console.log("Episode not found in the list:", item.Id); + console.warn("Episode not found in the list:", item.Id); } } }, [episodes, item]); diff --git a/components/series/SeasonPicker.tsx b/components/series/SeasonPicker.tsx index 3781821e..68e3ead5 100644 --- a/components/series/SeasonPicker.tsx +++ b/components/series/SeasonPicker.tsx @@ -60,7 +60,6 @@ export const SeasonPicker: React.FC = ({ item, initialSeasonIndex }) => { if (seasons && seasons.length > 0 && seasonIndex === undefined) { let initialIndex: number | undefined; - console.log("initialSeasonIndex", initialSeasonIndex); if (initialSeasonIndex !== undefined) { // Use the provided initialSeasonIndex if it exists in the seasons const seasonExists = seasons.some( diff --git a/providers/JellyfinProvider.tsx b/providers/JellyfinProvider.tsx index b12fac27..f8df5c86 100644 --- a/providers/JellyfinProvider.tsx +++ b/providers/JellyfinProvider.tsx @@ -97,7 +97,6 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ if (response?.status === 200) { setSecret(response?.data?.Secret); setIsPolling(true); - console.log("Initiating quick connect"); return response.data?.Code; } else { throw new Error("Failed to initiate quick connect"); @@ -116,7 +115,6 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ `${api.basePath}/QuickConnect/Connect?Secret=${secret}` ); - console.log("Polling quick connect"); if (response.status === 200) { if (response.data.Authenticated) { setIsPolling(false); @@ -133,7 +131,6 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ const { AccessToken, User } = authResponse.data; api.accessToken = AccessToken; - console.log("Quick connect authenticated", AccessToken, User.Id); setUser(User); await AsyncStorage.setItem("token", AccessToken); await AsyncStorage.setItem("user", JSON.stringify(User)); @@ -207,7 +204,6 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ } } catch (error) { if (axios.isAxiosError(error)) { - console.log("Axios error", error.response?.status); switch (error.response?.status) { case 401: throw new Error("Invalid username or password"); diff --git a/providers/PlaybackProvider.tsx b/providers/PlaybackProvider.tsx index 7ec7fd33..03b6363f 100644 --- a/providers/PlaybackProvider.tsx +++ b/providers/PlaybackProvider.tsx @@ -99,7 +99,6 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({ if (state && state.item.Id && user?.Id) { const vlcLink = "vlc://" + state?.url; if (vlcLink && settings?.openInVLC) { - console.log(vlcLink, settings?.openInVLC, Platform.OS === "ios"); Linking.openURL("vlc://" + state?.url || ""); return; } @@ -231,8 +230,6 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({ api?.accessToken }&deviceId=${deviceId}`; - console.log("WS", url); - const newWebSocket = new WebSocket(url); let keepAliveInterval: NodeJS.Timeout | null = null; @@ -243,7 +240,6 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({ keepAliveInterval = setInterval(() => { if (newWebSocket.readyState === WebSocket.OPEN) { newWebSocket.send(JSON.stringify({ MessageType: "KeepAlive" })); - console.log("KeepAlive message sent"); } }, 30000); }; @@ -254,7 +250,6 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({ }; newWebSocket.onclose = (e) => { - console.log("WebSocket connection closed:", e.reason); if (keepAliveInterval) { clearInterval(keepAliveInterval); } diff --git a/utils/jellyfin/playstate/reportPlaybackStopped.ts b/utils/jellyfin/playstate/reportPlaybackStopped.ts index eb7278ef..665eb032 100644 --- a/utils/jellyfin/playstate/reportPlaybackStopped.ts +++ b/utils/jellyfin/playstate/reportPlaybackStopped.ts @@ -41,8 +41,6 @@ export const reportPlaybackStopped = async ({ return; } - console.log("reportPlaybackStopped ~", { sessionId, itemId }); - try { const url = `${api.basePath}/PlayingItems/${itemId}`; const params = { diff --git a/utils/jellyfin/session/capabilities.ts b/utils/jellyfin/session/capabilities.ts index d76fbeb7..0c47d869 100644 --- a/utils/jellyfin/session/capabilities.ts +++ b/utils/jellyfin/session/capabilities.ts @@ -52,7 +52,6 @@ export const postCapabilities = async ({ ); return d; } catch (error: any | AxiosError) { - console.log("Failed to mark as not played", error); throw new Error("Failed to mark as not played"); } }; From 91ed109a04a46407a583b973abdd2af81254359a Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Tue, 27 Aug 2024 08:26:27 +0200 Subject: [PATCH 24/46] feat: select media source --- components/AudioTrackSelector.tsx | 18 ++++--- components/BitrateSelector.tsx | 2 +- components/ItemContent.tsx | 43 +++++++++------ components/MediaSourceSelector.tsx | 81 ++++++++++++++++++++++++++++ components/SubtitleTrackSelector.tsx | 20 +++---- utils/jellyfin/media/getStreamUrl.ts | 13 +++-- 6 files changed, 139 insertions(+), 38 deletions(-) create mode 100644 components/MediaSourceSelector.tsx diff --git a/components/AudioTrackSelector.tsx b/components/AudioTrackSelector.tsx index f92f4239..1d01c220 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,7 +33,7 @@ export const AudioTrackSelector: React.FC = ({ ); useEffect(() => { - const index = item.MediaSources?.[0].DefaultAudioStreamIndex; + const index = source.DefaultAudioStreamIndex; if (index !== undefined && index !== null) onChange(index); }, []); @@ -44,7 +46,7 @@ export const AudioTrackSelector: React.FC = ({ - {tc(selectedAudioSteam?.DisplayTitle, 13)} + {tc(selectedAudioSteam?.DisplayTitle, 7)} diff --git a/components/BitrateSelector.tsx b/components/BitrateSelector.tsx index 43df6fc5..45370614 100644 --- a/components/BitrateSelector.tsx +++ b/components/BitrateSelector.tsx @@ -58,7 +58,7 @@ export const BitrateSelector: React.FC = ({ Bitrate - + {BITRATES.find((b) => b.value === selected.value)?.key} diff --git a/components/ItemContent.tsx b/components/ItemContent.tsx index ecc0b109..1cc311fa 100644 --- a/components/ItemContent.tsx +++ b/components/ItemContent.tsx @@ -32,6 +32,8 @@ import React, { useEffect, useMemo, useState } from "react"; import { View } from "react-native"; import { useCastDevice } from "react-native-google-cast"; import { ItemHeader } from "./ItemHeader"; +import { MediaSourceSelector } from "./MediaSourceSelector"; +import { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models"; export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => { const [api] = useAtom(apiAtom); @@ -40,6 +42,8 @@ export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => { const [settings] = useSettings(); const castDevice = useCastDevice(); + const [selectedMediaSource, setSelectedMediaSource] = + useState(null); const [selectedAudioStream, setSelectedAudioStream] = useState(-1); const [selectedSubtitleStream, setSelectedSubtitleStream] = useState(0); @@ -85,6 +89,7 @@ export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => { item?.Id, maxBitrate, castDevice, + selectedMediaSource, selectedAudioStream, selectedSubtitleStream, settings, @@ -114,9 +119,10 @@ export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => { subtitleStreamIndex: selectedSubtitleStream, forceDirectPlay: settings?.forceDirectPlay, height: maxBitrate.height, + mediaSourceId: selectedMediaSource?.Id, }); - console.log("Transcode URL: ", url); + console.info("Stream URL:", url); return url; }, @@ -194,19 +200,24 @@ export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => { onChange={(val) => setMaxBitrate(val)} selected={maxBitrate} /> - {item && ( - - )} - {item && ( - + + {selectedMediaSource && ( + + + + )} ) : ( @@ -219,7 +230,9 @@ export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => { - + {item?.Type === "Episode" && ( + + )} diff --git a/components/MediaSourceSelector.tsx b/components/MediaSourceSelector.tsx new file mode 100644 index 00000000..c2a95c23 --- /dev/null +++ b/components/MediaSourceSelector.tsx @@ -0,0 +1,81 @@ +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]); + }, []); + + return ( + + + + + Video streams + + + {tc(selectedMediaSource, 7)} + + + + + + Video streams + {mediaSources?.map((source, idx: number) => ( + { + onChange(source); + }} + > + + { + source.MediaStreams?.find((s) => s.Type === "Video") + ?.DisplayTitle + } + + + ))} + + + + ); +}; diff --git a/components/SubtitleTrackSelector.tsx b/components/SubtitleTrackSelector.tsx index a445ffeb..8141e312 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 { @@ -53,7 +53,7 @@ export const SubtitleTrackSelector: React.FC = ({ {selectedSubtitleSteam - ? tc(selectedSubtitleSteam?.DisplayTitle, 13) + ? tc(selectedSubtitleSteam?.DisplayTitle, 7) : "None"} diff --git a/utils/jellyfin/media/getStreamUrl.ts b/utils/jellyfin/media/getStreamUrl.ts index 6c5a6056..caf82a46 100644 --- a/utils/jellyfin/media/getStreamUrl.ts +++ b/utils/jellyfin/media/getStreamUrl.ts @@ -18,6 +18,7 @@ export const getStreamUrl = async ({ subtitleStreamIndex = 0, forceDirectPlay = false, height, + mediaSourceId, }: { api: Api | null | undefined; item: BaseItemDto | null | undefined; @@ -30,8 +31,10 @@ export const getStreamUrl = async ({ subtitleStreamIndex?: number; forceDirectPlay?: boolean; height?: number; + mediaSourceId?: string | null; }) => { - if (!api || !userId || !item?.Id) { + if (!api || !userId || !item?.Id || !mediaSourceId) { + console.error("Missing required parameters"); return null; } @@ -46,7 +49,7 @@ export const getStreamUrl = async ({ StartTimeTicks: startTimeTicks, EnableTranscoding: maxStreamingBitrate ? true : undefined, AutoOpenLiveStream: true, - MediaSourceId: itemId, + MediaSourceId: mediaSourceId, AllowVideoStreamCopy: maxStreamingBitrate ? false : true, AudioStreamIndex: audioStreamIndex, SubtitleStreamIndex: subtitleStreamIndex, @@ -62,7 +65,9 @@ export const getStreamUrl = async ({ } ); - const mediaSource = response.data.MediaSources?.[0] as MediaSourceInfo; + const mediaSource: MediaSourceInfo = response.data.MediaSources.find( + (source: MediaSourceInfo) => source.Id === mediaSourceId + ); if (!mediaSource) { throw new Error("No media source"); @@ -75,7 +80,7 @@ export const getStreamUrl = async ({ if (mediaSource.SupportsDirectPlay || forceDirectPlay === true) { if (item.MediaType === "Video") { console.log("Using direct stream for video!"); - return `${api.basePath}/Videos/${itemId}/stream.mp4?playSessionId=${sessionData.PlaySessionId}&mediaSourceId=${itemId}&static=true`; + return `${api.basePath}/Videos/${itemId}/stream.mp4?playSessionId=${sessionData.PlaySessionId}&mediaSourceId=${mediaSource.Id}&static=true`; } else if (item.MediaType === "Audio") { console.log("Using direct stream for audio!"); const searchParams = new URLSearchParams({ From e0afb68f0cf1e5cd939ff8cfc6f88855aea3d660 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Tue, 27 Aug 2024 12:05:25 +0200 Subject: [PATCH 25/46] chore --- app.json | 4 ++-- providers/JellyfinProvider.tsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app.json b/app.json index 1cdee583..3cd8abbb 100644 --- a/app.json +++ b/app.json @@ -2,7 +2,7 @@ "expo": { "name": "Streamyfin", "slug": "streamyfin", - "version": "0.8.4", + "version": "0.9.0", "orientation": "default", "icon": "./assets/images/icon.png", "scheme": "streamyfin", @@ -33,7 +33,7 @@ }, "android": { "jsEngine": "hermes", - "versionCode": 26, + "versionCode": 27, "adaptiveIcon": { "foregroundImage": "./assets/images/icon.png" }, diff --git a/providers/JellyfinProvider.tsx b/providers/JellyfinProvider.tsx index f8df5c86..83350dd1 100644 --- a/providers/JellyfinProvider.tsx +++ b/providers/JellyfinProvider.tsx @@ -62,7 +62,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ setJellyfin( () => new Jellyfin({ - clientInfo: { name: "Streamyfin", version: "0.8.4" }, + clientInfo: { name: "Streamyfin", version: "0.9.0" }, deviceInfo: { name: Platform.OS === "ios" ? "iOS" : "Android", id }, }) ); @@ -80,7 +80,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ return { authorization: `MediaBrowser Client="Streamyfin", Device=${ Platform.OS === "android" ? "Android" : "iOS" - }, DeviceId="${deviceId}", Version="0.8.4"`, + }, DeviceId="${deviceId}", Version="0.9.0"`, }; }, [deviceId]); From 55ba3daf8623cfaee9fd70d7a28cc8464bdbee02 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Tue, 27 Aug 2024 12:05:31 +0200 Subject: [PATCH 26/46] chore --- eas.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/eas.json b/eas.json index 866f72e8..bd215c2a 100644 --- a/eas.json +++ b/eas.json @@ -21,13 +21,13 @@ } }, "production": { - "channel": "0.8.4", + "channel": "0.9.0", "android": { "image": "latest" } }, "production-apk": { - "channel": "0.8.4", + "channel": "0.9.0", "android": { "buildType": "apk", "image": "latest" From b550d6302fa2fdb762ba90caa11ee8c7a2a376bf Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Tue, 27 Aug 2024 12:06:26 +0200 Subject: [PATCH 27/46] fix: #112 --- components/settings/SettingToggles.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/settings/SettingToggles.tsx b/components/settings/SettingToggles.tsx index 43859c38..ac7261c6 100644 --- a/components/settings/SettingToggles.tsx +++ b/components/settings/SettingToggles.tsx @@ -66,7 +66,7 @@ export const SettingToggles: React.FC = () => { Download quality - Choose the search engine you want to use. + Choose the download quality. From 0d07f7216c46a61ffe26c569e587ed71ee0a3174 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Tue, 27 Aug 2024 22:32:09 +0200 Subject: [PATCH 28/46] fix: design --- components/AudioTrackSelector.tsx | 2 +- components/BitrateSelector.tsx | 2 +- components/Chromecast.tsx | 13 +- components/DownloadItem.tsx | 120 ++++++++----------- components/ItemContent.tsx | 68 ++++++----- components/ItemHeader.tsx | 4 +- components/MediaSourceSelector.tsx | 6 +- components/ParallaxPage.tsx | 40 +++++-- components/PlayButton.tsx | 21 +++- components/PlayedStatus.tsx | 17 ++- components/Ratings.tsx | 4 +- components/SubtitleTrackSelector.tsx | 4 +- components/common/HeaderBackButton.tsx | 2 +- components/common/ItemImage.tsx | 11 +- components/movies/MoviesTitleHeader.tsx | 6 +- components/series/EpisodeTitleHeader.tsx | 22 ++-- components/series/SeasonEpisodesCarousel.tsx | 4 +- components/stacks/NestedTabPageStack.tsx | 1 - hooks/useImageColors.ts | 42 +++++++ utils/atoms/primaryColor.ts | 73 +++++++++++ utils/jellyfin/media/getStreamUrl.ts | 1 - utils/jellyfin/session/capabilities.ts | 2 +- 22 files changed, 303 insertions(+), 162 deletions(-) create mode 100644 hooks/useImageColors.ts create mode 100644 utils/atoms/primaryColor.ts diff --git a/components/AudioTrackSelector.tsx b/components/AudioTrackSelector.tsx index 1d01c220..15d73f0a 100644 --- a/components/AudioTrackSelector.tsx +++ b/components/AudioTrackSelector.tsx @@ -42,7 +42,7 @@ export const AudioTrackSelector: React.FC = ({ - Audio streams + Audio diff --git a/components/BitrateSelector.tsx b/components/BitrateSelector.tsx index 45370614..9379e7c9 100644 --- a/components/BitrateSelector.tsx +++ b/components/BitrateSelector.tsx @@ -55,7 +55,7 @@ export const BitrateSelector: React.FC = ({ - Bitrate + Quality diff --git a/components/Chromecast.tsx b/components/Chromecast.tsx index 6539ce0b..f3ff8f16 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 { 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,10 @@ export const Chromecast: React.FC = ({ if (background === "transparent") return ( - + ); @@ -46,6 +50,7 @@ export const Chromecast: React.FC = ({ diff --git a/components/DownloadItem.tsx b/components/DownloadItem.tsx index 5f83ad03..d2ff0893 100644 --- a/components/DownloadItem.tsx +++ b/components/DownloadItem.tsx @@ -26,7 +26,7 @@ import ios from "@/utils/profiles/ios"; import native from "@/utils/profiles/native"; import old from "@/utils/profiles/old"; -interface DownloadProps extends TouchableOpacityProps { +interface DownloadProps extends ViewProps { item: BaseItemDto; } @@ -143,23 +143,19 @@ export const DownloadItem: React.FC = ({ item, ...props }) => { enabled: !!item.Id, }); - if (isFetching) { - return ( - + return ( + + {isFetching ? ( - - ); - } - - if (process && process?.item.Id === item.Id) { - return ( - { - router.push("/downloads"); - }} - {...props} - > - + ) : process && process?.item.Id === item.Id ? ( + { + router.push("/downloads"); + }} + > {process.progress === 0 ? ( ) : ( @@ -173,61 +169,41 @@ export const DownloadItem: React.FC = ({ item, ...props }) => { /> )} - - - ); - } - - if (queue.some((i) => i.id === item.Id)) { - return ( - { - router.push("/downloads"); - }} - {...props} - > - + + ) : queue.some((i) => i.id === item.Id) ? ( + { + router.push("/downloads"); + }} + > - - - ); - } - - if (downloaded) { - return ( - { - router.push("/downloads"); - }} - {...props} - > - + + ) : downloaded ? ( + { + router.push("/downloads"); + }} + > - - - ); - } 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, - }); - }} - {...props} - > - - - - - ); - } + + ) : ( + { + queueActions.enqueue(queue, setQueue, { + id: item.Id!, + execute: async () => { + if (!settings?.downloadQuality?.value) { + throw new Error("No download quality selected"); + } + await initiateDownload(settings?.downloadQuality?.value); + }, + item, + }); + }} + > + + + )} + + ); }; diff --git a/components/ItemContent.tsx b/components/ItemContent.tsx index 1cc311fa..f64e17a2 100644 --- a/components/ItemContent.tsx +++ b/components/ItemContent.tsx @@ -5,16 +5,12 @@ 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 { ItemImage } from "@/components/common/ItemImage"; -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 { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarousel"; -import { EpisodeTitleHeader } from "@/components/series/EpisodeTitleHeader"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { useSettings } from "@/utils/atoms/settings"; import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById"; @@ -24,16 +20,19 @@ 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, useState } from "react"; +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 { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models"; +import { useImageColors } from "@/hooks/useImageColors"; export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => { const [api] = useAtom(apiAtom); @@ -41,6 +40,7 @@ export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => { const [settings] = useSettings(); const castDevice = useCastDevice(); + const navigation = useNavigation(); const [selectedMediaSource, setSelectedMediaSource] = useState(null); @@ -52,22 +52,45 @@ export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => { value: undefined, }); + const headerHeightRef = useRef(0); + const { data: item, isLoading, isFetching, } = useQuery({ queryKey: ["item", id], - queryFn: async () => - await getUserItemData({ + queryFn: async () => { + const res = await getUserItemData({ api, userId: user?.Id, itemId: id, - }), + }); + + return res; + }, enabled: !!id && !!api, staleTime: 60 * 1000, }); + useEffect(() => { + navigation.setOptions({ + headerRight: () => + item && ( + + + + + + ), + }); + }, [item]); + + useEffect(() => { + if (item?.Type === "Episode") headerHeightRef.current = 400; + else headerHeightRef.current = 500; + }, [item]); + const { data: sessionData } = useQuery({ queryKey: ["sessionData", item?.Id], queryFn: async () => { @@ -139,12 +162,14 @@ export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => { return ( {item ? ( = React.memo(({ id }) => { } > - - - + + + {item ? ( - - - - - ) : ( - - - - )} - - {item ? ( - + setMaxBitrate(val)} selected={maxBitrate} @@ -227,7 +241,7 @@ export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => { )} - + {item?.Type === "Episode" && ( diff --git a/components/ItemHeader.tsx b/components/ItemHeader.tsx index d4c204b1..1eb15d64 100644 --- a/components/ItemHeader.tsx +++ b/components/ItemHeader.tsx @@ -21,10 +21,10 @@ export const ItemHeader: React.FC = ({ item, ...props }) => { ); return ( - + + {item.Type === "Episode" && } {item.Type === "Movie" && } - ); }; diff --git a/components/MediaSourceSelector.tsx b/components/MediaSourceSelector.tsx index c2a95c23..44a3766a 100644 --- a/components/MediaSourceSelector.tsx +++ b/components/MediaSourceSelector.tsx @@ -34,14 +34,14 @@ export const MediaSourceSelector: React.FC = ({ useEffect(() => { if (mediaSources?.length) onChange(mediaSources[0]); - }, []); + }, [mediaSources]); return ( - Video streams + Video {tc(selectedMediaSource, 7)} @@ -58,7 +58,7 @@ export const MediaSourceSelector: React.FC = ({ collisionPadding={8} sideOffset={8} > - Video streams + Media sources {mediaSources?.map((source, idx: number) => ( = ({ {logo && ( = ({ )} - {episodePoster && ( - - - {episodePoster} - - - )} - = ({ {headerImage} - + + + {children} diff --git a/components/PlayButton.tsx b/components/PlayButton.tsx index 5ced44c6..642aff8a 100644 --- a/components/PlayButton.tsx +++ b/components/PlayButton.tsx @@ -11,6 +11,8 @@ import CastContext, { } from "react-native-google-cast"; import { Button } from "./Button"; import { Text } from "./common/Text"; +import { useAtom } from "jotai"; +import { itemThemeColorAtom } from "@/utils/atoms/primaryColor"; interface Props extends React.ComponentProps { item?: BaseItemDto | null; @@ -22,6 +24,8 @@ export const PlayButton: React.FC = ({ item, url, ...props }) => { const client = useRemoteMediaClient(); const { setCurrentlyPlayingState } = usePlayback(); + const [color] = useAtom(itemThemeColorAtom); + const onPress = async () => { if (!url || !item) return; @@ -88,23 +92,30 @@ export const PlayButton: React.FC = ({ item, url, ...props }) => { ? "100%" : `${Math.max(playbackPercent, 15)}%`, height: "100%", + backgroundColor: color.primary, }} - className="absolute w-full h-full top-0 left-0 rounded-xl bg-purple-600 z-10" + className="absolute w-full h-full top-0 left-0 rounded-xl z-10" > - + {runtimeTicksToMinutes(item?.RunTimeTicks)} - - {client && } + + {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 65834fdf..51b6be3b 100644 --- a/components/Ratings.tsx +++ b/components/Ratings.tsx @@ -8,10 +8,10 @@ interface Props extends ViewProps { 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/SubtitleTrackSelector.tsx b/components/SubtitleTrackSelector.tsx index 8141e312..c70ad7dc 100644 --- a/components/SubtitleTrackSelector.tsx +++ b/components/SubtitleTrackSelector.tsx @@ -48,7 +48,7 @@ export const SubtitleTrackSelector: React.FC = ({ - Subtitles + Subtitle @@ -69,7 +69,7 @@ export const SubtitleTrackSelector: React.FC = ({ collisionPadding={8} sideOffset={8} > - Subtitles + Subtitle tracks { diff --git a/components/common/HeaderBackButton.tsx b/components/common/HeaderBackButton.tsx index 4ff4c38d..86ed3483 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" /> diff --git a/components/common/ItemImage.tsx b/components/common/ItemImage.tsx index f312a16c..8d7c6461 100644 --- a/components/common/ItemImage.tsx +++ b/components/common/ItemImage.tsx @@ -1,10 +1,11 @@ -import { View, ViewProps } from "react-native"; -import { Text } from "@/components/common/Text"; +import { useImageColors } from "@/hooks/useImageColors"; +import { apiAtom } from "@/providers/JellyfinProvider"; +import { itemThemeColorAtom } from "@/utils/atoms/primaryColor"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { Image, ImageProps, ImageSource } from "expo-image"; -import { useMemo, useState } from "react"; import { useAtom } from "jotai"; -import { apiAtom } from "@/providers/JellyfinProvider"; +import { useEffect, useMemo } from "react"; +import { getColors } from "react-native-image-colors"; interface Props extends ImageProps { item: BaseItemDto; @@ -81,6 +82,8 @@ export const ItemImage: React.FC = ({ return src; }, [item.ImageTags]); + useImageColors(source?.uri); + return ( = ({ item, ...props }) => { return ( - - {item?.Name} - {item?.ProductionYear} + + {item?.Name} + {item?.ProductionYear} ); }; diff --git a/components/series/EpisodeTitleHeader.tsx b/components/series/EpisodeTitleHeader.tsx index 41c48ffe..e0488728 100644 --- a/components/series/EpisodeTitleHeader.tsx +++ b/components/series/EpisodeTitleHeader.tsx @@ -11,14 +11,9 @@ export const EpisodeTitleHeader: React.FC = ({ item, ...props }) => { const router = useRouter(); return ( - - router.push(`/(auth)/series/${item.SeriesId}`)} - > - {item?.SeriesName} - - {item?.Name} - + + {item?.Name} + { router.push( @@ -27,14 +22,13 @@ export const EpisodeTitleHeader: React.FC = ({ item, ...props }) => { ); }} > - {item?.SeasonName} + {item?.SeasonName} - {"—"} - - {`Episode ${item.IndexNumber}`} - + + {"—"} + {`Episode ${item.IndexNumber}`} - {item?.ProductionYear} + {item?.ProductionYear} ); }; diff --git a/components/series/SeasonEpisodesCarousel.tsx b/components/series/SeasonEpisodesCarousel.tsx index d9a33304..43bd6780 100644 --- a/components/series/SeasonEpisodesCarousel.tsx +++ b/components/series/SeasonEpisodesCarousel.tsx @@ -107,14 +107,12 @@ export const SeasonEpisodesCarousel: React.FC = ({ }, [episodes, api, user?.Id, item]); useEffect(() => { - if (item?.Type === "Episode") { + if (item?.Type === "Episode" && item.Id) { const index = episodes?.findIndex((ep) => ep.Id === item.Id); if (index !== undefined && index !== -1) { setTimeout(() => { scrollToIndex(index); }, 400); - } else { - console.warn("Episode not found in the list:", item.Id); } } }, [episodes, item]); diff --git a/components/stacks/NestedTabPageStack.tsx b/components/stacks/NestedTabPageStack.tsx index 18f0365b..930c4bfc 100644 --- a/components/stacks/NestedTabPageStack.tsx +++ b/components/stacks/NestedTabPageStack.tsx @@ -8,7 +8,6 @@ const commonScreenOptions = { headerTransparent: true, headerShadowVisible: false, headerLeft: () => , - headerRight: () => , }; const routes = [ diff --git a/hooks/useImageColors.ts b/hooks/useImageColors.ts new file mode 100644 index 00000000..7d93e8ce --- /dev/null +++ b/hooks/useImageColors.ts @@ -0,0 +1,42 @@ +import { useState, useEffect } from "react"; +import { getColors } from "react-native-image-colors"; +import { itemThemeColorAtom } from "@/utils/atoms/primaryColor"; +import { useAtom } from "jotai"; + +export const useImageColors = (uri: string | undefined | null) => { + const [, setPrimaryColor] = useAtom(itemThemeColorAtom); + + useEffect(() => { + if (uri) { + getColors(uri, { + fallback: "#fff", + cache: true, + key: uri, + }) + .then((colors) => { + let primary: string = "#fff"; + let average: string = "#fff"; + let secondary: string = "#fff"; + + if (colors.platform === "android") { + primary = colors.dominant; + average = colors.average; + secondary = colors.muted; + } else if (colors.platform === "ios") { + primary = colors.primary; + secondary = colors.detail; + average = colors.background; + } + + setPrimaryColor({ + primary, + secondary, + average, + }); + }) + .catch((error) => { + console.error("Error getting colors", error); + }); + } + }, [uri, setPrimaryColor]); +}; diff --git a/utils/atoms/primaryColor.ts b/utils/atoms/primaryColor.ts new file mode 100644 index 00000000..d7036163 --- /dev/null +++ b/utils/atoms/primaryColor.ts @@ -0,0 +1,73 @@ +import { atom, useAtom } from "jotai"; + +interface ThemeColors { + primary: string; + secondary: string; + average: string; + text: string; +} + +const calculateTextColor = (backgroundColor: string): string => { + // Convert hex to RGB + const r = parseInt(backgroundColor.slice(1, 3), 16); + const g = parseInt(backgroundColor.slice(3, 5), 16); + const b = parseInt(backgroundColor.slice(5, 7), 16); + + // Calculate perceived brightness + // Using the formula: (R * 299 + G * 587 + B * 114) / 1000 + const brightness = (r * 299 + g * 587 + b * 114) / 1000; + + // Calculate contrast ratio with white and black + const contrastWithWhite = calculateContrastRatio([255, 255, 255], [r, g, b]); + const contrastWithBlack = calculateContrastRatio([0, 0, 0], [r, g, b]); + + // Use black text if the background is bright and has good contrast with black + if (brightness > 180 && contrastWithBlack >= 4.5) { + return "#000000"; + } + + // Otherwise, use white text + return "#FFFFFF"; +}; + +// Helper function to calculate contrast ratio +const calculateContrastRatio = (rgb1: number[], rgb2: number[]): number => { + const l1 = calculateRelativeLuminance(rgb1); + const l2 = calculateRelativeLuminance(rgb2); + const lighter = Math.max(l1, l2); + const darker = Math.min(l1, l2); + return (lighter + 0.05) / (darker + 0.05); +}; + +// Helper function to calculate relative luminance +const calculateRelativeLuminance = (rgb: number[]): number => { + const [r, g, b] = rgb.map((c) => { + c /= 255; + return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); + }); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; +}; + +const baseThemeColorAtom = atom({ + primary: "#FFFFFF", + secondary: "#000000", + average: "#888888", + text: "#000000", +}); + +export const itemThemeColorAtom = atom( + (get) => get(baseThemeColorAtom), + (get, set, update: Partial) => { + const currentColors = get(baseThemeColorAtom); + const newColors = { ...currentColors, ...update }; + + // Recalculate text color if primary color changes + if (update.primary) { + newColors.text = calculateTextColor(update.primary); + } + + set(baseThemeColorAtom, newColors); + } +); + +export const useItemThemeColor = () => useAtom(itemThemeColorAtom); diff --git a/utils/jellyfin/media/getStreamUrl.ts b/utils/jellyfin/media/getStreamUrl.ts index caf82a46..1ca210a7 100644 --- a/utils/jellyfin/media/getStreamUrl.ts +++ b/utils/jellyfin/media/getStreamUrl.ts @@ -34,7 +34,6 @@ export const getStreamUrl = async ({ mediaSourceId?: string | null; }) => { if (!api || !userId || !item?.Id || !mediaSourceId) { - console.error("Missing required parameters"); return null; } diff --git a/utils/jellyfin/session/capabilities.ts b/utils/jellyfin/session/capabilities.ts index 0c47d869..ccb068c2 100644 --- a/utils/jellyfin/session/capabilities.ts +++ b/utils/jellyfin/session/capabilities.ts @@ -25,7 +25,7 @@ export const postCapabilities = async ({ sessionId, }: PostCapabilitiesParams): Promise => { if (!api || !itemId || !sessionId) { - throw new Error("Missing required parameters"); + throw new Error("Missing parameters for marking item as not played"); } try { From 309345c834b15c101f9a4fa3899bb526808b1462 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Tue, 27 Aug 2024 22:32:15 +0200 Subject: [PATCH 29/46] chore --- bun.lockb | Bin 582914 -> 583327 bytes package.json | 1 + 2 files changed, 1 insertion(+) diff --git a/bun.lockb b/bun.lockb index b8333b9f8cb1f51a1c738f62f476498a374e9252..20d73a622a5f2249071539d3779152e1f06d620c 100755 GIT binary patch delta 92396 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�awIk8&MUN;6bYkmF^*n4_p0kC`WkArrT=M9S&1T_hQodWwf%^}p?$1#=*m9C!URZ$f*Kvx|n*m$8DcB17%kopRQym|&IpU6Z2EtYTHK)C4(k$G#A{^6gKEzNn@_*_u0vP{ zoMYp!uviRg#O8rI{$HS3)T(CG8sysog<1q}w_GR#Ed|xkyDToV@rOf2-DA_Ovgw2> zXHCQ|s%vb7PzA3AYk&`e8saigmfZsCsETUfPIMLcluh>xsB)gQ_#CKmc7aOwlGR`C zOh5(i0d-VG74QnWD%fY^tD+lxy^~c^+4tLMVPE)Z45#$dL0xs)Yj`cH{wQ2oI)kdF zi;WkGceU~7SiLGLzK7Mp;;^7h1Zn;FqXhje4ggi*MV4O->JTb8++r4}d}BbR&jITJE&$bF{cFEUx76ZY)q)yNx-~*0tgzbMpbnveYxtwQ>p_*h2~-)IEq@5qAyj-B zs5-Y={x~RO?zH;TpbnwZJuf3D@S=?nitn~us0#O3e8uWQ>92yS;B`<9*b6HEJ63K|DB6U#pbmH$9B+*ldEvJu}}{NCb^pxoe3P!;_R>JTblf?VwXf~qKybiyQy5t}bk z91m1M1@&)nslubIemGPEQf+)ylzt4lDy#=;2#>Srs-n^rHzGpYt(KrBZabS$r~=ws zE|h80Ef=bQ3{dGhTm5jT@_P}l{C#Ztz9xOKqksyyz+yiW;Rt1c0hS*QmG43uFVyG^ z0i_SMI1E%dBP<^Y>JVzD?u@oVRaDQiZHBQnUZ{e`S$(|KtD@p_tzH$?po!?Rz!aN5 zU+J`rDxi$hEEd`XMM}USQ~}dL-49%4b)oWIV|i6n`fJgJb8UQ8RK9uW;WDm(gzIfW zp@NI}qXKTWx=_v(Sbed@B{se)D&OtsD)&yCZmCUI1;hFs2}-!kCa8)kXgRuuWTlO- ziqh{zSGv_U{&1*t_t|voKn=yl>ZX1Iuu%B#C@3)p6HJ1D1n%JQczJ_D*l&w)Cs zq8j?V)t^7vW+=BAcG-xk=mzVbvzz*th*o{C*U@@x7qx*=O-BtG@&45UQLH zK^f)~Q0YIl@j~ebEC-FHst93*FKosyZ9<`3~zV+jxVFe^o(opYul<4p{uc zW;h(GM?cv3s;G*7viX0u@l{drzoNI0|24r*S_V!B)sr*9R^V_@4H*HdjIp4`d>W`j zsG*r*`Tqn}@hsA-p36bqGdNcgP(@eSf`l5gYpp)l>O$$ImRCg;e1p}6s^BJ20E;d07RD*ik1VUvv-*TabVj!pn4Yqn!R7FFrE>!+umJ7v) zTO3hrBSwJAc!|Z)R{uYt8lFu$6*$)B8)x$gRo(>44~L2`o=5`qr~p)f(?JzD!)6q! zrRI)u`n1=Z2=5E=g$ZGyw06245l3f>K>fLAQOX7$&>qtQPB z<(xlQ{YOxTQ2BoXW$C}HE({;ByYe`Ws;G+fj~wU_s^V&()`>b+7fL?{RK?9em7NC4 z`!5Am@nxW%P~8YB{Q^+4;#M$x#D0er?go`%B`DLa26g2t0#)H7 z79Rzbeha7rGgASKfI6z8DtgK4Lgjk}RKwn~`dc*z z!mOZ(4{gN%1eN{^o4zW#!JoVBbAWGbv`{{K2voa%0A-k;EdSZ^UqKy*L)qgGoBmIW zf7yJYTPP#$p@p?tEexxQs#_riQ~^hWGQ=^U;_F$xf#nS?KLOMsRKCWR3)S$GEElR{ zr&w%d^;Y`#RCGvCMW=(x*xK?lY(k;p&$QSERE6z98Mw3MF^k6;A;5+QZdW7fQdz;i!BqkujN{20`}<)BS)2vkddw)i`!L#TrP zuw1Bht)?!NzX^oDT)fF9e8A#EHlh9{938?2U)7K-5uOgG2XC9}P z^(ya0!VFiB{y%y>@`CU^Njb$T)y7d3Ws!SB_3)^kujN#_`;_j;>ya2i!}Xf1!6B4& zj=UZzYj7NSJ<>h$dgPJUBef=QRDFF?%f^w{Bagfusn;dt9!Fk}Jo0+vk=G;5T&Vh5 zq}EMw&5hcf zM_!M_4LFXx9*Gfl^6*iM=8@MUlX$MHYsQh+BagfudF1s--ikDz%^Z0>^2qCv<|C>j zuSXtvJ@Uxwk$Qbn4_%JD9@)vhq1LGeR|?%)9eF+S$m@|uUXMKTdgTA)wMdqW@HI-k z^;7k=Na^B7UXRpUk@oedBdybxZkNp4r^~eVQ?e)m(X1fVxv)#lVcLw$D zN<2SEzAG^$m`ups9BjEOad7acghtB{9t?7qAxv0?P$A*rpy6_a2FnrVEJr8{$|XE6 zA#DZ1mSFY@gjp*P_Dk3rw7eVP)VmSp-;J<6*e7AHgf1%)cJNc92=i7Vd@bRLpyNFV z>GvQky$9i`;DChBCG=f|@Jz5|6~f|G2!Bd=j^7AH=yfl``g;+|gI^{5B4Olegk8bf z)d*`=Bc!ZBs0fCxK^U?IVY`Hv18*%t@>+z+YZ3MYTO>Rxq0xN^uLimIAxyXrp+dsz zLBn+j4b~ydS%>f@Kg)^myo9v-5%vYM??;$*Kf-cBb>S(Vg7oA{lPv7dnI() zfbd>Wx&dL{286FAd=PZph>*S!Vd+MMkAeddK9|sU6T&CKl1&JUHzE8f;jxK24^f1DqAopQ}2@fMwNcb^m_y|ISM-b*bg79-tF5!6zX=Mn%2D8f$W|blAm+*Vg z@==6SA4Qn|D8iq?J_&mzblH-4e!|~L!S!1b2Lp+Z8vpy3k;4W2-l^8`Zu zpj^W964IVTXc)|X5@FVp2>T@*AGCZ5;nb%P=0AndDA*@quY@j7BQy?5pGKJXG{V;s zP6|3cgOL6V!qR6Dngs_Wd@iByvk1+DCC?%(eiq?R2`z%m=MZ{5hp_%RgqFdt5`K{| z@_B?-!P@5$);y1pQjU-o3@t|(QjV})!Wn`00z&c&2$NqxXcKIa@Ti1FyAaw1xw{Z1 z>_VuJa8}UpMT7<~BFuRaAsUoRcwRzU1wzMQb_K$$3WWU~6wm#%0~U zNNka$3}qL6l=xMG+dk;_X=2^T3Ej!9$VqOnyL(bnWY&3n$3mrFt;97Z7UmRYvx)yR zGq~jQ#M|l(>=VACUy_}d+j?T5d3G|e%sY@+-#zgyehi(xe#v=!ze+9AbCrMcmv#Lj zu^`D^TekU|#C8#{Q=UoOqU`HKi8s0KU1f8AOq`Su>2)`mgRIXJ{h;KR#1r^8LYDrL zcwS^GFRpt*%7MguH7ljTn(*6gB`v`QYVCWHgFe5}-<#K|OQ~*BV>cysze)GUMw(q| zv*7eUNO$=Is_DwQNymFx+4%)iXl=c+k$)uq?WTO8BIv<;TgWc!40ct%H?c5ncGl!P z=iagv^^@8etzb%a8rE=nm8G~ztz5UbtYbpb_{5Y|ye!VQD@wNTvUp>a(JmouWB+DJ z$?od1ud64q;PiRlSmCs?ZeCK%bvKkRtH>sc7 zw5+0T(!j)&pZHZn!Mg~-f164dsx%iAU0ANqT6b~+9I<92c> zIzNmKc6LhoxM3(-JN4&B`Hvwy7OY*xF3Mf6YQih|X>Zkk<8^zwC6(>z7MW44=FanY z9YM#)NYJKn^}4}ry&|m|{q?sw6tTd*!lD~w9SedFdPV9M>v!7pDW#sbwU*AY&}#Y| zw@s+buN$9b349D7RWmn7)Xo;{~goM))XuhqB9Rt0h={XP`Z4wcS=b6YW{6>9_pURn4{MtoEu+*A`7*fzl;=)3vrdr{HE1p@q5wa2z#v-C9H3(>ez>-#-wu&M$@!;+otPC_!B$0 z@7Q$wTMf>qR(n@JPpQlqoCmDDT{yqD+DB;ouYab*`N3+R*>o|q zKdh!-I8@%VIV0Ac4_K|69&mY9{KAUe(Ne7TrParo$BK2RBR7W*L{?|VS7tTa!H4$X$ zL7bX(I*ziL^_9n_c8qJ=yhG4VMQZ};mmrmQC};df?CaaS!_cnb4DGL*DJy!wBU z6;HL|1QO~y(^^PcS}m7wO`GmCt4&0!ZM9Zt@`Xv9$Fm@69Z9q4@(AZso{rX5n@o6G zs0Ez{L~0aIffv~{XWBIRXfv#)mq%rkshl&d*4Ap%(Dd_XT5H;&X~+vWFSc3-o30RT z5Sost)rtr|5r5Gn9Z{W_&iSNG*vTfGf!2x^Xr0Qi+DyXnUw+aLGOB^IIO9L3)x~PF z(Kb__)~>EryOi)lwvL#7PEwgK<9yhPXWN9Aqdj7^ZdRLv_NdjmTTS2l)jvg`#ZWU@ zXtGQR=Ng+=TR=r$sf0dfH_l#&vcgrI@!w4AZ4+LNHjB*XfahCHv*>bL zU>~bpiG-=Z)o#))^Bq(#Qnk0=1bCtXp{>eH8g_xNAv0zXxhX+2mK6f zAE8%wTGOk*Z-zF?BTSPa zZilEsb+qxknrFRRP=)FkL(^R7H(3&JJr@J7iRn~B+XT%UKK+|!Xq$QdON)rV>4vt2XT1bp<(OVD zOj^CRf@_90)6lj-LzYXt%)-pxgL0F?)cvIsIGc5vITKLmSgjbq(WQo;???|7vKz8$Oj;iw*4$o~seE zilQZkwvT7kC{+|KHMIRae?i@h!(C=*2Q>d_A&!TrB3@H?5Ojey0e6L=9pYINI2o)o zw8K0<0s6NJnwH=Z@Dxk|s}0{#o=*c6CuLX{2QgZIhvCq^}#=W@wf$JquAk?4)91i(x#+v(}x; zf~|&jo@bQ{3&A!+yTG&h)zaB+Xqt&nXsp`DcNp3wo>f+87vE`Um$m(=uu$SIL%hPX z-odGVyP>&ouY&&2*5dAiM(ucA0|O21knwvRS~h4KaSt2X4W83L+k|_>&~EaqpM=<~ z^?%e5HD@y*YD+!_P3!X(m}zJy44;{in&-B<3x@UxnkMo*xM*mPdDd#u zYPe)*+ISQ6dQ&a(%ZB)r=c9&p#n7}{95b}5hNd|?VQAM3%^TWDL%R-*g7ZoPZ74LY z!aoh4CZvzntJeQ5L)0&codf!J+t73(rk_I5YPe%)I&yDP9`Ki;rG=(8Ed9G{Xz8G7 zL|P5^3@tss)t{F5eTzoz$&b%H=MRTjD!Avn_tLzB8Z0dx%3mXyAM; zL2vWP2NGdS0(w`~3@{VS0#!gfP-AB`p!Q9*XVw5}!>k3~29>}&Ky8>6Kr`Ylfg1z# z>N345Zx|4u_Dj9@ERSBYmY;`Epckj*0B)cc0iFjJz!h*6Tmx#mR9mImCT{_~d`)G8 zT2CE7ZINk!573+4v|B#F)%*Lt0zE+jNCIDjZ!$!~%!A%QsP@VIKy8wT!BL>L$m8G? zI1NUE(Xz6kGc;@pk3ET6FCl#Z9)ZW;33v*;<#9u2Aj_csjhqFevZu4{ivhTSAO}#} zVhG3yrh*>{QEiLW2z6vSY17CVkf0ZV{s?qSZC9Ws#jD^3xCxelPaPcfor=Z{0Ws)W*mML=(L9vuAt2Z(uLj47PyTU^18r z^n-6|fGh^WL2(cPrqD~K0=)XHto>kd=N-(?ltrtsf02{$7una5*dXwcGFq=Br0d@ja)gIulC;s&k z(X9ZjEYMOmSMy^n(7{SSJXpdx%bWNP0ALoIT7vuv4KtWIhsBrz3XnFy?FgJ>5qd^Iv);7Ij;s@{}=m#Dlg&%`g zc-EVHzmlXT&TJq5M(lgRexO$v-vI{*Qa|$cJNN_WWI`toI$6-kfg0{k12xv22YPtm zAkaIT^&aOvV2)gE;tWji?1N}ZyMIsqN8R*KweP|EBL!hR*j6ltEjYx1~@F{2lnu0O-9ZT_!1A1d!^Naj=J({LP%9&3o{e01`nGXbRF2uG*s2_N*7PP6kuJR0Y}s zYqDN7aBBiJ!+iww0{PVd6P#sn8;@HEKK(2p|B1!`VbbGlxPueLA!Hd;g6MxX}CR*O9iL>TxX^FlVD9!H#>D1hrH=#C6MkvDx_L{E7S22g-_hNBf!?4G_#X5H{lEY)5DWr?!B8MzI2aAafU#g47!M|b z$zUp&2Bw3VU>2AS=7RZP0ay%{c(b0nl!sLy8LS3tWk(C-h|W*Pf(c+Em;@$+0YKM? z)K;gfZMs6Hwl_EJRLyNUfj_^4K~A71Gc}Rr25J?{2MU40ARI)30kwVwxpEcgwVS_#ec%8%0#4|io(p-<%RtY<{52^Rptp^xSt={guLX|d zT{9qHI2Z!@fdQZ|_!jgA=}7Dva)@cks}!YF8t5m3^`i>6!5we|>;h^T`b|G;v6qL< zKtEaW3Cy}gK8xp{l}6;><9-L;2US2cs19-fHwXc0u<-*KK_;Na8vP}g4s_D4MjM?{ z>Qr+km;ux-(pXCpr~zjpPyNYmCG)vgH;cE93xX|Vfu`? znu2DaIna-9{sI;P{h;T3Xg>or4CvIjA^B;QXbakb_Mjv90(6tEZJh-j zy<|dLr#owZC*t&4O zk!M}-ehc0;>5vVX@azlHs*7h`y!Hb>(*V>`bPrE^ao6CMArWeJEC+HDKwSdToh!PK ztvge6tvM3DA8^%-bpQ+jr^vuw-~_td+ZSHFym(Fm|2k5HdUdic+x|u(%H!&CtqbVN zYbIR1AXryY&%*mTw4UH&pevoVz$%ao3KF<3OkU%89+(dnfM0;F2hPAPN92Rake;MZ z_>zCRhSwFe0xq%BrPC##ma`!s;2z&@Uzdddok*evd z5BMJZ0Q!NS0EFa738 zJ`e$PMMTYymbDQ#JBjHH&wNVnbA$T0wSgu`Z`tm`+@#oAgBt;fU=ZKHe9t7t7Z5WxUL_vsLPA`U2REyH(6$OfLeq|B51 zBRs2-_82%0P5?E$4hBQOP%sP(0@;BN&_sC>uQt_9gx!_|YM5%~)Dr%bgs9o`JD?eB ztP!8b^L2h`FVK%*semXCDuChqUPphw44>LYx4^d$x|MpKH-QEOHk)^I%=@{x4UHZ2 z_G~HF83i3SYqdaS1U=cBH9RJq-vDyJn!jor+zDFHn78BV4xcSxBhXzz`qAw#aaGbL zgLi;R-sPYXSO%7YVffc^OowMx=F#?it(o5t@5SdjJ3ADQuLM==Qr%y1b%0+4bimi# zX=wUxF|M1`e)t@pzEhfqZ;8y9>MR(h!+@GV>XAtOZ24qZR`Z*(^IDI)R&Gvp2F9#2 zB;A5QQFv|Qx4N5+yA9V9R3V;6QtmsX>@;Ulj`B30=8R6L2zxiuQysS^u9n^-pn`NS z$OtWl(0}K-AkWMfy@~-9vZeTaiV#nOvCwqt5eqFG=vLnY{8svY+^2*w3f$rOclaOT z-dBlzj|XM^3)}{O0!{KwpmUmQ;3_x|&H|m+oB^jmAp$&!djcE>I`26IG;-xT3XTDN zcN!Fie+3EB1n6ApcU@0Wo%KCZta|JPpn9z8vl{3Po~4@nhas=Gg(X#Gdf`X?9YoHTIMr+ALb)xsVi_;VjrlC&nd! zmcE+riUJKY1o(j4ng$TSvuY0+fySEyL=jXlt~b!aP=0-iF&dFSP!aYDuBttm zaCHn-3s)AP)AG!?O>i~OelmZiGcb$3&I$$->h~a<9GvNlc4~-$l4X`NTTV~%Lhuv} zJnc_Q#Vv8OoS}|F(rA{mV1iN#fc!u+nHS^%p7L??oEvC-x>-)GkeXC2FKr}R$NFvw z&pUu8ZCZ9pS4*b$1pS?lXAMjJj{s!7fLB2Xukk~RsR+-Wq-vMYO4Y>W;0T;Eut_wJ zo?g_Bc(uNZ1FbhLQcv9$1EI7B^q4%Xm`5PKA^ zMx&|If-J(>J7-|Azqhnfuy|6Sv3g=G2|Y%_X0tvLE2U>U-K8r+i6@{paJ9@deofEo z{ML4?A!}>#v|FuIRYqRpjGr^GmPVq1#{o@KNg}Jjv!<;)u9jXoTu(8T;Js!~yM-2$ zb_E@kpHHN(+@FnI=}pLA!|n-IQ>7*PE>Oi?8>yx^8E8$v!|zJqZJ;y{^xb>Vv`cvS zH$c-cl)n(rIF=K>CfD@z1g|Q*(C(~VUbCLlX-EptSQ0mXHZu42pBlXnLv(mdcvQ4kOWp+Pn*q}* z98X*BG;d0UM})^vq#TNL2D|&ikQIjM*?YL66%#vUrxe6+8-+4cKC9@H?H~ChiQ){AG7$Z zG@O&-i%3HTYBo0v6(+3Mzb@OhUEYqzcwvf_Sd=%paM#bR|IMo3pBCq+S$L#Xe(y>T zyt?bb;0MFQEu$Td$<7dOMMHM=EK08dzwZ-j-I*>yv7FJEEy$^1>oY^smlljwl)RD}36ra>7?O;Pj6{{)PMqi0HI2 zw3J=HI^#KD*Jz3>*sY3}@+})Sr|}Ox(zfwaJ}baeQho6=KF4-hn(|9>FL91`jF!j| zu3*O$xgJbFJJDKbu8AL%6C#C^TGEQRL#KCgb!A=ol$L+ zx8t&*IAjNWZZ(!^Gro4H|G9aKpDZEe(WIQB3KGXi0?IG#w>$kMVx-H5+^$(l>6Mo? zXYg7LuUg9M*BrSTSFOcZyhen_MubOuHIVitTme30znvui3ac98wV-chkNmy5-|A%+ zYeaZSh9%jfu}qcA>MWH(%ZOm7gpP6rhn&V&0KNu&epzyL3O#`@ZD}##5%+!N#4=|P zCtq8`=nh9)XUs_4bm&~En$Zh5duX-i*ch*OWx^;|V44`OrV`hj2)>m@%ZXr;T!)-9 z###~1gU=n6b4SGPo8C3W=NY+K3=bKl`m3&B`EazWgrlW|Cb@#MZKB|{>@Q6JA@ja{ zu6Ev>M}KpLGmPV%O;*q*UXv^vTmiXgC2vynnc-|Q`I`@`jp(0921?KlOCWm0vC_HA zu~GJ{BssezOAE4b5O%Gs^x@eP-g&piSDN4OQk19H83|nl-!v(`${FvzN`+}@PA+n+ zZ>M()zXzYGv&%98KJULW$%<9ZDx9`Xm+XwjTiIk9={%CcL?E%y&12U)2HseVw~}^M zFO&8#aK62LCK673yJ6KCe1sF2HFlE@-E6&^v<1B_q zWw?`BZYLA>c8Oa}gB~XXR+9uDX?ugx%q-DZZ9iEGL+d z=>k^sixW}0m9bzsv)1{!qk+V)gS1eR))D+#8HMAnjU>{N4?A4Ee!==-=aFhgU|8KQ zyPRIL`0__57`VF`x6MRCuJyjMkj5CT+>$yMfSVN|_D(DlWA) zIO9Xgo5=fqHe~*aLtBn}JG5eu++H_AWz`0R`|C&nZFFTq->Kj2r}rwGm(G zC`WDSw~=f#4zuexWBt*&&%OF#sFgCSG~37;7#!W?u=4dXd}W*M{_LyXE%Ir2u_QFg z>nF*)3BCzZJSVMp0et?%8+3Ad^10+$&xLnM>M5TcuU*B#Tn#V_qWgAUd!+ffZREmM z*FIGt@~kZ%hrzVvqLP2Jvy8)}%x)#QsQCN^O!4l$lCN7^zmM@=(w6i-GIukPwvqWH z?zwj5Cy$}KgA3ZFAJ?FKC7)TNuBYTaRwB3Halcen$45>cwi_g^3PQ{a5^TE0{_J+2 zXxu(u*$Npl$7(@^m)7Q4Kz3+YyCp-oD|pyeXVrfvBN#fOY#9+OqqjOgcPAIIlY2C6 z&^O-qH%}&PBWrhwqp2%9fwU&RjtJPDVwH6n{Gt)AO8$&Uikk;Esh$Vf3 zy&n~kfn!_&NXIbQRg@O{`!;7)_vE5>AiMSyUA;1{qz0l=h(P8_+3omVF3IXx1A`|l z_a=N%q=}VF`xjnvdIP>9qr*#vmqv9{_IO8s$+8n)B1Lhe!Vc%|)Fk&e#q4Ty^fHsI}xuV{c#>xPpJe`>6e6OPFP;flD17k?H%&c}XI|BR$UUjrRzxmj(4z) zkIFCwgmr%=U;lF>rr%46rMt`@NATlin>rh$(I0rcBv18MW*PLrFd zo>YJRy4^nGBEK9nWOpc<9J#ajW=IkYbk0%AcMrZo#JlP8dykhLJep+jMJZ!$*`*Bc zl(iXdwmq}?)`+wFQW)Av!agD#E;%Q_w;H~}1hi&yVnDmrbx~4@&&19VV`l*~X-$}f_a{KAlpEtHk$V%oasiu6F<*M?z%QICY^ziDXwrqH_>Xwx3 zRF<%b1lvQ(s54jgsIODfR(~;jIM1(Ta`TEalHn(3oGr4wnyy-Zbzu7=gc4;-&nSsI z1m~MFw=6BO-65wBw@6Gl0O<>v@YETYGs>?bgI=`Mb<1{YG+VubAn_!4Ce(I zpkd4r#~Ei}nkcW8vM0#p?_^;0+9Gam^ni8;ov(5=&6tDETJElI+B#hR4O43rbhq%a zgJbI8HAFHIj{8>_RO^Y$KC5|-xgU175^ELB3leK3Auvs}mq~;jx2!tkjO8jIla6O8 zH396D*_!SH(%>-N>Yr*J%9|CR;j`0aj}Y4Q9;)Zz;(vtxYT~PZ#F^{)_%g`K<1pos z`A2YmkZm~b3GdiBzn`T^%O@pvj38r?%t9hdcukb2%4e|$<}Bg$GR}yUvW@WMc%LL4 zW%&H_JsEuzP1z*Yt_(SMloz-wkBx zF%;1P@wQlv{^`?hYsQT4;q3^9qIzMJglR_KmNM$pkXpx`(J?#UvSnb$@85Z{a7sQ~ zzqMvbo;yF<4&RlzXP+wK=U7QVsy97fVu5K&VA{B;A=Z`*C!Fyyli#;P?EI)zF^tG!Vtk|%|LNh z*1+I!$zkQo^Z^DD0xF$-`}?tVd+5k(#`{>we3EwZ@-#eGoL0~CcdEcYEQ9byUu=Jl zdDWqlv=GxTP2K{ex|7aYLH68a7;AWnVazZOJVo=hsxa8yq&9X-()CWdc~_HGPsvBR zkT2T(OfX5}K0`L9N#tpqy)wYzie&ovT%!J7>#=q_(0WeAMgI+UqzX=|p7EqAn5%=T zX+n2Du}EZ87BU)Mjod&pW{myQ4V1l%waZYOzFQZYg@f z^DEk&w~8%#Hil$-GUj6DAiJzp?Y4xX)~gBJuHvn7@&b9;sY9Q?_+9i=v6ZIhk~Ut} zTz#&OTB6lo_Fc93tygzTOVvxxC{JFjl-qIam0g#J!y55||CVoBRxtOOwY>b|c4jrO zXXRu4%I;#MFG&HZS83yN1*It#k-D6g>8KHH4+C19v2be|jewVSSCJ_eNrUFivls|R zGi^`qrSmms31h}ERthTp_%#Bugtos7EQ_hZs4H(tvFp4sV#o|ZCf;X4X{5}BDdx9U znEB`trw?% z82;i7G9htkk)b)SFB!7H+p!l4la|CW((j2Yhpc&m!V!s*9bH8dZje?>hY5Ccl%o!M z@fg_w(Y>yXnytM^`4ZyMV8vZK++N$ z?#eOz$?Oe-Qo^1i?O|~IE`8NWBMsg}uh0ZXNU~BarI0yM4C1_MdpnwUcT}%l_g0n< zQlhccRf{Xis{>q^WfJdJuNTzwaN1KTTvH|PHbEYcI_k8OF&SKeKCxa8+snY)&Y+mQ z9qiUH_sJIlA7*KuF~#F4R5T6pvyh&xrZ4zN2N(pjrt)n$s$qRD?Pt(-EYc{+atG3S zDT?F1Mxd(vKJqFy^Zu1eH&eW3l?KXJK{BVc#$!vw1UoDp)<0AO??JSTe~J61pI^Cn z$G~nWew)il_4|`N)HoJ(v|FU}&214$<8};4;rmI7|3%IDQMGDijcGLCyTRhoZBTeNrZyE7<$i7gF^u>W!bY&G?mGjX1^40sL=!?9 zC*OBge3r)Xa+AR9p)b3HK5#~+OW)1b4=r)*(cT%!pa1=wto@thRfXRg{5>r0y*=zg zJ=);xy%QUM2_$c5Myi$@fnP=%J;K*i>8_3;8lh{CH>pDW=jf|(HjxpJJwt$MfJ`3j zK5Ck*E!3V3T(s?)1iZ3j@%AViI^;LdU#Zk5OSOjWf-{FsZ`F%6ZdwDXzf?@) zG8&+%A+6&1X=KO|doEQpbAJ#@FT1VznrIQ%cVgYo?q@u6DO) z42I7tIV*{Q@>g0{2nL{R>0C)MU5426g?jrBwH;m2fgzs`Ync`X@=OOKWv z;_XO+q83tPr|s$R4RB(TEjdyir$dNLkqIoZpp8VP$C)RU)k&6?rEzvkg8n)s*QdMu zb87pHgV6pO@JvS!kNUx$|8{>*+PTM$i&@Y#48KEl|mMuNr`VG zz2a)>_*xodCQXB6aAsbvm4}(JdN{}0758!SHRtGKR|{ETVVq<`j}-SKtmg8rpDW&7 zXq?>`=3P7XeQ)17Z|SOzCE&a|$$UT8BKO)cHbbcQsruEgU1JFctFvW$A1X;%tV$S_ zg?L*^nylpE#ilVetlHBR&m6*(ys1cA2*}Q?G}X0DNH@Cry1iG_{fYZ2=}YRh$5 zwp_D-_swLx^*V~CeP__PS9V$Di*dl%Rf8Shz~1Y7ksLTcu?_A`W0AIwzTqLewb(-Aw zr!I^wwbB&!n-Ic}$C^}>sKFJjn5-JNLqLh?PC)KIrrRl>Kjiz}5xKe-RzBO1Y6oOO zm(KIiq8|mga=6pZw9}THF#OTUxKA43gC4Al8`&f>kQC&W_BlvTvN(cpf+Uk0#~Q7S z&2N_M2qfxd1f-3!%;-MxM*_A6YyGn@$>hND8ja->^Hz3wH^>!g+qeSL7-Nu`e%Llq zBrw8T=Uc|YGKwl{t-|4el+ES)OjNX&* zA;f_qTHE6OET(@pCcAV0 zqj`Cj_dTUnz^&54_Tttv8sb&{L8Q5qUZ`7pAXMmmP^E3>Q#Bk#54 zFphg6nzQzc<-eV4JiX&W^lcU)EU~v%GUu`Q3g^M|a;c}z9vP&>3^L1hp0i!by*#AD zZug#9{C=sFmngj@&Y99R%zlNJw|sjLc4t~-E2Xh>b3Y2*JT96JU?j4YJzYOa$VZw6 zOQTGLZe-ER*Qqm$BfqPe`-RJ|)>^H#V&n;k65SJp@Y|iAT>XnRBV96x^{OeM1t^zP zOGmn7Jyu#PJ-hTSKuUMXa{U!ACpX~Clj{X2oSBl0+>j3ovYp_P^exC6laNW0tiSRP zZN&RO3z7~W$y5l6iR~ka(_f!Tok9fORtDg>7p=56+th9M_LcYMl-I=w9bA#K_2g(F z;>#yR3$v5rQnD?03LKf*%^gyxrwSfR=zHBsmWG90K`{xd?cQ0bW}ESEI9t4v!tpzA z)Dn9+WA5M#U+0fXd1IQPX{9F$lP7zhi^-F`Q^Ymey=Q^#f8Q&m+hp~x{4V}Wt9hHv ztMar6S$d{-u&_3H9lHRDtZoyh|NeU`!&8a?l_@kDm$e>guXS>F|HYydyL|UuDW@w%+ksVm2V%hE24R)z*8PGm|(3Bgu$(t_ulDE|1nzWfcCH6Y7XKP8h zaLUcZlCpK=g=3+bug$VBJ6R61nC$Mq(H>?0teQ_2N=N`H!0MrV!zFWZS8&X97*sL% zBHQIoQI~7K0)sBFlf)&w$-)ahGSo+J^zjt_Zu< z0;F(+E2lf%R(l+JwEsQtw`%U}LrBD+iI^gdB50@UU`S6?6EAlw`bFJaI?>m5iarX)z^C8-ugXR$?*r$gEzfd$jc8iv&`jo$E@W$ycar)?D8-tzdY z6(xs0NGZPIZ?iL(-1eWapw{CayPngDUzi`0gVdQcxjQI%OHh8b_Sk)BP1Z7Z8dUjd zn^nNpn!L5uD%fmIlF22I8~G&}KP=a-*-FAo$%I`vak5;UG7=ZVJA~3Ke$|r!F*t3c z>~5R{8MKY1;X^UZss>44B~6hmC2>|tGQXr^NmmK)s~M$RNfh)wa#CMeEtdT`rCd(; zq65s{W?6Y&8R>R90gNNUM8}XnpCVM=AUp$7(0Ri|3VM{t~iqzb&n{ zOgXpPyH1++R&K54gtXcn>k2BF42S9x4^Lib(fP9z8yLu9*+)lACwP;UH#G`pYgcf@ z?XSHZI}PQ}{gSJ+D~dX-t}{7%Lgzlfnv@^>$?XdszLM}>cH*L3Bbe#fJAc?%fmoVy z&~CqXlhW@l?-w^vb7n6F&Xv8TDbOi$a|I=o`!&kHgOquV2v*62MXqeVR=T~mOWW6| z$GL=~18~>TP5b=V^W7;{&SDu-?AU_cFN#R}bEMZMxv8Pnk_=_63DM*C`1Sm3MVGqQ zl!4e%4y$ALEXn=4Ycj(?3n_Ea0<5)EutFNIiawx>! zd{#vw%UQ20kffuRf9hL(C)?&}I_1xQc$4A#qp!6owVN z(S@-&#Xnv5N1xW5uJL$1AUibddobjLq1fjIogepe?oVOx7e{&5C~uR!7i6ivX=QdB za=*N>{VMUZ$;}{%TTF}@Sm6yM#!>!@FXi}dMN6yNt!v?(gY#bD-hbrZFW6<@cq6yxoW?cp3t-{(x>uB-Z)p$bS=-> z6=(BHyExb9>_#~l$ISTUORx)w zb;__4p?)2_9iyP+Baerq4xH}0{M3Pbl&@cgh#_}>C6V;2D6+BBUKOkGrpw2@zJtB= zF#Fh2AFfM0>*0!wwv757V~G2!OZJ|V*#~p~w)UrP<1IeR3XV|pVNCB%=~0oaMTu0TCQJzSyk4GEq{I9u!Ed>eOv%XT!%SCqz6IUx zR7ln|Z@Z#nrd+cNedg|u|46?zYd<2R-Kj{r$(w8>V?mnj({pC5{Zq;tA8CnKJ{vRc zZJM;-4ZB{;ZfVhT+@Tv!EIvzowoGS-?Y+N!qQph9C&?pNtxg^wX)3vL^2L_0N@S$4 zl&M6Z)-+6OaN6SUE4ebH@gz4z#i;hDJ*W&AQR(wP-7|HEiqQ{~ZTQT@ ztF~0GOvH`Fu@I+~B9m2>8HJd@G{<>rW~7t1F0#TaIrO{`SA(7y*}G9^c}Zz4MttwQN-)$bc0rE6Ez> zmlzjaZ_ZoDQq-UDs7SAp(jEp!DuIDTE?Irmy=6~|B6_~{pvY&vPa#s@cmtTq|b2h_R!kDX3&vdI}N_scU=SDsROZs z4|e2|Q6KOwK}I!r35$f(po0#U2KDUZnb3^?RAI{!Slzeo+vCmhE|;nwc>6Ki^E^Gw z&aW;henw>bB&=pioF`?>$GQmhK-$%$IlQ2RtZWB68cITKyjdlwyI4%1CU@p-Iq6r6 z%KY$=En06RZhOB-f%_v(QYe#D;k(Af=_a8}agZq>GE=}D6LLjoEGgFg(1 zzF89#u=8e*lsu&>0LLuJLXBeEAA~RZ(Jb|R7|%Ln)vtP@#>MJ|bc46nOiAm0CraP_ zCk)!=GW(1@)gUTF_qJ>Fw1u>i?IT)GO6i+INU8WhZ8BM9?ng+y(8sna*LviNutAMh zv2oOrLRgw#*f^rp+8Zv9@rv#lSeuC6lge7S4dwbL)Hbi}@{DOt$U5mb7%1Uc6Pun% zNnuCcs2cWG*EdEkU-MQbJ9D}#J4vcHBy&H)pi-n{m2O4D$6aR{$`DJ}<0zUqsB-qz zxpCa9P2#&xs`g%^Iom%sQBYUAq#6t{XJ7~-pmr6$nzcHijLyEalu-B{@g@gvf(o1u zJ2r4+-jp|Xs|pEtV%I?SxR^IOt=)ClFi^jq_Et%cbzJ~jP%mA>71Vx_*ifzH&JT{G@+FldAE@82ct$-y1RQW#t^K}A4jS&rk* z{nT!GX=;7>!?|H0e;Gci%&LQ6M}(|tOjS?}R>+owuw;d97C~gF6wxG^UoT4;S$$sG zr0Prmw#V8MDwQ82yF7i_w0pY~x)bi%YS$kjtB=%JxPG$96lt&;vTlIVrt#u1UMM6? z_*PZ}eCRoLO!8HIWK*gF@C0h)lT~Osr=W~1k4E?yagiWND)&;0zeBQ1i7B1V_@>Dk z-*zRZWW%ZelW-$}Oror0Sm!59id~IJaT5tsd7fHy*(r9vyhGWM%L)x4B}pbHCch?H zi`6>&VM$qkr!KIVe0?wd8q@NQa?PlA+>p1EI$t>89hy=}R+IBID0>_-zXBxxr)Wne z@@Lh+{MZsAsq^!U=qY-WQbV^zv?r{Tvb7T|13zQ>YWYx05lzP~$yhqV%f;M#2m%$P zLEmj2v8wir(?*a%yPF?J+6#+c}cZ1W`Cseu2-{y;Ku) zH>J=@a7C)l<2r2WT0e2h(A-2|HnLWbYB0pqf*}-!rSE?>v4x`t%OxH|&>r4sdCv=- z7t}YbpgqP|IXf<6ni7*kE;n`UVtsCTGup;@Vbv^Ht~En9=qq_YcP0IcY39G5W!ZYw zznEp&(<_HL+cib%G{Gq7u5uF0@idZ@JitHqbzFw#LaJ)muYk=Ui*-;Ni3|9Sgm_ zUNwF5+Z{Hh6pC%ww9k*c0hgx8yIZDTf5_w4$>OqBBdudt(ad#fwIS>cdQdD_Cbpqd z*OrxSTyJ?_4HloauH25*lE1C1D7t0!w#42_TDEnCG1>3e7H>mkjS`KXTubh>#gY*v z_1f{SsSIkzyACo-e`S{A{Cct3D@yf4+ZrOTws&1}|Faw^Ei2WQp;lFz${h&paBhdC z(tO{*IiTL#b3Y|_I^=a4_U!Ckdb;zCKf#HZo+h=w zNnB@Fytlp2DTt-u8T@pAz@NfXE|#k)`nor>9?y{kF3+niiq z719yd$f=_8X%~9XIeDti1R2#8_P@9}LJKYJ_iy?hpER+o37ztG$gZxG)(NTJjY!r? zOPr94kv98xesx>5`qp6`0Sb@Q{&qW3#&=^YgHM#h62vnz?tAj|%|A*IpeYioRahg_ z2D#b|3rlpg!&2U?+y*lbEYqEs%q9e1Y0Ix_*-O~DpWgLJ+vxG`I3KIIX?tsfQ9=ds z<5rG&?ebJSL_})iLnJIVe(pW!{`uP4L$D8yhoX9Tti;_T(?|HJ)%MxP>(-9VJl^T; z*aStDw)~ReD;S>eQ#IGf^fxaTc{{t0x8o8NwMP3$d1ZKEM#wR} zHT?2^93w}*!s=@uC6&585SA}AN^?Lr0>L+^2Y&rzRu7tly`M(zsh_d2b}hJnEb%1g zl6bdOX;x>NA;o$!lGyg-*VItt)rPABwT5?|S=nxQ|2xMmW-UOk>%74xk~rtc@+)B- zU(LZA(^Q@<_BKf%>NM7|Yny3kL>wBDc#gT|pN8IYPmFxX+`bf1z{LPU@ zeO*QKu6o^}-)TyGz7OHLMRZa^?@1 zi^eCpvSM*aNODD|^Y|Pok%u_3wlfK#`5#AT=iudm+8K2ZEN?f@^FO%jIvN!4XYtDx zbY7{7z&#T_HKKN_)zo)TgDgL$@U4@$Z^+jfse|M8s^GBBSAToXFZWR2EF2BYp{G}tn8hlIdeuR$k{s12Ou;vhEZ_1grBqy| z1lfJuK;QI|uHXJQHFI7b;?4U>1-aDARg7ItG@ER3c9$%*(s%LlkZPM~HVh{ECa-+@ z9m7_*4EW9!oGu)`oMh*Fng5;ZEe`Z4m6ut1kpi2?{uPGBB&iqgY_q8>>_tV}4Cy_A z*eR&^H-G=zYRamCobqRXSAeI){@X_EkU>AVqMvW2rm_CL%|2^ASq8aGlqnVVUpHfv zM~(MCZ~dmJ{I}kQ$=tp);-BBP1!czcP0EFQ9(p&W5x*c?cJH-Q$TnoW>MVZ6-6{9G z9{L#jny39Al!PB?WjEiJkw3Dw^dEQR=ZZYNMUTU4r`5fMzx%KM*sBNa;wFwi1u176I9?C#}t7n;pb$RiP`N*>gPc)y)mF;f^&(DrF8gE1JIy&>sk7gTo;S8nUr{_(85mok0Vc?I6gT4@0(zLB~r zDLIGWyemnYaOz3*Ar$6X`A(fvvUCWlp+(Ft?xAdKv-sGoJCx13Go{v0RH6g2e<%tU zdxVA&!XNVSFucY~zhQWtCx;=r8&tPj%x6FT`eE}UW3Q14RoZAVW}}^TsHCYB7QD8R zdV(JZ7j@`BT_sE9{?nhfMd|S9uV!zOon%=rt`af-Y(btKh?Q#CMLghUy`ZC2pW4PU z%Nkr=iVY{hQ>D^yVzo#0|G!;}5}2kG4eUR<7$pGKe{(fTDf?=aV0-ecVLyL0O0Zi$ zmGPpBQA%+a$=@zU8U4TYb^=XGs{09o9A?LkS+DrVX?EStY28cnKjt92=HdSyWiR)C z@+iA?m@rVrPjr{WP{5 zi)LwT=#&(xc4`X7vmD}>#^fPlDnoxhsXUd}n2)9s_F37Xj>TYE*bU)%dc3`X&9dNL zl%vNOasI3;-%dk8F`{$#G$Jw2xuz3IRe5-Y2)p%lWfi|`uC%-iUF-4>EyZ|U93FlnG=_acn?-JH7t3WpUTl=i^XhX^tD{FlUvdlRAp!WLBc!e-X_hVwJqww2L$ z`o#JrHZI`}X^CM+;wuur3^5ceqm~hCURksZVHPGA)hQ-9my?z9O>8smfOfsNkE)fe zwt7Z;XKwO=RA25&au0+-<4seuYJ5^(-?4^)xydNGx}1ROO4tgf2tDNbMIy;1lU5L= zwY@Rgy|cOP`IYKt=d81mVZX(f8KGaqp~%Oa@SXlwZwT*daGlido#{nv%lP;d#`k_l^jQO?8l2` zH^n}=PIxgZVNj!VK(=#L3iWx1MGD?qx0jXZV2`J1f15wrw_Lc+2&oq}SHB@;*HO6k z1XxJ8MNlv0Wy0dSvF?>;c|8(&A^)K>P5qP27?)TfDe{z zix|&3!7iP-+Y|HL)@GTP-)huVa&D$1hrDS|#Lm6nElqr_5qfK*RdFS`qow+0da7N) z(v&TUa%FR>t@^fh^6%GR>97gsN!VCf$rfGr>@RH-K+>vXO?GA5qPtqCa80gRx-;8a zSHY)N-*r9{%2bVxU@@Yn)nTJ(g5`DZxxL>^<{YHiTD#K!A8Sp~|3+&;xmQGPV@^(p(o8-JKT+~61Brs#jGRC@Es)A+F<7|n|HW+y}+kutMq@mh8^?r zox+3Ihpo#qNgK9btKz;h-`g>~yDc`YWo>)67z^8~W@{Eb1=(WPf2Dp#KflR8wesoN z-)~Q&U$W`<|7vbV?9pESKe|!%f12hNYzdB--aYK?EBRlY7Q6mx%X3yeu-eDQEb}I5 z@5T^Uns8lL*Hu8&k^b`dAcAZh47zAtHt4f*+a{i@s@FGL=Q+HrWs7L{0vL1wEd6KI zvgW-!{S*7nNj9>vS1aKVqv8b^a>0=K;KCn9M)od%h~p5Ms%iSYKV6s|^g;834dyP7 z%^MX}9WRcqm)XuzYkB zKjWnDQCECSg+yC}8l9%+;E+1Ma@YfP#9B0ak2gAnX1S zpV3T_7nR_XWTULaQ`YWxdf5%JK%aH9E^U2FFDx=g?X1f1)QRjo$wK!!m{qJF>{H-L zxjKE)rKH;4=`A6rTqW4DU;Pw`wJ+4>pzU|3(1SnkW5;uAV4J)TZuY%_SEFxz!5cpU z9krt9;&NT*zh(t$RZ<4IN zN2hxwUxiS`FJ1Du@L%?PcV7E|y>`1rR^rFK4~8r-Jji;*o%PqO6|B`Sy%*H$yxe>i zXfzxA%b%gkeBamZbn|9+&3AOkU%g0!p5%pnG;een?MBp@C7nvWxh3VzLTPl)le%Zc zu|*D_q4Xz8=Ci!3A>Cn-H_p<$#!0=iyeTGw^i64*rH-|S(K3le%W);VXsfA7B-cM% z5n6%j<|DlvTsEIWdnzoE=h@Y@c#u5^m+dqp{jg1M?Dlr(RRrwUXYQi9Qpxo&XsHaG z*yrN++t#xQR%H+8-@RV07X2|ucAj@7dNh-N8;Oj*z==ECSCsf%BAp4CmQDSaWja4{()cVe8>wZ=!w7{i z*>r^lV_P{rk_!7@R%^|VjYZfov-#TXzM_13^_e(xSSG_@@9iVx+*MRW`=?a7MoTe! zKTgY{Yg|C{#wc5W7A`ZTc7eKgbPCGd4!T$J^Eoqqo@A!z5F*{|*L zkyi#0f+mO-=hd1ws%RHEnX6LHh%wVr!v03CY6#ZajlE&k+RGO2NPXe@#2Yl7e{oXQ zojBSq^~%}Tr9YUbkltdWch%BsQ=PVz4mXin_5peCjiVQ@z3K9HxO;K1~;cIIh z%(Vm0D#(#K1LgRiB+t~=2>4WzckA%WmNyPg*$AJWw1jb*6uU*AngoLyCtBUD=WL$T zRo4-9z-Bz2!y9cC%j@kJyJz$SKi-%PKg-6*SGPE=xDgKB__V)x;+5Vt&Ypq8o(_BM z9V7GL@IEz0%H3r^xe2FA(WtD6{Y-fl z`BxZ^tRDwOQ)dT=w(llfvx8DTzvIf`{cwVOdxyPPNpe}EeBt41OMp8i^e=Y4Wt(J| z%*6Rm(?9ICuQF*u9BY0#%Ukm=SCpRV?St!RB+LJ@Mu&8FNvbI?_IzpONV~gKeN&mD zuSQRkflgoh(v?}$25B2h?6_PdORQ@X6tE!%-3IiB~w=4VD~I?qYZ9% z&fA}QO-k35Xz9+nOdt}c43lTr37)<6NYI7V@tIN>vdIKLLbpzgd(WRCI~~43A<3}o z;==YpKI!(CKG7qEedi1rnAJDPlxdLn(HXM)A)5p2!|U#Ac+85&!@c)9wyc}N=<7*{ zm*jjzr+GoiQDkake|Zw^`U}zv8vJG4XqT^ye?&WYGIMCSZ;&ZVaXm(8nDR3PXPq6+ z?&2dTx{);H!U}Jb35Tz&d`z2sp|wfZX4q8>O zL!K6*_aAY4w!>a;b03{;i<|+Yb02+_7&DMKRNJTDS@%WOEycJ+*2=Hh*OmW)oD(y`o$^X;D0wr=YR3tD{n5gAeIHO?=9b`38rKf-wu#_^7eh zgEyWA4uO#&T63RT;CM3P{qrv}J{=iKFKyG~{{_v!>`_ntMXo8M-Q04}++HG*?9Uoh zN$;EXxdmz~c)f>(JL9=!j9Ds8VO4r7Vj4)W+(=J{uoEHqGVpADL?${!7TGPgOW!Y6 zX1nsB9~m+ml{_i86Xe5W-KvylJ*N4uwjRIg(?bj7iTFrGiy75ZX>5NRPCT{i{(ZH*rTMvAeT<@F66z_uSg%y~lX(BO zL{kVIoVVdzeu^_zjQjIJhu6yKu1<hi{a9FN^LRvqhnGr+j0X3rkFwWPAQsc4OK?HPUWkPVBKY_UDZb z&N&CY*XGmlgTG99vr-~eWwJCj4b}WMBXL7FE%?xC+jI4T;dvL0tYe+~vy*1&Tjm;D zqwZah9U7jUXxfJ(B&EZq(&*huYix}@S8M+P58tR4Xy;g4wb8q6m7fowsGjVprx(ru zD(CdA7l{+{OzjdaO z6jn`VGTvO~;+P(F@iYJ;yWf;gDS=Zv1T+6?C@XfT%19`8r u(##F22NoLs_HP$L?eb`GX7PjEzHPmG_sHwJxJTZ!@Ah9DxFBtx68{URRu0Sn diff --git a/package.json b/package.json index b7a62df5..d354102c 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "expo-haptics": "~13.0.1", "expo-image": "~1.12.15", "expo-keep-awake": "~13.0.2", + "expo-linear-gradient": "~13.0.2", "expo-linking": "~6.3.1", "expo-navigation-bar": "~3.0.7", "expo-router": "~3.5.23", From 8eac2f39a85e7df040d8754f379d2ffc58a90bb3 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Wed, 28 Aug 2024 09:33:48 +0200 Subject: [PATCH 30/46] fix: move button --- README.md | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) 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. From 4641ff726ca5f3e0dc629d6b72829e4360497956 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Wed, 28 Aug 2024 22:00:50 +0200 Subject: [PATCH 31/46] wip: design refactor --- .../(home,libraries,search)/series/[id].tsx | 2 +- components/AudioTrackSelector.tsx | 21 +- components/BitrateSelector.tsx | 47 ++- components/Chromecast.tsx | 2 +- components/DownloadItem.tsx | 304 ++++++++++++------ components/ItemContent.tsx | 264 +++++++++------ components/ItemHeader.tsx | 22 +- components/MediaSourceSelector.tsx | 24 +- components/ParallaxPage.tsx | 11 +- components/PlayButton.tsx | 109 +++++-- components/SubtitleTrackSelector.tsx | 26 +- components/common/HeaderBackButton.tsx | 2 +- components/series/SeasonPicker.tsx | 70 ++-- components/settings/SettingToggles.tsx | 4 +- utils/atoms/primaryColor.ts | 4 +- utils/jellyfin/media/getStreamUrl.ts | 2 +- 16 files changed, 580 insertions(+), 334 deletions(-) diff --git a/app/(auth)/(tabs)/(home,libraries,search)/series/[id].tsx b/app/(auth)/(tabs)/(home,libraries,search)/series/[id].tsx index 15ae2870..73870886 100644 --- a/app/(auth)/(tabs)/(home,libraries,search)/series/[id].tsx +++ b/app/(auth)/(tabs)/(home,libraries,search)/series/[id].tsx @@ -61,7 +61,7 @@ const page: React.FC = () => { return ( = ({ }, []); return ( - + - + Audio - - - - {tc(selectedAudioSteam?.DisplayTitle, 7)} - - - + + + {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 ( - + - + Quality - - - - {BITRATES.find((b) => b.value === selected.value)?.key} - - - + + + {BITRATES.find((b) => b.value === selected.value)?.key} + + Bitrates - {BITRATES?.map((b, index: number) => ( + {sorted.map((b) => ( { onChange(b); }} diff --git a/components/Chromecast.tsx b/components/Chromecast.tsx index f3ff8f16..60c4400d 100644 --- a/components/Chromecast.tsx +++ b/components/Chromecast.tsx @@ -39,7 +39,7 @@ export const Chromecast: React.FC = ({ if (background === "transparent") return ( diff --git a/components/DownloadItem.tsx b/components/DownloadItem.tsx index d2ff0893..a07c0e9a 100644 --- a/components/DownloadItem.tsx +++ b/components/DownloadItem.tsx @@ -2,8 +2,17 @@ import { useRemuxHlsToMp4 } from "@/hooks/useRemuxHlsToMp4"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { runningProcesses } from "@/utils/atoms/downloads"; import { queueActions, queueAtom } from "@/utils/atoms/queue"; -import { getPlaybackInfo } from "@/utils/jellyfin/media/getPlaybackInfo"; +import { useSettings } from "@/utils/atoms/settings"; +import ios from "@/utils/profiles/ios"; +import native from "@/utils/profiles/native"; +import old from "@/utils/profiles/old"; import Ionicons from "@expo/vector-icons/Ionicons"; +import { + BottomSheetBackdrop, + BottomSheetBackdropProps, + BottomSheetModal, + BottomSheetView, +} from "@gorhom/bottom-sheet"; import { BaseItemDto, MediaSourceInfo, @@ -12,19 +21,16 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; import { useQuery } from "@tanstack/react-query"; import { router } from "expo-router"; import { useAtom } from "jotai"; -import { - TouchableOpacity, - TouchableOpacityProps, - View, - ViewProps, -} from "react-native"; +import { useCallback, useMemo, useRef, useState } from "react"; +import { TouchableOpacity, View, ViewProps } from "react-native"; +import { AudioTrackSelector } from "./AudioTrackSelector"; +import { Bitrate, BitrateSelector } from "./BitrateSelector"; +import { Button } from "./Button"; +import { Text } from "./common/Text"; import { Loader } from "./Loader"; +import { MediaSourceSelector } from "./MediaSourceSelector"; import ProgressCircle from "./ProgressCircle"; -import { DownloadQuality, useSettings } from "@/utils/atoms/settings"; -import { useCallback } from "react"; -import ios from "@/utils/profiles/ios"; -import native from "@/utils/profiles/native"; -import old from "@/utils/profiles/old"; +import { SubtitleTrackSelector } from "./SubtitleTrackSelector"; interface DownloadProps extends ViewProps { item: BaseItemDto; @@ -35,100 +41,134 @@ export const DownloadItem: React.FC = ({ item, ...props }) => { const [user] = useAtom(userAtom); const [process] = useAtom(runningProcesses); const [queue, setQueue] = useAtom(queueAtom); - const [settings] = useSettings(); - const { startRemuxing } = useRemuxHlsToMp4(item); - const initiateDownload = useCallback( - async (qualitySetting: DownloadQuality) => { - if (!api || !user?.Id || !item.Id) { - throw new Error( - "DownloadItem ~ initiateDownload: No api or user or item" - ); - } + const [selectedMediaSource, setSelectedMediaSource] = + useState(null); + const [selectedAudioStream, setSelectedAudioStream] = useState(-1); + const [selectedSubtitleStream, setSelectedSubtitleStream] = + useState(0); + const [maxBitrate, setMaxBitrate] = useState({ + key: "Max", + value: undefined, + }); - let deviceProfile: any = ios; + /** + * Bottom sheet + */ + const bottomSheetModalRef = useRef(null); + const snapPoints = useMemo(() => ["50%"], []); - if (settings?.deviceProfile === "Native") { - deviceProfile = native; - } else if (settings?.deviceProfile === "Old") { - deviceProfile = old; - } + const handlePresentModalPress = useCallback(() => { + bottomSheetModalRef.current?.present(); + }, []); - let maxStreamingBitrate: number | undefined = undefined; + const handleSheetChanges = useCallback((index: number) => { + console.log("handleSheetChanges", index); + }, []); - if (qualitySetting === "high") { - maxStreamingBitrate = 8000000; - } else if (qualitySetting === "low") { - maxStreamingBitrate = 2000000; - } + const closeModal = useCallback(() => { + bottomSheetModalRef.current?.dismiss(); + }, []); - const response = await api.axiosInstance.post( - `${api.basePath}/Items/${item.Id}/PlaybackInfo`, - { - DeviceProfile: deviceProfile, - UserId: user.Id, - MaxStreamingBitrate: maxStreamingBitrate, - StartTimeTicks: 0, - EnableTranscoding: maxStreamingBitrate ? true : undefined, - AutoOpenLiveStream: true, - MediaSourceId: item.Id, - AllowVideoStreamCopy: maxStreamingBitrate ? false : true, - }, - { - headers: { - Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`, - }, - } + /** + * Start download + */ + const initiateDownload = useCallback(async () => { + if (!api || !user?.Id || !item.Id || !selectedMediaSource?.Id) { + throw new Error( + "DownloadItem ~ initiateDownload: No api or user or item" ); + } - let url: string | undefined = undefined; + let deviceProfile: any = ios; - const mediaSource = response.data.MediaSources?.[0] as MediaSourceInfo; + if (settings?.deviceProfile === "Native") { + deviceProfile = native; + } else if (settings?.deviceProfile === "Old") { + deviceProfile = old; + } - if (!mediaSource) { - throw new Error("No media source"); + const response = await api.axiosInstance.post( + `${api.basePath}/Items/${item.Id}/PlaybackInfo`, + { + DeviceProfile: deviceProfile, + UserId: user.Id, + MaxStreamingBitrate: maxBitrate.value, + StartTimeTicks: 0, + EnableTranscoding: maxBitrate.value ? true : undefined, + AutoOpenLiveStream: true, + AllowVideoStreamCopy: maxBitrate.value ? false : true, + MediaSourceId: selectedMediaSource?.Id, + AudioStreamIndex: selectedAudioStream, + SubtitleStreamIndex: selectedSubtitleStream, + }, + { + headers: { + Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`, + }, } + ); - if (mediaSource.SupportsDirectPlay) { - if (item.MediaType === "Video") { - console.log("Using direct stream for video!"); - url = `${api.basePath}/Videos/${item.Id}/stream.mp4?mediaSourceId=${item.Id}&static=true`; - } else if (item.MediaType === "Audio") { - console.log("Using direct stream for audio!"); - const searchParams = new URLSearchParams({ - UserId: user.Id, - DeviceId: api.deviceInfo.id, - MaxStreamingBitrate: "140000000", - Container: - "opus,webm|opus,mp3,aac,m4a|aac,m4b|aac,flac,webma,webm|webma,wav,ogg", - TranscodingContainer: "mp4", - TranscodingProtocol: "hls", - AudioCodec: "aac", - api_key: api.accessToken, - StartTimeTicks: "0", - EnableRedirection: "true", - EnableRemoteMedia: "false", - }); - url = `${api.basePath}/Audio/${ - item.Id - }/universal?${searchParams.toString()}`; - } + let url: string | undefined = undefined; + + const mediaSource: MediaSourceInfo = response.data.MediaSources.find( + (source: MediaSourceInfo) => source.Id === selectedMediaSource?.Id + ); + + if (!mediaSource) { + throw new Error("No media source"); + } + + if (mediaSource.SupportsDirectPlay) { + if (item.MediaType === "Video") { + console.log("Using direct stream for video!"); + url = `${api.basePath}/Videos/${item.Id}/stream.mp4?mediaSourceId=${item.Id}&static=true&mediaSourceId=${mediaSource.Id}&deviceId=${api.deviceInfo.id}&api_key=${api.accessToken}`; + } else if (item.MediaType === "Audio") { + console.log("Using direct stream for audio!"); + const searchParams = new URLSearchParams({ + UserId: user.Id, + DeviceId: api.deviceInfo.id, + MaxStreamingBitrate: "140000000", + Container: + "opus,webm|opus,mp3,aac,m4a|aac,m4b|aac,flac,webma,webm|webma,wav,ogg", + TranscodingContainer: "mp4", + TranscodingProtocol: "hls", + AudioCodec: "aac", + api_key: api.accessToken, + StartTimeTicks: "0", + EnableRedirection: "true", + EnableRemoteMedia: "false", + }); + url = `${api.basePath}/Audio/${ + item.Id + }/universal?${searchParams.toString()}`; } + } - if (mediaSource.TranscodingUrl) { - console.log("Using transcoded stream!"); - url = `${api.basePath}${mediaSource.TranscodingUrl}`; - } else { - throw new Error("No transcoding url"); - } + if (mediaSource.TranscodingUrl) { + console.log("Using transcoded stream!"); + url = `${api.basePath}${mediaSource.TranscodingUrl}`; + } else { + throw new Error("No transcoding url"); + } - return await startRemuxing(url); - }, - [api, item, startRemuxing, user?.Id] - ); + return await startRemuxing(url); + }, [ + api, + item, + startRemuxing, + user?.Id, + selectedMediaSource, + selectedAudioStream, + selectedSubtitleStream, + maxBitrate, + ]); + /** + * Check if item is downloaded + */ const { data: downloaded, isFetching } = useQuery({ queryKey: ["downloaded", item.Id], queryFn: async () => { @@ -143,6 +183,17 @@ export const DownloadItem: React.FC = ({ item, ...props }) => { enabled: !!item.Id, }); + const renderBackdrop = useCallback( + (props: BottomSheetBackdropProps) => ( + + ), + [] + ); + return ( = ({ item, ...props }) => { ) : ( - { - queueActions.enqueue(queue, setQueue, { - id: item.Id!, - execute: async () => { - 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 index f64e17a2..536d43e6 100644 --- a/components/ItemContent.tsx +++ b/components/ItemContent.tsx @@ -32,16 +32,23 @@ import { useCastDevice } from "react-native-google-cast"; import { Chromecast } from "./Chromecast"; import { ItemHeader } from "./ItemHeader"; import { MediaSourceSelector } from "./MediaSourceSelector"; -import { useImageColors } from "@/hooks/useImageColors"; +import Animated, { + useSharedValue, + useAnimatedStyle, + withTiming, + runOnJS, +} from "react-native-reanimated"; +import { Loader } from "./Loader"; +import { set } from "lodash"; export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => { const [api] = useAtom(apiAtom); const [user] = useAtom(userAtom); - const [settings] = useSettings(); + const opacity = useSharedValue(0); const castDevice = useCastDevice(); const navigation = useNavigation(); - + const [settings] = useSettings(); const [selectedMediaSource, setSelectedMediaSource] = useState(null); const [selectedAudioStream, setSelectedAudioStream] = useState(-1); @@ -52,6 +59,27 @@ export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => { value: undefined, }); + const [loadingImage, setLoadingImage] = useState(true); + const [loadingLogo, setLoadingLogo] = useState(true); + + 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(0); const { @@ -70,9 +98,32 @@ export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => { return res; }, enabled: !!id && !!api, - staleTime: 60 * 1000, + 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: () => @@ -88,7 +139,7 @@ export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => { useEffect(() => { if (item?.Type === "Episode") headerHeightRef.current = 400; - else headerHeightRef.current = 500; + else if (item?.Type === "Movie") headerHeightRef.current = 500; }, [item]); const { data: sessionData } = useQuery({ @@ -155,110 +206,123 @@ export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => { const logoUrl = useMemo(() => getLogoImageUrlById({ api, item }), [item]); - const loading = useMemo( - () => isLoading || isFetching, - [isLoading, isFetching] - ); + const loading = useMemo(() => { + return Boolean( + isLoading || isFetching || loadingImage || (logoUrl && loadingLogo) + ); + }, [isLoading, isFetching, loadingImage, loadingLogo, logoUrl]); return ( - - {item ? ( - - ) : ( - - )} - - } - logo={ - <> - {logoUrl ? ( - - ) : null} - - } - > - - - - - {item ? ( - - setMaxBitrate(val)} - selected={maxBitrate} + + {loading && ( + + + + )} + + + {localItem && ( + setLoadingImage(false)} + onError={() => setLoadingImage(false)} + /> + )} + + + } + logo={ + <> + {logoUrl ? ( + setLoadingLogo(false)} + onError={() => setLoadingLogo(false)} /> - - {selectedMediaSource && ( - - + } + > + + + + + {localItem ? ( + + setMaxBitrate(val)} + selected={maxBitrate} /> - + {selectedMediaSource && ( + <> + + + + )} + + ) : ( + + + )} - - ) : ( - - - - + + + + + + {item?.Type === "Episode" && ( + )} - + + + + + {item?.Type === "Episode" && ( + + )} + + + - - {item?.Type === "Episode" && ( - - )} - - - - - - {item?.Type === "Episode" && ( - - )} - - - - - + + ); }); diff --git a/components/ItemHeader.tsx b/components/ItemHeader.tsx index 1eb15d64..dcffe023 100644 --- a/components/ItemHeader.tsx +++ b/components/ItemHeader.tsx @@ -11,17 +11,25 @@ interface Props extends ViewProps { 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 index 44a3766a..b32ceb4b 100644 --- a/components/MediaSourceSelector.tsx +++ b/components/MediaSourceSelector.tsx @@ -37,16 +37,19 @@ export const MediaSourceSelector: React.FC = ({ }, [mediaSources]); return ( - + - + Video - - - {tc(selectedMediaSource, 7)} - - + + {selectedMediaSource} + = ({ onChange(source); }} > - - { - source.MediaStreams?.find((s) => s.Type === "Video") - ?.DisplayTitle - } - + {source.Name} ))} diff --git a/components/ParallaxPage.tsx b/components/ParallaxPage.tsx index 764ed9ef..daebca6b 100644 --- a/components/ParallaxPage.tsx +++ b/components/ParallaxPage.tsx @@ -1,6 +1,6 @@ import { LinearGradient } from "expo-linear-gradient"; import { type PropsWithChildren, type ReactElement } from "react"; -import { View } from "react-native"; +import { View, ViewProps } from "react-native"; import Animated, { interpolate, useAnimatedRef, @@ -8,19 +8,20 @@ import Animated, { useScrollViewOffset, } from "react-native-reanimated"; -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); @@ -47,7 +48,7 @@ export const ParallaxScrollView: React.FC = ({ }); return ( - + { item?: BaseItemDto | null; @@ -26,6 +34,47 @@ export const PlayButton: React.FC = ({ item, url, ...props }) => { const [color] = useAtom(itemThemeColorAtom); + // Create a shared value for animation progress + const progress = useSharedValue(0); + + // Create shared values for start and end colors + const startColor = useSharedValue(color); + const endColor = useSharedValue(color); + + useEffect(() => { + // When color changes, update end color and animate progress + endColor.value = color; + progress.value = 0; // Reset progress + progress.value = withTiming(1, { duration: 300 }); // Animate to 1 over 500ms + }, [color]); + + // Animated style for primary color + const animatedPrimaryStyle = useAnimatedStyle(() => ({ + backgroundColor: interpolateColor( + progress.value, + [0, 1], + [startColor.value.average, endColor.value.average] + ), + })); + + // Animated style for text color + const animatedTextStyle = useAnimatedStyle(() => ({ + color: interpolateColor( + progress.value, + [0, 1], + [startColor.value.text, endColor.value.text] + ), + })); + + // Update start color after animation completes + useEffect(() => { + const timeout = setTimeout(() => { + startColor.value = color; + }, 500); // Should match the duration in withTiming + + return () => clearTimeout(timeout); + }, [color]); + const onPress = async () => { if (!url || !item) return; @@ -85,37 +134,43 @@ export const PlayButton: React.FC = ({ item, url, ...props }) => { return ( + + - - + className="flex flex-row items-center justify-center bg-transparent rounded-xl z-20 h-12 w-full " + > - + {runtimeTicksToMinutes(item?.RunTimeTicks)} - - - {client && } + + + + + {client && ( + + + + )} diff --git a/components/SubtitleTrackSelector.tsx b/components/SubtitleTrackSelector.tsx index c70ad7dc..3b53a7d9 100644 --- a/components/SubtitleTrackSelector.tsx +++ b/components/SubtitleTrackSelector.tsx @@ -44,20 +44,24 @@ export const SubtitleTrackSelector: React.FC = ({ if (subtitleStreams.length === 0) return null; return ( - + - + Subtitle - - - - {selectedSubtitleSteam - ? tc(selectedSubtitleSteam?.DisplayTitle, 7) - : "None"} - - - + + + {selectedSubtitleSteam + ? tc(selectedSubtitleSteam?.DisplayTitle, 7) + : "None"} + + = ({ return ( router.back()} - className=" bg-black rounded-full p-2 border border-neutral-900" + className=" bg-neutral-800/80 rounded-full p-2" {...touchableOpacityProps} > = ({ item, initialSeasonIndex }) => { const { data: episodes, isFetching } = useQuery({ queryKey: ["episodes", item.Id, selectedSeasonId], queryFn: async () => { - if (!api || !user?.Id || !item.Id) return []; - const response = await api.axiosInstance.get( - `${api.basePath}/Shows/${item.Id}/Episodes`, - { - params: { - userId: user?.Id, - seasonId: selectedSeasonId, - Fields: - "ItemCounts,PrimaryImageAspectRatio,CanDelete,MediaSourceCount,Overview", - }, - headers: { - Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`, - }, - } - ); + if (!api || !user?.Id || !item.Id || !selectedSeasonId) return []; + const res = await getTvShowsApi(api).getEpisodes({ + seriesId: item.Id, + userId: user.Id, + seasonId: selectedSeasonId, + enableUserData: true, + fields: ["MediaSources", "MediaStreams", "Overview"], + }); - return response.data.Items as BaseItemDto[]; + return res.data.Items; }, enabled: !!api && !!user?.Id && !!item.Id && !!selectedSeasonId, }); + const queryClient = useQueryClient(); + useEffect(() => { + for (let e of episodes || []) { + queryClient.prefetchQuery({ + queryKey: ["item", e.Id], + queryFn: async () => { + if (!e.Id) return; + const res = await getUserItemData({ + api, + userId: user?.Id, + itemId: e.Id, + }); + return res; + }, + staleTime: 60 * 5 * 1000, + }); + } + }, [episodes]); + // Used for height calculation const [nrOfEpisodes, setNrOfEpisodes] = useState(0); useEffect(() => { @@ -164,26 +180,6 @@ export const SeasonPicker: React.FC = ({ item, initialSeasonIndex }) => { ))} - {/* Old View. Might have a setting later to manually select view. */} - {/* {episodes && ( - - ( - { - router.push(`/(auth)/items/${item.Id}`); - }} - className="flex flex-col w-48" - > - - - - )} - /> - - )} */} {isFetching ? ( { onValueChange={(value) => updateSettings({ autoRotate: value })} /> - { ))} - + */} Start videos in fullscreen diff --git a/utils/atoms/primaryColor.ts b/utils/atoms/primaryColor.ts index d7036163..99dbc709 100644 --- a/utils/atoms/primaryColor.ts +++ b/utils/atoms/primaryColor.ts @@ -62,8 +62,8 @@ export const itemThemeColorAtom = atom( const newColors = { ...currentColors, ...update }; // Recalculate text color if primary color changes - if (update.primary) { - newColors.text = calculateTextColor(update.primary); + if (update.average) { + newColors.text = calculateTextColor(update.average); } set(baseThemeColorAtom, newColors); diff --git a/utils/jellyfin/media/getStreamUrl.ts b/utils/jellyfin/media/getStreamUrl.ts index 1ca210a7..075d486f 100644 --- a/utils/jellyfin/media/getStreamUrl.ts +++ b/utils/jellyfin/media/getStreamUrl.ts @@ -79,7 +79,7 @@ export const getStreamUrl = async ({ if (mediaSource.SupportsDirectPlay || forceDirectPlay === true) { if (item.MediaType === "Video") { console.log("Using direct stream for video!"); - return `${api.basePath}/Videos/${itemId}/stream.mp4?playSessionId=${sessionData.PlaySessionId}&mediaSourceId=${mediaSource.Id}&static=true`; + return `${api.basePath}/Videos/${itemId}/stream.mp4?playSessionId=${sessionData.PlaySessionId}&mediaSourceId=${mediaSource.Id}&static=true&subtitleStreamIndex=${subtitleStreamIndex}&audioStreamIndex=${audioStreamIndex}&deviceId=${api.deviceInfo.id}&api_key=${api.accessToken}`; } else if (item.MediaType === "Audio") { console.log("Using direct stream for audio!"); const searchParams = new URLSearchParams({ From 30348dc28ff9425aa14a7022ba0f367d6e0dc5f7 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Wed, 28 Aug 2024 22:11:54 +0200 Subject: [PATCH 32/46] chore: version --- app.json | 4 ++-- eas.json | 4 ++-- providers/JellyfinProvider.tsx | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/app.json b/app.json index 3cd8abbb..2a9b1e60 100644 --- a/app.json +++ b/app.json @@ -2,7 +2,7 @@ "expo": { "name": "Streamyfin", "slug": "streamyfin", - "version": "0.9.0", + "version": "0.10.0", "orientation": "default", "icon": "./assets/images/icon.png", "scheme": "streamyfin", @@ -33,7 +33,7 @@ }, "android": { "jsEngine": "hermes", - "versionCode": 27, + "versionCode": 29, "adaptiveIcon": { "foregroundImage": "./assets/images/icon.png" }, diff --git a/eas.json b/eas.json index bd215c2a..299b5e9c 100644 --- a/eas.json +++ b/eas.json @@ -21,13 +21,13 @@ } }, "production": { - "channel": "0.9.0", + "channel": "0.10.0", "android": { "image": "latest" } }, "production-apk": { - "channel": "0.9.0", + "channel": "0.10.0", "android": { "buildType": "apk", "image": "latest" diff --git a/providers/JellyfinProvider.tsx b/providers/JellyfinProvider.tsx index 83350dd1..25e1f510 100644 --- a/providers/JellyfinProvider.tsx +++ b/providers/JellyfinProvider.tsx @@ -62,7 +62,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ setJellyfin( () => new Jellyfin({ - clientInfo: { name: "Streamyfin", version: "0.9.0" }, + clientInfo: { name: "Streamyfin", version: "0.10.0" }, deviceInfo: { name: Platform.OS === "ios" ? "iOS" : "Android", id }, }) ); @@ -80,7 +80,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ return { authorization: `MediaBrowser Client="Streamyfin", Device=${ Platform.OS === "android" ? "Android" : "iOS" - }, DeviceId="${deviceId}", Version="0.9.0"`, + }, DeviceId="${deviceId}", Version="0.10.0"`, }; }, [deviceId]); From d52f025873c1b45245fe0ef9f9c725c7761dea81 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Thu, 29 Aug 2024 08:40:55 +0200 Subject: [PATCH 33/46] fix: landscape design --- app/(auth)/(tabs)/(home)/downloads.tsx | 12 +++++- app/(auth)/(tabs)/(home)/index.tsx | 14 ++++++- app/(auth)/(tabs)/(home)/settings.tsx | 12 +++++- app/(auth)/(tabs)/(libraries)/[libraryId].tsx | 42 ++++++++++++++----- app/(auth)/(tabs)/(libraries)/index.tsx | 5 +++ app/(auth)/(tabs)/(search)/index.tsx | 6 +++ utils/jellyfin/media/getStreamUrl.ts | 2 +- 7 files changed, 77 insertions(+), 16 deletions(-) diff --git a/app/(auth)/(tabs)/(home)/downloads.tsx b/app/(auth)/(tabs)/(home)/downloads.tsx index 69467450..05765a97 100644 --- a/app/(auth)/(tabs)/(home)/downloads.tsx +++ b/app/(auth)/(tabs)/(home)/downloads.tsx @@ -13,6 +13,7 @@ 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 ( @@ -63,7 +66,14 @@ const downloads: React.FC = () => { return ( - + Queue diff --git a/app/(auth)/(tabs)/(home)/index.tsx b/app/(auth)/(tabs)/(home)/index.tsx index 749253f0..72688d26 100644 --- a/app/(auth)/(tabs)/(home)/index.tsx +++ b/app/(auth)/(tabs)/(home)/index.tsx @@ -18,7 +18,8 @@ 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; @@ -270,6 +271,8 @@ export default function index() { // ); // } + const insets = useSafeAreaInsets(); + if (e1 || e2) return ( @@ -286,6 +289,7 @@ export default function index() { ); + return ( } > - + {sections.map((section, index) => { diff --git a/app/(auth)/(tabs)/(home)/settings.tsx b/app/(auth)/(tabs)/(home)/settings.tsx index ce8d5df5..88667016 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,18 @@ export default function settings() { refetchInterval: 1000, }); + const insets = useSafeAreaInsets(); + return ( - + Information diff --git a/app/(auth)/(tabs)/(libraries)/[libraryId].tsx b/app/(auth)/(tabs)/(libraries)/[libraryId].tsx index 3f96282e..327d11b5 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,7 @@ 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"; const MemoizedTouchableItemRouter = React.memo(TouchableItemRouter); @@ -49,6 +55,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); @@ -60,6 +67,14 @@ const Page = () => { ScreenOrientation.Orientation.PORTRAIT_UP ); + 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]); + useLayoutEffect(() => { setSortBy([ { @@ -193,18 +208,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 ( @@ -401,9 +419,7 @@ const Page = () => { renderItem={renderItem} keyExtractor={keyExtractor} estimatedItemSize={244} - numColumns={ - orientation === ScreenOrientation.Orientation.PORTRAIT_UP ? 3 : 5 - } + numColumns={getNumberOfColumns()} onEndReached={() => { if (hasNextPage) { fetchNextPage(); @@ -411,7 +427,11 @@ const Page = () => { }} onEndReachedThreshold={1} ListHeaderComponent={ListHeaderComponent} - contentContainerStyle={{ paddingBottom: 24 }} + contentContainerStyle={{ + paddingBottom: 24, + paddingLeft: insets.left, + paddingRight: insets.right, + }} ItemSeparatorComponent={() => ( @@ -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 3e9dfd79..5975c9e6 100644 --- a/app/(auth)/(tabs)/(search)/index.tsx +++ b/app/(auth)/(tabs)/(search)/index.tsx @@ -28,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 = [ @@ -41,6 +42,7 @@ const exampleSearches = [ export default function search() { const params = useLocalSearchParams(); + const insets = useSafeAreaInsets(); const { q, prev } = params as { q: string; prev: Href }; @@ -220,6 +222,10 @@ export default function search() { {Platform.OS === "android" && ( diff --git a/utils/jellyfin/media/getStreamUrl.ts b/utils/jellyfin/media/getStreamUrl.ts index 075d486f..38d9fb2e 100644 --- a/utils/jellyfin/media/getStreamUrl.ts +++ b/utils/jellyfin/media/getStreamUrl.ts @@ -31,7 +31,7 @@ export const getStreamUrl = async ({ subtitleStreamIndex?: number; forceDirectPlay?: boolean; height?: number; - mediaSourceId?: string | null; + mediaSourceId: string | null; }) => { if (!api || !userId || !item?.Id || !mediaSourceId) { return null; From 79b87b3d726caae708b9b6f4ecbaf83e231cbf22 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Thu, 29 Aug 2024 08:42:16 +0200 Subject: [PATCH 34/46] fix: song pages --- .../albums/[albumId].tsx | 48 ++-- .../artists/[artistId].tsx | 79 +++-- .../songs/[songId].tsx | 271 ------------------ components/music/SongsListItem.tsx | 12 +- components/stacks/NestedTabPageStack.tsx | 1 - 5 files changed, 75 insertions(+), 336 deletions(-) delete mode 100644 app/(auth)/(tabs)/(home,libraries,search)/songs/[songId].tsx 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)/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/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/stacks/NestedTabPageStack.tsx b/components/stacks/NestedTabPageStack.tsx index 930c4bfc..32caef76 100644 --- a/components/stacks/NestedTabPageStack.tsx +++ b/components/stacks/NestedTabPageStack.tsx @@ -17,7 +17,6 @@ const routes = [ "artists/[artistId]", "collections/[collectionId]", "items/page", - "songs/[songId]", "series/[id]", ]; From a97610a51d0186c4518669b9dbd91f30f2c28192 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Thu, 29 Aug 2024 08:44:47 +0200 Subject: [PATCH 35/46] fix: audio poster + links --- components/CurrentlyPlayingBar.tsx | 46 ++++++++++++++++++------------ 1 file changed, 27 insertions(+), 19 deletions(-) diff --git a/components/CurrentlyPlayingBar.tsx b/components/CurrentlyPlayingBar.tsx index 5fbd7b53..92a938a7 100644 --- a/components/CurrentlyPlayingBar.tsx +++ b/components/CurrentlyPlayingBar.tsx @@ -17,7 +17,6 @@ import Animated, { import Video from "react-native-video"; import { Text } from "./common/Text"; import { Loader } from "./Loader"; -import { debounce } from "lodash"; export const CurrentlyPlayingBar: React.FC = () => { const segments = useSegments(); @@ -25,7 +24,6 @@ export const CurrentlyPlayingBar: React.FC = () => { currentlyPlaying, pauseVideo, playVideo, - setCurrentlyPlayingState, stopPlayback, setVolume, setIsPlaying, @@ -36,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); @@ -66,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 @@ -94,19 +93,20 @@ 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 || !backdropUrl) return null; + if (!api || !currentlyPlaying || !poster) return null; return { uri: currentlyPlaying.url, isNetwork: true, @@ -120,13 +120,13 @@ export const CurrentlyPlayingBar: React.FC = () => { description: currentlyPlaying.item?.Overview ? currentlyPlaying.item?.Overview : undefined, - imageUri: backdropUrl, + imageUri: poster, subtitle: currentlyPlaying.item?.Album ? currentlyPlaying.item?.Album : undefined, }, }; - }, [currentlyPlaying, startPosition, api, backdropUrl]); + }, [currentlyPlaying, startPosition, api, poster]); if (!api || !currentlyPlaying) return null; @@ -174,8 +174,8 @@ export const CurrentlyPlayingBar: React.FC = () => { controls={false} pictureInPicture={true} poster={ - backdropUrl && currentlyPlaying.item?.Type === "Audio" - ? backdropUrl + poster && currentlyPlaying.item?.Type === "Audio" + ? poster : undefined } debug={{ @@ -228,10 +228,17 @@ export const CurrentlyPlayingBar: React.FC = () => { { - if (currentlyPlaying.item?.Type === "Audio") - router.push(`/albums/${currentlyPlaying.item?.AlbumId}`); - else - router.push(`/items/page?id=${currentlyPlaying.item?.Id}`); + if (currentlyPlaying.item?.Type === "Audio") { + router.push( + // @ts-ignore + `/(auth)/(tabs)/${from}/albums/${currentlyPlaying.item.AlbumId}` + ); + } else { + router.push( + // @ts-ignore + `/(auth)/(tabs)/${from}/items/page?id=${currentlyPlaying.item?.Id}` + ); + } }} > {currentlyPlaying.item?.Name} @@ -240,7 +247,8 @@ export const CurrentlyPlayingBar: React.FC = () => { { router.push( - `/(auth)/series/${currentlyPlaying.item.SeriesId}` + // @ts-ignore + `/(auth)/(tabs)/${from}/series/${currentlyPlaying.item.SeriesId}` ); }} className="text-xs opacity-50" From 2baf57156ec12a2cd0066251af3bedb1c5f156fd Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Thu, 29 Aug 2024 08:44:58 +0200 Subject: [PATCH 36/46] fix: landscape design --- components/ItemContent.tsx | 41 +++++++++++++++++++++++++++++++++++--- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/components/ItemContent.tsx b/components/ItemContent.tsx index 536d43e6..9491c5cd 100644 --- a/components/ItemContent.tsx +++ b/components/ItemContent.tsx @@ -40,6 +40,8 @@ import Animated, { } 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); @@ -62,6 +64,26 @@ export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => { 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, @@ -138,6 +160,10 @@ export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => { }, [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; }, [item]); @@ -169,7 +195,8 @@ export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => { settings, ], queryFn: async () => { - if (!api || !user?.Id || !sessionData) return null; + if (!api || !user?.Id || !sessionData || !selectedMediaSource?.Id) + return null; let deviceProfile: any = ios; @@ -193,7 +220,7 @@ export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => { subtitleStreamIndex: selectedSubtitleStream, forceDirectPlay: settings?.forceDirectPlay, height: maxBitrate.height, - mediaSourceId: selectedMediaSource?.Id, + mediaSourceId: selectedMediaSource.Id, }); console.info("Stream URL:", url); @@ -212,8 +239,16 @@ export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => { ); }, [isLoading, isFetching, loadingImage, loadingLogo, logoUrl]); + const insets = useSafeAreaInsets(); + return ( - + {loading && ( From dc02db646372480122c3f24d96b6758736e58dc2 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Thu, 29 Aug 2024 09:11:50 +0200 Subject: [PATCH 37/46] chore: version --- app.json | 4 ++-- eas.json | 4 ++-- providers/JellyfinProvider.tsx | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/app.json b/app.json index 2a9b1e60..d14d982c 100644 --- a/app.json +++ b/app.json @@ -2,7 +2,7 @@ "expo": { "name": "Streamyfin", "slug": "streamyfin", - "version": "0.10.0", + "version": "0.10.1", "orientation": "default", "icon": "./assets/images/icon.png", "scheme": "streamyfin", @@ -33,7 +33,7 @@ }, "android": { "jsEngine": "hermes", - "versionCode": 29, + "versionCode": 30, "adaptiveIcon": { "foregroundImage": "./assets/images/icon.png" }, diff --git a/eas.json b/eas.json index 299b5e9c..fe95ec60 100644 --- a/eas.json +++ b/eas.json @@ -21,13 +21,13 @@ } }, "production": { - "channel": "0.10.0", + "channel": "0.10.1", "android": { "image": "latest" } }, "production-apk": { - "channel": "0.10.0", + "channel": "0.10.1", "android": { "buildType": "apk", "image": "latest" diff --git a/providers/JellyfinProvider.tsx b/providers/JellyfinProvider.tsx index 25e1f510..311b5e52 100644 --- a/providers/JellyfinProvider.tsx +++ b/providers/JellyfinProvider.tsx @@ -62,7 +62,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ setJellyfin( () => new Jellyfin({ - clientInfo: { name: "Streamyfin", version: "0.10.0" }, + clientInfo: { name: "Streamyfin", version: "0.10.1" }, deviceInfo: { name: Platform.OS === "ios" ? "iOS" : "Android", id }, }) ); @@ -80,7 +80,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ return { authorization: `MediaBrowser Client="Streamyfin", Device=${ Platform.OS === "android" ? "Android" : "iOS" - }, DeviceId="${deviceId}", Version="0.10.0"`, + }, DeviceId="${deviceId}", Version="0.10.1"`, }; }, [deviceId]); From 78189c824693f4906c24dc36c8e2a766add8775a Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Thu, 29 Aug 2024 12:58:51 +0200 Subject: [PATCH 38/46] fix: download url not correct for direct streams --- app.json | 4 ++-- app/(auth)/(tabs)/(home)/downloads.tsx | 17 ++++++++--------- app/(auth)/(tabs)/(home)/settings.tsx | 10 ++++++++-- components/DownloadItem.tsx | 8 +++----- components/MediaSourceSelector.tsx | 12 +++++++++++- components/common/ItemImage.tsx | 4 +--- eas.json | 4 ++-- providers/JellyfinProvider.tsx | 4 ++-- utils/jellyfin/media/getStreamUrl.ts | 18 ++++++++++-------- 9 files changed, 47 insertions(+), 34 deletions(-) diff --git a/app.json b/app.json index d14d982c..fd339123 100644 --- a/app.json +++ b/app.json @@ -2,7 +2,7 @@ "expo": { "name": "Streamyfin", "slug": "streamyfin", - "version": "0.10.1", + "version": "0.10.2", "orientation": "default", "icon": "./assets/images/icon.png", "scheme": "streamyfin", @@ -33,7 +33,7 @@ }, "android": { "jsEngine": "hermes", - "versionCode": 30, + "versionCode": 31, "adaptiveIcon": { "foregroundImage": "./assets/images/icon.png" }, diff --git a/app/(auth)/(tabs)/(home)/downloads.tsx b/app/(auth)/(tabs)/(home)/downloads.tsx index 05765a97..706e3581 100644 --- a/app/(auth)/(tabs)/(home)/downloads.tsx +++ b/app/(auth)/(tabs)/(home)/downloads.tsx @@ -65,15 +65,14 @@ const downloads: React.FC = () => { } return ( - - + + Queue diff --git a/app/(auth)/(tabs)/(home)/settings.tsx b/app/(auth)/(tabs)/(home)/settings.tsx index 88667016..2a64b3ce 100644 --- a/app/(auth)/(tabs)/(home)/settings.tsx +++ b/app/(auth)/(tabs)/(home)/settings.tsx @@ -27,9 +27,15 @@ export default function settings() { const insets = useSafeAreaInsets(); return ( - + = ({ item, ...props }) => { item.Id }/universal?${searchParams.toString()}`; } - } - - if (mediaSource.TranscodingUrl) { + } else if (mediaSource.TranscodingUrl) { console.log("Using transcoded stream!"); url = `${api.basePath}${mediaSource.TranscodingUrl}`; - } else { - throw new Error("No transcoding url"); } + if (!url) throw new Error("No url"); + return await startRemuxing(url); }, [ api, diff --git a/components/MediaSourceSelector.tsx b/components/MediaSourceSelector.tsx index b32ceb4b..1478837a 100644 --- a/components/MediaSourceSelector.tsx +++ b/components/MediaSourceSelector.tsx @@ -36,6 +36,14 @@ export const MediaSourceSelector: React.FC = ({ 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 ( = ({ onChange(source); }} > - {source.Name} + + {name(source.Name)} + ))} diff --git a/components/common/ItemImage.tsx b/components/common/ItemImage.tsx index 8d7c6461..c3ad2ed6 100644 --- a/components/common/ItemImage.tsx +++ b/components/common/ItemImage.tsx @@ -1,11 +1,9 @@ import { useImageColors } from "@/hooks/useImageColors"; import { apiAtom } from "@/providers/JellyfinProvider"; -import { itemThemeColorAtom } from "@/utils/atoms/primaryColor"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { Image, ImageProps, ImageSource } from "expo-image"; import { useAtom } from "jotai"; -import { useEffect, useMemo } from "react"; -import { getColors } from "react-native-image-colors"; +import { useMemo } from "react"; interface Props extends ImageProps { item: BaseItemDto; diff --git a/eas.json b/eas.json index fe95ec60..0105680f 100644 --- a/eas.json +++ b/eas.json @@ -21,13 +21,13 @@ } }, "production": { - "channel": "0.10.1", + "channel": "0.10.2", "android": { "image": "latest" } }, "production-apk": { - "channel": "0.10.1", + "channel": "0.10.2", "android": { "buildType": "apk", "image": "latest" diff --git a/providers/JellyfinProvider.tsx b/providers/JellyfinProvider.tsx index 311b5e52..7f9eebd9 100644 --- a/providers/JellyfinProvider.tsx +++ b/providers/JellyfinProvider.tsx @@ -62,7 +62,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ setJellyfin( () => new Jellyfin({ - clientInfo: { name: "Streamyfin", version: "0.10.1" }, + clientInfo: { name: "Streamyfin", version: "0.10.2" }, deviceInfo: { name: Platform.OS === "ios" ? "iOS" : "Android", id }, }) ); @@ -80,7 +80,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({ return { authorization: `MediaBrowser Client="Streamyfin", Device=${ Platform.OS === "android" ? "Android" : "iOS" - }, DeviceId="${deviceId}", Version="0.10.1"`, + }, DeviceId="${deviceId}", Version="0.10.2"`, }; }, [deviceId]); diff --git a/utils/jellyfin/media/getStreamUrl.ts b/utils/jellyfin/media/getStreamUrl.ts index 38d9fb2e..28b47f8e 100644 --- a/utils/jellyfin/media/getStreamUrl.ts +++ b/utils/jellyfin/media/getStreamUrl.ts @@ -76,10 +76,12 @@ export const getStreamUrl = async ({ throw new Error("no PlaySessionId"); } + let url: string | null | undefined; + if (mediaSource.SupportsDirectPlay || forceDirectPlay === true) { if (item.MediaType === "Video") { console.log("Using direct stream for video!"); - return `${api.basePath}/Videos/${itemId}/stream.mp4?playSessionId=${sessionData.PlaySessionId}&mediaSourceId=${mediaSource.Id}&static=true&subtitleStreamIndex=${subtitleStreamIndex}&audioStreamIndex=${audioStreamIndex}&deviceId=${api.deviceInfo.id}&api_key=${api.accessToken}`; + url = `${api.basePath}/Videos/${itemId}/stream.mp4?playSessionId=${sessionData.PlaySessionId}&mediaSourceId=${mediaSource.Id}&static=true&subtitleStreamIndex=${subtitleStreamIndex}&audioStreamIndex=${audioStreamIndex}&deviceId=${api.deviceInfo.id}&api_key=${api.accessToken}`; } else if (item.MediaType === "Audio") { console.log("Using direct stream for audio!"); const searchParams = new URLSearchParams({ @@ -97,16 +99,16 @@ export const getStreamUrl = async ({ EnableRedirection: "true", EnableRemoteMedia: "false", }); - return `${ + url = `${ api.basePath }/Audio/${itemId}/universal?${searchParams.toString()}`; } + } else if (mediaSource.TranscodingUrl) { + console.log("Using transcoded stream!"); + url = `${api.basePath}${mediaSource.TranscodingUrl}`; } - if (mediaSource.TranscodingUrl) { - console.log("Using transcoded stream!"); - return `${api.basePath}${mediaSource.TranscodingUrl}`; - } else { - throw new Error("No transcoding url"); - } + if (!url) throw new Error("No url"); + + return url; }; From 8b3b492f5e3be42d6731eaf86d6606965b525576 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Thu, 29 Aug 2024 13:10:54 +0200 Subject: [PATCH 39/46] fix: small design fixes --- app/(auth)/(tabs)/(home)/settings.tsx | 9 +-------- components/Chromecast.tsx | 12 +++++++++++- components/downloads/SeriesCard.tsx | 2 +- 3 files changed, 13 insertions(+), 10 deletions(-) diff --git a/app/(auth)/(tabs)/(home)/settings.tsx b/app/(auth)/(tabs)/(home)/settings.tsx index 2a64b3ce..c688f9b2 100644 --- a/app/(auth)/(tabs)/(home)/settings.tsx +++ b/app/(auth)/(tabs)/(home)/settings.tsx @@ -34,14 +34,7 @@ export default function settings() { paddingBottom: 100, }} > - + Information diff --git a/components/Chromecast.tsx b/components/Chromecast.tsx index 60c4400d..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, ViewProps } from "react-native"; +import { Platform, View, ViewProps } from "react-native"; import GoogleCast, { CastButton, useCastDevice, @@ -37,6 +37,16 @@ export const Chromecast: React.FC = ({ }, [client, devices, castDevice, sessionManager, discoveryManager]); if (background === "transparent") + return ( + + + + ); + + if (Platform.OS === "android") return ( = ({ items }) => { return ( - {items[0].SeriesName} + {items[0].SeriesName} {items.length} From 8c0e7f7db8af8b7b9009013ea71872440a938189 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Thu, 29 Aug 2024 23:03:51 +0200 Subject: [PATCH 40/46] fix: item page for item not associated with movie/tv-show not loading --- components/ItemContent.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/components/ItemContent.tsx b/components/ItemContent.tsx index 9491c5cd..2722e012 100644 --- a/components/ItemContent.tsx +++ b/components/ItemContent.tsx @@ -102,7 +102,7 @@ export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => { }); }; - const headerHeightRef = useRef(0); + const headerHeightRef = useRef(400); const { data: item, @@ -166,6 +166,7 @@ export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => { } if (item?.Type === "Episode") headerHeightRef.current = 400; else if (item?.Type === "Movie") headerHeightRef.current = 500; + else headerHeightRef.current = 400; }, [item]); const { data: sessionData } = useQuery({ From bbaab1994a246a02f9f9305fdb56846609e260a6 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Fri, 30 Aug 2024 00:13:15 +0200 Subject: [PATCH 41/46] fix: #104 #103 #102 --- app/(auth)/(tabs)/(libraries)/[libraryId].tsx | 24 ++++--------------- app/_layout.tsx | 22 +++++++++++++++-- utils/atoms/orientation.ts | 7 ++++++ 3 files changed, 31 insertions(+), 22 deletions(-) create mode 100644 utils/atoms/orientation.ts diff --git a/app/(auth)/(tabs)/(libraries)/[libraryId].tsx b/app/(auth)/(tabs)/(libraries)/[libraryId].tsx index 327d11b5..77b2e3ff 100644 --- a/app/(auth)/(tabs)/(libraries)/[libraryId].tsx +++ b/app/(auth)/(tabs)/(libraries)/[libraryId].tsx @@ -45,6 +45,7 @@ import { 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); @@ -63,9 +64,7 @@ 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; @@ -73,7 +72,7 @@ const Page = () => { if (screenWidth < 960) return 6; if (screenWidth < 1280) return 7; return 6; - }, [screenWidth]); + }, [screenWidth, orientation]); useLayoutEffect(() => { setSortBy([ @@ -90,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 () => { @@ -417,6 +400,7 @@ const Page = () => { contentInsetAdjustmentBehavior="automatic" data={flatData} renderItem={renderItem} + extraData={orientation} keyExtractor={keyExtractor} estimatedItemSize={244} numColumns={getNumberOfColumns()} diff --git a/app/_layout.tsx b/app/_layout.tsx index db1a105e..8f852130 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -13,11 +13,12 @@ import { Stack, useRouter } from "expo-router"; import * as ScreenOrientation from "expo-screen-orientation"; import * as SplashScreen from "expo-splash-screen"; import { StatusBar } from "expo-status-bar"; -import { Provider as JotaiProvider } from "jotai"; +import { Provider as JotaiProvider, useAtom } from "jotai"; import { useEffect, useRef } from "react"; import { GestureHandlerRootView } from "react-native-gesture-handler"; import "react-native-reanimated"; import * as Linking from "expo-linking"; +import { orientationAtom } from "@/utils/atoms/orientation"; SplashScreen.preventAutoHideAsync(); @@ -45,6 +46,7 @@ export default function RootLayout() { function Layout() { const [settings, updateSettings] = useSettings(); + const [orientation, setOrientation] = useAtom(orientationAtom); useKeepAwake(); @@ -71,8 +73,24 @@ function Layout() { ); }, [settings]); + useEffect(() => { + 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(); - const router = useRouter(); if (url) { const { hostname, path, queryParams } = Linking.parse(url); diff --git a/utils/atoms/orientation.ts b/utils/atoms/orientation.ts new file mode 100644 index 00000000..19c40dad --- /dev/null +++ b/utils/atoms/orientation.ts @@ -0,0 +1,7 @@ +import * as ScreenOrientation from "expo-screen-orientation"; +import { Orientation } from "expo-screen-orientation"; +import { atom } from "jotai"; + +export const orientationAtom = atom( + ScreenOrientation.OrientationLock.PORTRAIT_UP +); From 6c1db4bbb90e1409544a7af2fa361fb4c752ab7e Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Fri, 30 Aug 2024 00:13:45 +0200 Subject: [PATCH 42/46] Revert "fix: #104 #103 #102" This reverts commit bbaab1994a246a02f9f9305fdb56846609e260a6. --- app/(auth)/(tabs)/(libraries)/[libraryId].tsx | 24 +++++++++++++++---- app/_layout.tsx | 22 ++--------------- utils/atoms/orientation.ts | 7 ------ 3 files changed, 22 insertions(+), 31 deletions(-) delete mode 100644 utils/atoms/orientation.ts diff --git a/app/(auth)/(tabs)/(libraries)/[libraryId].tsx b/app/(auth)/(tabs)/(libraries)/[libraryId].tsx index 77b2e3ff..327d11b5 100644 --- a/app/(auth)/(tabs)/(libraries)/[libraryId].tsx +++ b/app/(auth)/(tabs)/(libraries)/[libraryId].tsx @@ -45,7 +45,6 @@ import { 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); @@ -64,7 +63,9 @@ const Page = () => { const [sortBy, setSortBy] = useAtom(sortByAtom); const [sortOrder, setSortOrder] = useAtom(sortOrderAtom); - const [orientation, setOrientation] = useAtom(orientationAtom); + const [orientation, setOrientation] = useState( + ScreenOrientation.Orientation.PORTRAIT_UP + ); const getNumberOfColumns = useCallback(() => { if (orientation === ScreenOrientation.Orientation.PORTRAIT_UP) return 3; @@ -72,7 +73,7 @@ const Page = () => { if (screenWidth < 960) return 6; if (screenWidth < 1280) return 7; return 6; - }, [screenWidth, orientation]); + }, [screenWidth]); useLayoutEffect(() => { setSortBy([ @@ -89,6 +90,22 @@ 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 () => { @@ -400,7 +417,6 @@ const Page = () => { contentInsetAdjustmentBehavior="automatic" data={flatData} renderItem={renderItem} - extraData={orientation} keyExtractor={keyExtractor} estimatedItemSize={244} numColumns={getNumberOfColumns()} diff --git a/app/_layout.tsx b/app/_layout.tsx index 8f852130..db1a105e 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -13,12 +13,11 @@ import { Stack, useRouter } from "expo-router"; import * as ScreenOrientation from "expo-screen-orientation"; import * as SplashScreen from "expo-splash-screen"; import { StatusBar } from "expo-status-bar"; -import { Provider as JotaiProvider, useAtom } from "jotai"; +import { Provider as JotaiProvider } from "jotai"; import { useEffect, useRef } from "react"; import { GestureHandlerRootView } from "react-native-gesture-handler"; import "react-native-reanimated"; import * as Linking from "expo-linking"; -import { orientationAtom } from "@/utils/atoms/orientation"; SplashScreen.preventAutoHideAsync(); @@ -46,7 +45,6 @@ export default function RootLayout() { function Layout() { const [settings, updateSettings] = useSettings(); - const [orientation, setOrientation] = useAtom(orientationAtom); useKeepAwake(); @@ -73,24 +71,8 @@ function Layout() { ); }, [settings]); - useEffect(() => { - 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(); + const router = useRouter(); if (url) { const { hostname, path, queryParams } = Linking.parse(url); diff --git a/utils/atoms/orientation.ts b/utils/atoms/orientation.ts deleted file mode 100644 index 19c40dad..00000000 --- a/utils/atoms/orientation.ts +++ /dev/null @@ -1,7 +0,0 @@ -import * as ScreenOrientation from "expo-screen-orientation"; -import { Orientation } from "expo-screen-orientation"; -import { atom } from "jotai"; - -export const orientationAtom = atom( - ScreenOrientation.OrientationLock.PORTRAIT_UP -); From 55b1c3ae4583aab7d9b0ca66cbc62c03d8863e06 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Fri, 30 Aug 2024 00:13:58 +0200 Subject: [PATCH 43/46] Reapply "fix: #104 #103 #102" This reverts commit 6c1db4bbb90e1409544a7af2fa361fb4c752ab7e. fix #104 fix #102 fix #103 --- app/(auth)/(tabs)/(libraries)/[libraryId].tsx | 24 ++++--------------- app/_layout.tsx | 22 +++++++++++++++-- utils/atoms/orientation.ts | 7 ++++++ 3 files changed, 31 insertions(+), 22 deletions(-) create mode 100644 utils/atoms/orientation.ts diff --git a/app/(auth)/(tabs)/(libraries)/[libraryId].tsx b/app/(auth)/(tabs)/(libraries)/[libraryId].tsx index 327d11b5..77b2e3ff 100644 --- a/app/(auth)/(tabs)/(libraries)/[libraryId].tsx +++ b/app/(auth)/(tabs)/(libraries)/[libraryId].tsx @@ -45,6 +45,7 @@ import { 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); @@ -63,9 +64,7 @@ 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; @@ -73,7 +72,7 @@ const Page = () => { if (screenWidth < 960) return 6; if (screenWidth < 1280) return 7; return 6; - }, [screenWidth]); + }, [screenWidth, orientation]); useLayoutEffect(() => { setSortBy([ @@ -90,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 () => { @@ -417,6 +400,7 @@ const Page = () => { contentInsetAdjustmentBehavior="automatic" data={flatData} renderItem={renderItem} + extraData={orientation} keyExtractor={keyExtractor} estimatedItemSize={244} numColumns={getNumberOfColumns()} diff --git a/app/_layout.tsx b/app/_layout.tsx index db1a105e..8f852130 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -13,11 +13,12 @@ import { Stack, useRouter } from "expo-router"; import * as ScreenOrientation from "expo-screen-orientation"; import * as SplashScreen from "expo-splash-screen"; import { StatusBar } from "expo-status-bar"; -import { Provider as JotaiProvider } from "jotai"; +import { Provider as JotaiProvider, useAtom } from "jotai"; import { useEffect, useRef } from "react"; import { GestureHandlerRootView } from "react-native-gesture-handler"; import "react-native-reanimated"; import * as Linking from "expo-linking"; +import { orientationAtom } from "@/utils/atoms/orientation"; SplashScreen.preventAutoHideAsync(); @@ -45,6 +46,7 @@ export default function RootLayout() { function Layout() { const [settings, updateSettings] = useSettings(); + const [orientation, setOrientation] = useAtom(orientationAtom); useKeepAwake(); @@ -71,8 +73,24 @@ function Layout() { ); }, [settings]); + useEffect(() => { + 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(); - const router = useRouter(); if (url) { const { hostname, path, queryParams } = Linking.parse(url); diff --git a/utils/atoms/orientation.ts b/utils/atoms/orientation.ts new file mode 100644 index 00000000..19c40dad --- /dev/null +++ b/utils/atoms/orientation.ts @@ -0,0 +1,7 @@ +import * as ScreenOrientation from "expo-screen-orientation"; +import { Orientation } from "expo-screen-orientation"; +import { atom } from "jotai"; + +export const orientationAtom = atom( + ScreenOrientation.OrientationLock.PORTRAIT_UP +); From 68cfe99421996f3779359ab6065464285e2bd8e3 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Fri, 30 Aug 2024 00:28:07 +0200 Subject: [PATCH 44/46] fix: #95 --- components/DownloadItem.tsx | 30 ++++++++++++++++++++---------- providers/JellyfinProvider.tsx | 17 +++++++++++++++++ 2 files changed, 37 insertions(+), 10 deletions(-) diff --git a/components/DownloadItem.tsx b/components/DownloadItem.tsx index f5e6914a..e74f800c 100644 --- a/components/DownloadItem.tsx +++ b/components/DownloadItem.tsx @@ -22,7 +22,7 @@ import { useQuery } from "@tanstack/react-query"; import { router } from "expo-router"; import { useAtom } from "jotai"; import { useCallback, useMemo, useRef, useState } from "react"; -import { TouchableOpacity, View, ViewProps } from "react-native"; +import { Alert, TouchableOpacity, View, ViewProps } from "react-native"; import { AudioTrackSelector } from "./AudioTrackSelector"; import { Bitrate, BitrateSelector } from "./BitrateSelector"; import { Button } from "./Button"; @@ -54,11 +54,14 @@ export const DownloadItem: React.FC = ({ item, ...props }) => { value: undefined, }); + const userCanDownload = useMemo(() => { + return user?.Policy?.EnableContentDownloading; + }, [user]); + /** * Bottom sheet */ const bottomSheetModalRef = useRef(null); - const snapPoints = useMemo(() => ["50%"], []); const handlePresentModalPress = useCallback(() => { bottomSheetModalRef.current?.present(); @@ -286,14 +289,21 @@ export const DownloadItem: React.FC = ({ item, ...props }) => {