diff --git a/app.json b/app.json index 18082398..5d8d431c 100644 --- a/app.json +++ b/app.json @@ -25,6 +25,9 @@ "NSAllowsArbitraryLoads": true } }, + "config": { + "usesNonExemptEncryption": false + }, "supportsTablet": true, "bundleIdentifier": "com.fredrikburmester.streamyfin" }, diff --git a/app/(auth)/(tabs)/(home,libraries,search)/items/[id].tsx b/app/(auth)/(tabs)/(home,libraries,search)/items/[id].tsx index 02945db5..84958a90 100644 --- a/app/(auth)/(tabs)/(home,libraries,search)/items/[id].tsx +++ b/app/(auth)/(tabs)/(home,libraries,search)/items/[id].tsx @@ -114,6 +114,7 @@ const page: React.FC = () => { audioStreamIndex: selectedAudioStream, subtitleStreamIndex: selectedSubtitleStream, forceDirectPlay: settings?.forceDirectPlay, + height: maxBitrate.height, }); console.log("Transcode URL: ", url); diff --git a/app/(auth)/(tabs)/(libraries)/[libraryId].tsx b/app/(auth)/(tabs)/(libraries)/[libraryId].tsx index b9315dec..3f96282e 100644 --- a/app/(auth)/(tabs)/(libraries)/[libraryId].tsx +++ b/app/(auth)/(tabs)/(libraries)/[libraryId].tsx @@ -9,7 +9,7 @@ import React, { useMemo, useState, } from "react"; -import { FlatList, View } from "react-native"; +import { FlatList, RefreshControl, View } from "react-native"; import { Text } from "@/components/common/Text"; import { TouchableItemRouter } from "@/components/common/TouchableItemRouter"; @@ -38,6 +38,7 @@ import { getUserLibraryApi, } from "@jellyfin/sdk/lib/utils/api"; import { FlashList } from "@shopify/flash-list"; +import { Loader } from "@/components/Loader"; const MemoizedTouchableItemRouter = React.memo(TouchableItemRouter); @@ -90,7 +91,7 @@ const Page = () => { }; }, []); - const { data: library } = useQuery({ + const { data: library, isLoading: isLibraryLoading } = useQuery({ queryKey: ["library", libraryId], queryFn: async () => { if (!api) return null; @@ -101,7 +102,7 @@ const Page = () => { return response.data; }, enabled: !!api && !!user?.Id && !!libraryId, - staleTime: 0, + staleTime: 60 * 1000, }); const fetchItems = useCallback( @@ -112,36 +113,15 @@ const Page = () => { }): Promise => { if (!api || !library) return null; - let includeItemTypes: BaseItemKind[] | undefined = []; - - switch (library?.CollectionType) { - case "movies": - includeItemTypes.push("Movie"); - break; - case "boxsets": - includeItemTypes.push("BoxSet"); - break; - case "tvshows": - includeItemTypes.push("Series"); - break; - case "music": - includeItemTypes.push("MusicAlbum"); - break; - default: - includeItemTypes = undefined; - break; - } - const response = await getItemsApi(api).getItems({ userId: user?.Id, parentId: libraryId, - limit: 20, + limit: 36, startIndex: pageParam, sortBy: [sortBy[0].key, "SortName", "ProductionYear"], sortOrder: [sortOrder[0].key], - includeItemTypes, enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"], - recursive: true, + recursive: false, imageTypeLimit: 1, fields: ["PrimaryImageAspectRatio", "SortName"], genres: selectedGenres, @@ -164,40 +144,41 @@ const Page = () => { ] ); - const { data, isFetching, fetchNextPage, hasNextPage } = useInfiniteQuery({ - queryKey: [ - "library-items", - libraryId, - selectedGenres, - selectedYears, - selectedTags, - sortBy, - sortOrder, - ], - queryFn: fetchItems, - getNextPageParam: (lastPage, pages) => { - if ( - !lastPage?.Items || - !lastPage?.TotalRecordCount || - lastPage?.TotalRecordCount === 0 - ) - return undefined; + const { data, isFetching, fetchNextPage, hasNextPage, isLoading } = + useInfiniteQuery({ + queryKey: [ + "library-items", + libraryId, + selectedGenres, + selectedYears, + selectedTags, + sortBy, + sortOrder, + ], + queryFn: fetchItems, + getNextPageParam: (lastPage, pages) => { + if ( + !lastPage?.Items || + !lastPage?.TotalRecordCount || + lastPage?.TotalRecordCount === 0 + ) + return undefined; - const totalItems = lastPage.TotalRecordCount; - const accumulatedItems = pages.reduce( - (acc, curr) => acc + (curr?.Items?.length || 0), - 0 - ); + const totalItems = lastPage.TotalRecordCount; + const accumulatedItems = pages.reduce( + (acc, curr) => acc + (curr?.Items?.length || 0), + 0 + ); - if (accumulatedItems < totalItems) { - return lastPage?.Items?.length * pages.length; - } else { - return undefined; - } - }, - initialPageParam: 0, - enabled: !!api && !!user?.Id && !!library, - }); + if (accumulatedItems < totalItems) { + return lastPage?.Items?.length * pages.length; + } else { + return undefined; + } + }, + initialPageParam: 0, + enabled: !!api && !!user?.Id && !!library, + }); const flatData = useMemo(() => { return ( @@ -394,7 +375,19 @@ const Page = () => { ] ); - if (!library) return null; + if (isLoading || isLibraryLoading) + return ( + + + + ); + + if (flatData.length === 0) + return ( + + No items found + + ); return ( { data={flatData} renderItem={renderItem} keyExtractor={keyExtractor} - estimatedItemSize={255} + estimatedItemSize={244} numColumns={ orientation === ScreenOrientation.Orientation.PORTRAIT_UP ? 3 : 5 } @@ -416,7 +409,7 @@ const Page = () => { fetchNextPage(); } }} - onEndReachedThreshold={0.5} + onEndReachedThreshold={1} ListHeaderComponent={ListHeaderComponent} contentContainerStyle={{ paddingBottom: 24 }} ItemSeparatorComponent={() => ( diff --git a/app/(auth)/(tabs)/(libraries)/_layout.tsx b/app/(auth)/(tabs)/(libraries)/_layout.tsx index ed2d8e6e..4e4f453b 100644 --- a/app/(auth)/(tabs)/(libraries)/_layout.tsx +++ b/app/(auth)/(tabs)/(libraries)/_layout.tsx @@ -1,8 +1,15 @@ import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack"; +import { useSettings } from "@/utils/atoms/settings"; +import { Ionicons } from "@expo/vector-icons"; import { Stack } from "expo-router"; import { Platform } from "react-native"; +import * as DropdownMenu from "zeego/dropdown-menu"; export default function IndexLayout() { + const [settings, updateSettings] = useSettings(); + + if (!settings?.libraryOptions) return null; + return ( ( + + + + + + Display + + + + Display + + + + updateSettings({ + libraryOptions: { + ...settings.libraryOptions, + display: "row", + }, + }) + } + > + + + Row + + + + updateSettings({ + libraryOptions: { + ...settings.libraryOptions, + display: "list", + }, + }) + } + > + + + List + + + + + + + Image style + + + + updateSettings({ + libraryOptions: { + ...settings.libraryOptions, + imageStyle: "poster", + }, + }) + } + > + + + Poster + + + + updateSettings({ + libraryOptions: { + ...settings.libraryOptions, + imageStyle: "cover", + }, + }) + } + > + + + Cover + + + + + + + { + if (settings.libraryOptions.imageStyle === "poster") + return; + updateSettings({ + libraryOptions: { + ...settings.libraryOptions, + showTitles: newValue === "on" ? true : false, + }, + }); + }} + > + + + Show titles + + + { + updateSettings({ + libraryOptions: { + ...settings.libraryOptions, + showStats: newValue === "on" ? true : false, + }, + }); + }} + > + + + Show stats + + + + + + + + ), }} /> { + for (const item of data || []) { + queryClient.prefetchQuery({ + queryKey: ["library", item.Id], + queryFn: async () => { + if (!item.Id || !user?.Id || !api) return null; + const response = await getUserLibraryApi(api).getItem({ + itemId: item.Id, + userId: user?.Id, + }); + return response.data; + }, + staleTime: 60 * 1000, + }); + } + }, [data]); + if (isLoading) return ( @@ -41,59 +61,38 @@ export default function index() { ); + if (!data) + return ( + + No libraries found + + ); + return ( } keyExtractor={(item) => item.Id || ""} - ItemSeparatorComponent={() => } + ItemSeparatorComponent={() => + settings?.libraryOptions?.display === "row" ? ( + + ) : ( + + ) + } estimatedItemSize={200} /> ); } - -interface Props { - library: BaseItemDto; -} - -const LibraryItemCard: React.FC = ({ library }) => { - const [api] = useAtom(apiAtom); - - const url = useMemo( - () => - getPrimaryImageUrl({ - api, - item: library, - }), - [library] - ); - - if (!url) return null; - - return ( - - - - - {library.Name} - - - - ); -}; diff --git a/bun.lockb b/bun.lockb index 6eea000f..32f4452c 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/components/BitrateSelector.tsx b/components/BitrateSelector.tsx index d5217611..e38df8bb 100644 --- a/components/BitrateSelector.tsx +++ b/components/BitrateSelector.tsx @@ -1,11 +1,11 @@ import { TouchableOpacity, View } from "react-native"; import * as DropdownMenu from "zeego/dropdown-menu"; import { Text } from "./common/Text"; -import { atom, useAtom } from "jotai"; export type Bitrate = { key: string; value: number | undefined; + height?: number; }; const BITRATES: Bitrate[] = [ @@ -16,22 +16,27 @@ const BITRATES: Bitrate[] = [ { key: "8 Mb/s", value: 8000000, + height: 1080, }, { key: "4 Mb/s", value: 4000000, + height: 1080, }, { key: "2 Mb/s", value: 2000000, + height: 720, }, { key: "500 Kb/s", value: 500000, + height: 480, }, { key: "250 Kb/s", value: 250000, + height: 480, }, ]; diff --git a/components/CurrentlyPlayingBar.tsx b/components/CurrentlyPlayingBar.tsx index 2e84aec1..a3276692 100644 --- a/components/CurrentlyPlayingBar.tsx +++ b/components/CurrentlyPlayingBar.tsx @@ -29,6 +29,7 @@ export const CurrentlyPlayingBar: React.FC = () => { setIsPlaying, isPlaying, videoRef, + presentFullscreenPlayer, onProgress, } = usePlayback(); @@ -166,6 +167,26 @@ export const CurrentlyPlayingBar: React.FC = () => { isNetwork: true, startPosition, headers: getAuthHeaders(api), + metadata: { + artist: currentlyPlaying.item?.AlbumArtist + ? currentlyPlaying.item?.AlbumArtist + : undefined, + title: currentlyPlaying.item?.Name + ? currentlyPlaying.item?.Name + : "Unknown", + description: currentlyPlaying.item?.Overview + ? currentlyPlaying.item?.Overview + : undefined, + imageUri: backdropUrl ? backdropUrl : undefined, + subtitle: currentlyPlaying.item?.Album + ? currentlyPlaying.item?.Album + : undefined, + }, + }} + onRestoreUserInterfaceForPictureInPictureStop={() => { + setTimeout(() => { + presentFullscreenPlayer(); + }, 300); }} onBuffer={(e) => e.isBuffering ? console.log("Buffering...") : null diff --git a/components/library/LibraryItemCard.tsx b/components/library/LibraryItemCard.tsx new file mode 100644 index 00000000..87de1870 --- /dev/null +++ b/components/library/LibraryItemCard.tsx @@ -0,0 +1,209 @@ +import { TouchableOpacityProps, View, ViewProps } from "react-native"; +import { Text } from "@/components/common/Text"; +import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; +import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; +import { useAtom } from "jotai"; +import { useEffect, useMemo, useState } from "react"; +import { TouchableItemRouter } from "../common/TouchableItemRouter"; +import { + BaseItemDto, + BaseItemKind, + CollectionType, +} from "@jellyfin/sdk/lib/generated-client/models"; +import { Image } from "expo-image"; +import { getColors, ImageColorsResult } from "react-native-image-colors"; +import { useQuery } from "@tanstack/react-query"; +import { getItemsApi } from "@jellyfin/sdk/lib/utils/api"; +import { sortBy } from "lodash"; +import { useSettings } from "@/utils/atoms/settings"; +import { Ionicons } from "@expo/vector-icons"; + +interface Props extends TouchableOpacityProps { + library: BaseItemDto; +} + +type LibraryColor = { + dominantColor: string; + averageColor: string; + secondary: string; +}; + +type IconName = React.ComponentProps["name"]; + +const icons: Record = { + movies: "film", + tvshows: "tv", + music: "musical-notes", + books: "book", + homevideos: "videocam", + boxsets: "albums", + playlists: "list", + folders: "folder", + livetv: "tv", + musicvideos: "musical-notes", + photos: "images", + trailers: "videocam", + unknown: "help-circle", +} as const; +export const LibraryItemCard: React.FC = ({ library, ...props }) => { + const [api] = useAtom(apiAtom); + const [user] = useAtom(userAtom); + const [settings] = useSettings(); + + const [imageInfo, setImageInfo] = useState({ + dominantColor: "#fff", + averageColor: "#fff", + secondary: "#fff", + }); + + const url = useMemo( + () => + getPrimaryImageUrl({ + api, + item: library, + }), + [library] + ); + + const { data: itemsCount } = useQuery({ + queryKey: ["library-count", library.Id], + queryFn: async () => { + if (!api) return null; + const response = await getItemsApi(api).getItems({ + userId: user?.Id, + parentId: library.Id, + limit: 0, + }); + return response.data.TotalRecordCount; + }, + }); + + useEffect(() => { + if (url) { + getColors(url, { + fallback: "#fff", + cache: true, + key: url, + }) + .then((colors) => { + let dominantColor: string = "#fff"; + let averageColor: string = "#fff"; + let secondary: string = "#fff"; + + if (colors.platform === "android") { + dominantColor = colors.dominant; + averageColor = colors.average; + secondary = colors.muted; + } else if (colors.platform === "ios") { + dominantColor = colors.primary; + averageColor = colors.background; + secondary = colors.detail; + } + + setImageInfo({ + dominantColor, + averageColor, + secondary, + }); + }) + .catch((error) => { + console.log("Error getting colors", error); + }); + } + }, [url]); + + if (!url) return null; + + if (settings?.libraryOptions?.display === "row") { + return ( + + + + + {library.Name} + + {settings?.libraryOptions?.showStats && ( + + {itemsCount} items + + )} + + + ); + } + + if (settings?.libraryOptions?.imageStyle === "cover") { + return ( + + + + + + + {settings?.libraryOptions?.showTitles && ( + + {library.Name} + + )} + {settings?.libraryOptions?.showStats && ( + + {itemsCount} items + + )} + + + ); + } + + return ( + + + + + {library.Name} + + {settings?.libraryOptions?.showStats && ( + + {itemsCount} items + + )} + + + + + + + ); +}; diff --git a/package.json b/package.json index 7382feb7..eb62667e 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "react-native-gesture-handler": "~2.16.1", "react-native-get-random-values": "^1.11.0", "react-native-google-cast": "^4.8.2", + "react-native-image-colors": "^2.4.0", "react-native-ios-context-menu": "^2.5.1", "react-native-ios-utilities": "^4.4.5", "react-native-reanimated": "~3.10.1", diff --git a/providers/PlaybackProvider.tsx b/providers/PlaybackProvider.tsx index c23b0137..5031c8ac 100644 --- a/providers/PlaybackProvider.tsx +++ b/providers/PlaybackProvider.tsx @@ -102,8 +102,11 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({ setCurrentlyPlaying(state); setIsPlaying(true); - if (settings?.openFullScreenVideoPlayerByDefault) - presentFullscreenPlayer(); + if (settings?.openFullScreenVideoPlayerByDefault) { + setTimeout(() => { + presentFullscreenPlayer(); + }, 300); + } } else { setCurrentlyPlaying(null); setIsFullscreen(false); diff --git a/utils/atoms/settings.ts b/utils/atoms/settings.ts index 4d38d60f..1342061f 100644 --- a/utils/atoms/settings.ts +++ b/utils/atoms/settings.ts @@ -24,6 +24,14 @@ export const DownloadOptions: DownloadOption[] = [ }, ]; +export type LibraryOptions = { + display: "row" | "list"; + cardStyle: "compact" | "detailed"; + imageStyle: "poster" | "cover"; + showTitles: boolean; + showStats: boolean; +}; + type Settings = { autoRotate?: boolean; forceLandscapeInVideoPlayer?: boolean; @@ -36,6 +44,7 @@ type Settings = { marlinServerUrl?: string; openInVLC?: boolean; downloadQuality?: DownloadOption; + libraryOptions: LibraryOptions; }; /** @@ -59,6 +68,13 @@ const loadSettings = async (): Promise => { marlinServerUrl: "", openInVLC: false, downloadQuality: DownloadOptions[0], + libraryOptions: { + display: "list", + cardStyle: "detailed", + imageStyle: "cover", + showTitles: true, + showStats: true, + }, }; try { diff --git a/utils/jellyfin/media/getStreamUrl.ts b/utils/jellyfin/media/getStreamUrl.ts index 86587677..6c5a6056 100644 --- a/utils/jellyfin/media/getStreamUrl.ts +++ b/utils/jellyfin/media/getStreamUrl.ts @@ -17,6 +17,7 @@ export const getStreamUrl = async ({ audioStreamIndex = 0, subtitleStreamIndex = 0, forceDirectPlay = false, + height, }: { api: Api | null | undefined; item: BaseItemDto | null | undefined; @@ -28,6 +29,7 @@ export const getStreamUrl = async ({ audioStreamIndex?: number; subtitleStreamIndex?: number; forceDirectPlay?: boolean; + height?: number; }) => { if (!api || !userId || !item?.Id) { return null; @@ -48,12 +50,16 @@ export const getStreamUrl = async ({ AllowVideoStreamCopy: maxStreamingBitrate ? false : true, AudioStreamIndex: audioStreamIndex, SubtitleStreamIndex: subtitleStreamIndex, + DeInterlace: true, + BreakOnNonKeyFrames: false, + CopyTimestamps: false, + EnableMpegtsM2TsMode: false, }, { headers: { Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`, }, - }, + } ); const mediaSource = response.data.MediaSources?.[0] as MediaSourceInfo; @@ -87,7 +93,9 @@ export const getStreamUrl = async ({ EnableRedirection: "true", EnableRemoteMedia: "false", }); - return `${api.basePath}/Audio/${itemId}/universal?${searchParams.toString()}`; + return `${ + api.basePath + }/Audio/${itemId}/universal?${searchParams.toString()}`; } }