Compare commits

...

25 Commits

Author SHA1 Message Date
Fredrik Burmester
ca7fd382f2 wip 2024-08-18 13:30:12 +02:00
Fredrik Burmester
d8201aa1fc wip 2024-08-18 13:07:08 +02:00
Fredrik Burmester
dec45056f3 wip 2024-08-18 11:53:40 +02:00
Fredrik Burmester
1d41b7080f wip 2024-08-18 11:46:31 +02:00
Fredrik Burmester
83a09ad74a wip 2024-08-18 11:33:26 +02:00
Fredrik Burmester
65ac147441 wip 2024-08-18 11:12:40 +02:00
Fredrik Burmester
6a070cfbe0 wip 2024-08-18 11:07:32 +02:00
Fredrik Burmester
9d1a03a5f2 wip 2024-08-18 11:06:31 +02:00
Fredrik Burmester
08b28f7599 wip 2024-08-18 10:54:50 +02:00
Fredrik Burmester
6a8a155547 chore 2024-08-18 10:52:51 +02:00
Fredrik Burmester
dbb7c6c9a5 fix: use scrollview for better design 2024-08-18 10:52:48 +02:00
Fredrik Burmester
30280e8b3a fix: icon 2024-08-18 10:52:34 +02:00
Fredrik Burmester
5281cba284 feat: inf horizontal scroll for media lists 2024-08-18 10:52:20 +02:00
Fredrik Burmester
da666d3991 fix: tight navbar on android 2024-08-18 10:51:44 +02:00
Fredrik Burmester
817a758b8a fix: double routing bug 2024-08-18 10:51:35 +02:00
Fredrik Burmester
f04a29b757 fix: loading test rollback 2024-08-18 07:45:01 +02:00
Fredrik Burmester
550fc39faa fix: loading indicator style 2024-08-18 07:44:28 +02:00
Fredrik Burmester
d56bb79ac2 feat: filters with bottom sheet 2024-08-17 23:34:51 +02:00
Fredrik Burmester
30781a6dfe wip: refactor 2024-08-17 19:11:42 +02:00
Fredrik Burmester
ba6c2d5409 feat: sort by button 2024-08-17 19:11:29 +02:00
Fredrik Burmester
73b266adb4 feat: blurhash 2024-08-17 19:11:16 +02:00
Fredrik Burmester
e0ca83ae1f chore 2024-08-17 13:15:30 +02:00
Fredrik Burmester
4a17a00f81 feat: more media list stuff 2024-08-17 13:15:25 +02:00
Fredrik Burmester
6bfc0c72d1 fix: refactor 2024-08-17 13:14:59 +02:00
Fredrik Burmester
26050f7179 feat: library page 2024-08-17 13:14:33 +02:00
74 changed files with 11568 additions and 2127 deletions

View File

@@ -108,7 +108,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

View File

@@ -2,7 +2,7 @@
"expo": {
"name": "Streamyfin",
"slug": "streamyfin",
"version": "0.6.0",
"version": "0.6.1",
"orientation": "default",
"icon": "./assets/images/icon.png",
"scheme": "streamyfin",
@@ -46,15 +46,9 @@
"favicon": "./assets/images/favicon.png"
},
"plugins": [
"@react-native-tvos/config-tv",
"expo-router",
"expo-font",
"@config-plugins/ffmpeg-kit-react-native",
[
"react-native-google-cast",
{
"useDefaultExpandedMediaControls": true
}
],
[
"react-native-video",
{
@@ -67,35 +61,6 @@
"useExoplayerDash": false
}
}
],
[
"expo-build-properties",
{
"ios": {
"deploymentTarget": "14.0"
},
"android": {
"minSdkVersion": 24,
"usesCleartextTraffic": true,
"packagingOptions": {
"jniLibs": {
"useLegacyPackaging": true
}
}
}
}
],
[
"expo-screen-orientation",
{
"initialOrientation": "DEFAULT"
}
],
[
"expo-sensors",
{
"motionPermission": "Allow Streamyfin to access your device motion for landscape video watching."
}
]
],
"experiments": {

View File

@@ -1,13 +1,10 @@
import { router, Tabs } from "expo-router";
import React, { useEffect } from "react";
import * as NavigationBar from "expo-navigation-bar";
import { TabBarIcon } from "@/components/navigation/TabBarIcon";
import { Colors } from "@/constants/Colors";
import { Platform, TouchableOpacity, View } from "react-native";
import { Feather } from "@expo/vector-icons";
import { Chromecast } from "@/components/Chromecast";
import { BlurView } from "expo-blur";
import { StyleSheet } from "react-native";
import * as NavigationBar from "expo-navigation-bar";
import { Tabs } from "expo-router";
import React, { useEffect } from "react";
import { Platform, StyleSheet } from "react-native";
export default function TabLayout() {
useEffect(() => {
@@ -29,6 +26,8 @@ export default function TabLayout() {
borderTopRightRadius: 0,
borderTopWidth: 0,
paddingTop: 8,
paddingBottom: Platform.OS === "android" ? 8 : 26,
height: Platform.OS === "android" ? 58 : 74,
},
tabBarBackground: () =>
Platform.OS === "ios" ? (
@@ -70,6 +69,19 @@ export default function TabLayout() {
),
}}
/>
<Tabs.Screen
name="library"
options={{
headerShown: false,
title: "Library",
tabBarIcon: ({ color, focused }) => (
<TabBarIcon
name={focused ? "apps" : "apps-outline"}
color={color}
/>
),
}}
/>
</Tabs>
);
}

View File

@@ -1,4 +1,3 @@
import { Chromecast } from "@/components/Chromecast";
import { Feather } from "@expo/vector-icons";
import { Stack, useRouter } from "expo-router";
import { Platform, View } from "react-native";
@@ -17,21 +16,8 @@ export default function IndexLayout() {
headerBlurEffect: "prominent",
headerTransparent: Platform.OS === "ios" ? true : false,
headerShadowVisible: false,
headerLeft: () => (
<TouchableOpacity
style={{
marginRight: Platform.OS === "android" ? 17 : 0,
}}
onPress={() => {
router.push("/(auth)/downloads");
}}
>
<Feather name="download" color={"white"} size={22} />
</TouchableOpacity>
),
headerRight: () => (
<View className="flex flex-row items-center space-x-2">
<Chromecast />
<TouchableOpacity
onPress={() => {
router.push("/(auth)/settings");

View File

@@ -1,38 +1,25 @@
import { HorizontalScroll } from "@/components/common/HorrizontalScroll";
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
import { ItemCardText } from "@/components/ItemCardText";
import { LargeMovieCarousel } from "@/components/home/LargeMovieCarousel";
import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList";
import { Loader } from "@/components/Loader";
import { MediaListSection } from "@/components/medialists/MediaListSection";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { Ionicons } from "@expo/vector-icons";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import {
BaseItemDto,
ItemFields,
ItemFilter,
} from "@jellyfin/sdk/lib/generated-client/models";
import {
getChannelsApi,
getItemsApi,
getSuggestionsApi,
getTvShowsApi,
getUserApi,
getUserLibraryApi,
getUserViewsApi,
} from "@jellyfin/sdk/lib/utils/api";
import NetInfo from "@react-native-community/netinfo";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useRouter } from "expo-router";
import { useAtom } from "jotai";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
ActivityIndicator,
RefreshControl,
ScrollView,
TouchableOpacity,
View,
} from "react-native";
import NetInfo, { NetInfoState } from "@react-native-community/netinfo";
import { Button } from "@/components/Button";
import { Ionicons } from "@expo/vector-icons";
import MoviePoster from "@/components/MoviePoster";
import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList";
import { useSettings } from "@/utils/atoms/settings";
import { RefreshControl, ScrollView, View } from "react-native";
export default function index() {
const router = useRouter();
@@ -42,7 +29,24 @@ export default function index() {
const [user] = useAtom(userAtom);
const [loading, setLoading] = useState(false);
const [settings, _] = useSettings();
const [isConnected, setIsConnected] = useState<boolean | null>(null);
useEffect(() => {
const unsubscribe = NetInfo.addEventListener((state) => {
if (state.isConnected == false || state.isInternetReachable === false)
setIsConnected(false);
else setIsConnected(true);
});
NetInfo.fetch().then((state) => {
setIsConnected(state.isConnected);
});
return () => {
unsubscribe();
};
}, []);
const { data, isLoading, isError } = useQuery<BaseItemDto[]>({
queryKey: ["resumeItems", user?.Id],
@@ -77,35 +81,21 @@ export default function index() {
return _nextUpData?.filter((i) => !data?.find((d) => d.Id === i.Id));
}, [_nextUpData]);
const { data: collections, isLoading: isLoadingCollections } = useQuery({
queryKey: ["collections", user?.Id],
const { data: collections } = useQuery({
queryKey: ["collectinos", user?.Id],
queryFn: async () => {
if (!api || !user?.Id) {
return [];
return null;
}
const data = (
await getItemsApi(api).getItems({
userId: user.Id,
})
).data;
const order = ["boxsets", "tvshows", "movies"];
const cs = data.Items?.sort((a, b) => {
if (
order.indexOf(a.CollectionType!) < order.indexOf(b.CollectionType!)
) {
return 1;
}
return -1;
const response = await getUserViewsApi(api).getUserViews({
userId: user.Id,
});
return data.Items || [];
return response.data.Items || null;
},
enabled: !!api && !!user?.Id,
staleTime: 0,
staleTime: 60 * 1000,
});
const movieCollectionId = useMemo(() => {
@@ -178,10 +168,10 @@ export default function index() {
staleTime: 60 * 1000,
});
const { data: mediaListCollection } = useQuery<string | null>({
queryKey: ["mediaListCollection", user?.Id],
const { data: mediaListCollections } = useQuery({
queryKey: ["mediaListCollections-home", user?.Id],
queryFn: async () => {
if (!api || !user?.Id) return null;
if (!api || !user?.Id) return [];
const response = await getItemsApi(api).getItems({
userId: user.Id,
@@ -191,77 +181,29 @@ export default function index() {
includeItemTypes: ["BoxSet"],
});
return response.data.Items?.[0].Id || null;
return [];
},
enabled: !!api && !!user?.Id && settings?.usePopularPlugin === true,
staleTime: 60 * 1000,
});
const { data: popularItems, isLoading: isLoadingPopular } = useQuery<
BaseItemDto[]
>({
queryKey: ["popular", user?.Id],
queryFn: async () => {
if (!api || !user?.Id || !mediaListCollection) return [];
const response = await getItemsApi(api).getItems({
userId: user.Id,
parentId: mediaListCollection,
limit: 10,
});
return response.data.Items || [];
},
enabled: !!api && !!user?.Id && !!mediaListCollection,
staleTime: 60 * 1000,
enabled: !!api && !!user?.Id && false,
staleTime: 0,
});
const refetch = useCallback(async () => {
setLoading(true);
await queryClient.refetchQueries({ queryKey: ["resumeItems", user?.Id] });
await queryClient.refetchQueries({ queryKey: ["items", user?.Id] });
await queryClient.refetchQueries({ queryKey: ["suggestions", user?.Id] });
await queryClient.refetchQueries({ queryKey: ["resumeItems"] });
await queryClient.refetchQueries({ queryKey: ["nextUp-all"] });
await queryClient.refetchQueries({ queryKey: ["recentlyAddedInMovies"] });
await queryClient.refetchQueries({ queryKey: ["recentlyAddedInTVShows"] });
await queryClient.refetchQueries({ queryKey: ["suggestions"] });
await queryClient.refetchQueries({
queryKey: ["mediaListCollections-home"],
});
setLoading(false);
}, [queryClient, user?.Id]);
const [isConnected, setIsConnected] = useState<boolean | null>(null);
useEffect(() => {
const unsubscribe = NetInfo.addEventListener((state) => {
if (state.isConnected == false || state.isInternetReachable === false)
setIsConnected(false);
else setIsConnected(true);
});
NetInfo.fetch().then((state) => {
setIsConnected(state.isConnected);
});
return () => {
unsubscribe();
};
}, []);
if (isConnected === false) {
return (
<View className="flex flex-col items-center justify-center h-full -mt-6 px-8">
<Text className="text-3xl font-bold mb-2">No Internet</Text>
<Text className="text-center opacity-70">
No worries, you can still watch{"\n"}downloaded content.
</Text>
<View className="mt-4">
<Button
color="purple"
onPress={() => router.push("/(auth)/downloads")}
justify="center"
iconRight={
<Ionicons name="arrow-forward" size={20} color="white" />
}
>
Go to downloads
</Button>
</View>
</View>
);
}
@@ -279,7 +221,7 @@ export default function index() {
if (isLoading)
return (
<View className="justify-center items-center h-full">
<ActivityIndicator />
<Loader />
</View>
);
@@ -292,6 +234,8 @@ export default function index() {
}
>
<View className="flex flex-col pt-4 pb-24 gap-y-4">
<LargeMovieCarousel />
<ScrollingCollectionList
title="Continue Watching"
data={data}
@@ -299,13 +243,6 @@ export default function index() {
orientation="horizontal"
/>
<ScrollingCollectionList
title="Popular"
data={popularItems}
loading={isLoadingPopular}
disabled={!mediaListCollection}
/>
<ScrollingCollectionList
title="Next Up"
data={nextUpData}
@@ -325,13 +262,6 @@ export default function index() {
loading={isLoadingRecentlyAddedTVShows}
/>
<ScrollingCollectionList
title="Collections"
data={collections}
loading={isLoadingCollections}
orientation="horizontal"
/>
<ScrollingCollectionList
title="Suggestions"
data={suggestions}

View File

@@ -0,0 +1,30 @@
import { Stack, useRouter } from "expo-router";
import { Platform } from "react-native";
export default function IndexLayout() {
return (
<Stack>
<Stack.Screen
name="index"
options={{
headerShown: true,
headerLargeTitle: true,
headerTitle: "Library",
headerBlurEffect: "prominent",
headerTransparent: Platform.OS === "ios" ? true : false,
headerShadowVisible: false,
}}
/>
<Stack.Screen
name="collections/[collectionId]"
options={{
title: "",
headerShown: true,
headerBlurEffect: "prominent",
headerTransparent: Platform.OS === "ios" ? true : false,
headerShadowVisible: false,
}}
/>
</Stack>
);
}

View File

@@ -0,0 +1,335 @@
import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import { FilterButton } from "@/components/filters/FilterButton";
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
import { ItemCardText } from "@/components/ItemCardText";
import { Loader } from "@/components/Loader";
import MoviePoster from "@/components/posters/MoviePoster";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import {
genreFilterAtom,
sortByAtom,
sortOptions,
sortOrderAtom,
sortOrderOptions,
tagsFilterAtom,
yearFilterAtom,
} from "@/utils/atoms/filters";
import {
BaseItemDtoQueryResult,
BaseItemKind,
} from "@jellyfin/sdk/lib/generated-client/models";
import { getFilterApi, getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { Stack, useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai";
import React, { useCallback, useEffect, useMemo } from "react";
import { NativeScrollEvent, ScrollView, View } from "react-native";
const isCloseToBottom = ({
layoutMeasurement,
contentOffset,
contentSize,
}: NativeScrollEvent) => {
const paddingToBottom = 200;
return (
layoutMeasurement.height + contentOffset.y >=
contentSize.height - paddingToBottom
);
};
const page: React.FC = () => {
const searchParams = useLocalSearchParams();
const { collectionId } = searchParams as { collectionId: string };
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const navigation = useNavigation();
const [selectedGenres, setSelectedGenres] = useAtom(genreFilterAtom);
const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom);
const [selectedTags, setSelectedTags] = useAtom(tagsFilterAtom);
const [sortBy, setSortBy] = useAtom(sortByAtom);
const [sortOrder, setSortOrder] = useAtom(sortOrderAtom);
const { data: collection } = useQuery({
queryKey: ["collection", collectionId],
queryFn: async () => {
if (!api) return null;
const response = await getItemsApi(api).getItems({
userId: user?.Id,
ids: [collectionId],
});
const data = response.data.Items?.[0];
return data;
},
enabled: !!api && !!user?.Id && !!collectionId,
staleTime: 0,
});
const fetchItems = useCallback(
async ({
pageParam,
}: {
pageParam: number;
}): Promise<BaseItemDtoQueryResult | null> => {
if (!api || !collection) return null;
const includeItemTypes: BaseItemKind[] = [];
switch (collection?.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:
break;
}
const response = await getItemsApi(api).getItems({
userId: user?.Id,
parentId: collectionId,
limit: 66,
startIndex: pageParam,
sortBy: [sortBy[0].key, "SortName", "ProductionYear"],
sortOrder: [sortOrder[0].key],
includeItemTypes,
enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"],
recursive: true,
imageTypeLimit: 1,
fields: ["PrimaryImageAspectRatio", "SortName"],
genres: selectedGenres,
tags: selectedTags,
years: selectedYears.map((year) => parseInt(year)),
});
return response.data || null;
},
[
api,
user?.Id,
collectionId,
collection?.CollectionType,
selectedGenres,
selectedYears,
selectedTags,
sortBy,
sortOrder,
]
);
const { data, isFetching, fetchNextPage } = useInfiniteQuery({
queryKey: [
"library-items",
collection,
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
);
if (accumulatedItems < totalItems) {
return lastPage?.Items?.length * pages.length;
} else {
return undefined;
}
},
initialPageParam: 0,
enabled: !!api && !!user?.Id && !!collection,
});
const type = useMemo(() => {
return data?.pages.flatMap((page) => page?.Items)[0]?.Type || null;
}, [data]);
const flatData = useMemo(() => {
return data?.pages.flatMap((p) => p?.Items) || [];
}, [data]);
if (!collection || !collection.CollectionType) return null;
return (
<ScrollView
contentInsetAdjustmentBehavior="automatic"
onScroll={({ nativeEvent }) => {
if (isCloseToBottom(nativeEvent)) {
fetchNextPage();
}
}}
scrollEventThrottle={400}
>
<View className="mt-4 mb-24">
<View className="mb-4">
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View className="flex flex-row space-x-1 px-3">
<ResetFiltersButton />
<FilterButton
collectionId={collectionId}
queryKey="genreFilter"
queryFn={async () => {
if (!api) return null;
const response = await getFilterApi(
api
).getQueryFiltersLegacy({
userId: user?.Id,
includeItemTypes: type ? [type] : [],
parentId: collectionId,
});
return response.data.Genres || [];
}}
set={setSelectedGenres}
values={selectedGenres}
title="Genres"
renderItemLabel={(item) => item.toString()}
searchFilter={(item, search) =>
item.toLowerCase().includes(search.toLowerCase())
}
/>
<FilterButton
collectionId={collectionId}
queryKey="tagsFilter"
queryFn={async () => {
if (!api) return null;
const response = await getFilterApi(
api
).getQueryFiltersLegacy({
userId: user?.Id,
includeItemTypes: type ? [type] : [],
parentId: collectionId,
});
return response.data.Tags || [];
}}
set={setSelectedTags}
values={selectedTags}
title="Tags"
renderItemLabel={(item) => item.toString()}
searchFilter={(item, search) =>
item.toLowerCase().includes(search.toLowerCase())
}
/>
<FilterButton
collectionId={collectionId}
queryKey="yearFilter"
queryFn={async () => {
if (!api) return null;
const response = await getFilterApi(
api
).getQueryFiltersLegacy({
userId: user?.Id,
includeItemTypes: type ? [type] : [],
parentId: collectionId,
});
return (
response.data.Years?.sort((a, b) => b - a).map((y) =>
y.toString()
) || []
);
}}
set={setSelectedYears}
values={selectedYears}
title="Years"
renderItemLabel={(item) => item.toString()}
searchFilter={(item, search) =>
item.toLowerCase().includes(search.toLowerCase())
}
/>
<FilterButton
icon="sort"
collectionId={collectionId}
queryKey="sortByFilter"
queryFn={async () => {
return sortOptions;
}}
set={setSortBy}
values={sortBy}
title="Sort by"
renderItemLabel={(item) => item.value}
searchFilter={(item, search) =>
item.value.toLowerCase().includes(search.toLowerCase()) ||
item.value.toLowerCase().includes(search.toLowerCase())
}
showSearch={false}
/>
<FilterButton
icon="sort"
showSearch={false}
collectionId={collectionId}
queryKey="orderByFilter"
queryFn={async () => {
return sortOrderOptions;
}}
set={setSortOrder}
values={sortOrder}
title="Order by"
renderItemLabel={(item) => item.value}
searchFilter={(item, search) =>
item.value.toLowerCase().includes(search.toLowerCase()) ||
item.value.toLowerCase().includes(search.toLowerCase())
}
/>
</View>
</ScrollView>
{!type && isFetching && (
<Loader
style={{
marginTop: 300,
}}
/>
)}
</View>
<View className="flex flex-row flex-wrap px-4 justify-between after:content-['']">
{flatData.map(
(item, index) =>
item && (
<TouchableItemRouter
key={`${item.Id}`}
style={{
width: "32%",
marginBottom: 4,
}}
item={item}
className={`
`}
>
<MoviePoster item={item} />
<ItemCardText item={item} />
</TouchableItemRouter>
)
)}
{flatData.length % 3 !== 0 && (
<View
style={{
width: "33%",
}}
></View>
)}
</View>
</View>
</ScrollView>
);
};
export default page;

View File

@@ -0,0 +1,104 @@
import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getUserViewsApi } from "@jellyfin/sdk/lib/utils/api";
import { FlashList } from "@shopify/flash-list";
import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useRouter } from "expo-router";
import { useAtom } from "jotai";
import { useMemo } from "react";
import { TouchableOpacity, View } from "react-native";
export default function index() {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const { data, isLoading: isLoading } = useQuery({
queryKey: ["user-views", user?.Id],
queryFn: async () => {
if (!api || !user?.Id) {
return null;
}
const response = await getUserViewsApi(api).getUserViews({
userId: user.Id,
});
return response.data.Items || null;
},
enabled: !!api && !!user?.Id,
staleTime: 60 * 1000,
});
if (isLoading)
return (
<View className="justify-center items-center h-full">
<Loader />
</View>
);
return (
<FlashList
contentInsetAdjustmentBehavior="automatic"
contentContainerStyle={{
paddingTop: 17,
paddingHorizontal: 17,
paddingBottom: 150,
}}
data={data}
renderItem={({ item }) => <CollectionCard collection={item} />}
keyExtractor={(item) => item.Id || ""}
ItemSeparatorComponent={() => <View className="h-4" />}
estimatedItemSize={200}
/>
);
}
interface Props {
collection: BaseItemDto;
}
const CollectionCard: React.FC<Props> = ({ collection }) => {
const router = useRouter();
const [api] = useAtom(apiAtom);
const url = useMemo(
() =>
getPrimaryImageUrl({
api,
item: collection,
}),
[collection]
);
if (!url) return null;
return (
<TouchableOpacity
onPress={() => {
router.push(`/library/collections/${collection.Id}`);
}}
>
<View className="flex justify-center rounded-xl w-full relative border border-neutral-900 h-20 ">
<Image
source={{ uri: url }}
style={{
width: "100%",
height: "100%",
borderRadius: 8,
position: "absolute",
top: 0,
left: 0,
}}
/>
<Text className="font-bold text-xl text-start px-4">
{collection.Name}
</Text>
</View>
</TouchableOpacity>
);
};

View File

@@ -1,25 +1,38 @@
import { HorizontalScroll } from "@/components/common/HorrizontalScroll";
import { Input } from "@/components/common/Input";
import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
import { ItemCardText } from "@/components/ItemCardText";
import MoviePoster from "@/components/MoviePoster";
import Poster from "@/components/Poster";
import { Loader } from "@/components/Loader";
import AlbumCover from "@/components/posters/AlbumCover";
import MoviePoster from "@/components/posters/MoviePoster";
import SeriesPoster from "@/components/posters/SeriesPoster";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import { Ionicons } from "@expo/vector-icons";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getSearchApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { router, Stack, useNavigation } from "expo-router";
import { router, useNavigation } from "expo-router";
import { useAtom } from "jotai";
import React, { useLayoutEffect, useState } from "react";
import React, { useLayoutEffect, useMemo, useState } from "react";
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
import { useDebounce } from "use-debounce";
const exampleSearches = [
"Lord of the rings",
"Avengers",
"Game of Thrones",
"Breaking Bad",
"Stranger Things",
"The Mandalorian",
];
export default function search() {
const [search, setSearch] = useState<string>("");
const [debouncedSearch] = useDebounce(search, 500);
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
@@ -36,13 +49,13 @@ export default function search() {
});
}, [navigation]);
const { data: movies } = useQuery({
queryKey: ["search-movies", search],
const { data: movies, isLoading: l1 } = useQuery({
queryKey: ["search-movies", debouncedSearch],
queryFn: async () => {
if (!api || !user || search.length === 0) return [];
if (!api || !user || debouncedSearch.length === 0) return [];
const searchApi = await getSearchApi(api).getSearchHints({
searchTerm: search,
searchTerm: debouncedSearch,
limit: 10,
includeItemTypes: ["Movie"],
});
@@ -51,13 +64,13 @@ export default function search() {
},
});
const { data: series } = useQuery({
queryKey: ["search-series", search],
const { data: series, isLoading: l2 } = useQuery({
queryKey: ["search-series", debouncedSearch],
queryFn: async () => {
if (!api || !user || search.length === 0) return [];
if (!api || !user || debouncedSearch.length === 0) return [];
const searchApi = await getSearchApi(api).getSearchHints({
searchTerm: search,
searchTerm: debouncedSearch,
limit: 10,
includeItemTypes: ["Series"],
});
@@ -65,13 +78,14 @@ export default function search() {
return searchApi.data.SearchHints;
},
});
const { data: episodes } = useQuery({
queryKey: ["search-episodes", search],
const { data: episodes, isLoading: l3 } = useQuery({
queryKey: ["search-episodes", debouncedSearch],
queryFn: async () => {
if (!api || !user || search.length === 0) return [];
if (!api || !user || debouncedSearch.length === 0) return [];
const searchApi = await getSearchApi(api).getSearchHints({
searchTerm: search,
searchTerm: debouncedSearch,
limit: 10,
includeItemTypes: ["Episode"],
});
@@ -80,13 +94,73 @@ export default function search() {
},
});
const { data: artists, isLoading: l4 } = useQuery({
queryKey: ["search-artists", debouncedSearch],
queryFn: async () => {
if (!api || !user || debouncedSearch.length === 0) return [];
const searchApi = await getSearchApi(api).getSearchHints({
searchTerm: debouncedSearch,
limit: 10,
includeItemTypes: ["MusicArtist"],
});
return searchApi.data.SearchHints;
},
});
const { data: albums, isLoading: l5 } = useQuery({
queryKey: ["search-albums", debouncedSearch],
queryFn: async () => {
if (!api || !user || debouncedSearch.length === 0) return [];
const searchApi = await getSearchApi(api).getSearchHints({
searchTerm: debouncedSearch,
limit: 10,
includeItemTypes: ["MusicAlbum"],
});
return searchApi.data.SearchHints;
},
});
const { data: songs, isLoading: l6 } = useQuery({
queryKey: ["search-songs", debouncedSearch],
queryFn: async () => {
if (!api || !user || debouncedSearch.length === 0) return [];
const searchApi = await getSearchApi(api).getSearchHints({
searchTerm: debouncedSearch,
limit: 10,
includeItemTypes: ["Audio"],
});
return searchApi.data.SearchHints;
},
});
const noResults = useMemo(() => {
return !(
artists?.length ||
albums?.length ||
songs?.length ||
movies?.length ||
episodes?.length ||
series?.length
);
}, [artists, episodes, albums, songs, movies, series]);
const loading = useMemo(() => {
return l1 || l2 || l3 || l4 || l5 || l6;
}, [l1, l2, l3, l4, l5, l6]);
return (
<>
<ScrollView
keyboardDismissMode="on-drag"
contentInsetAdjustmentBehavior="automatic"
>
<View className="flex flex-col pt-2 pb-20">
<View className="flex flex-col pt-4 pb-32">
{Platform.OS === "android" && (
<View className="mb-4 px-4">
<Input
@@ -99,8 +173,8 @@ export default function search() {
/>
</View>
)}
<Text className="font-bold text-2xl px-4 mb-2">Movies</Text>
<SearchItemWrapper
header="Movies"
ids={movies?.map((m) => m.Id!)}
renderItem={(data) => (
<HorizontalScroll<BaseItemDto>
@@ -112,7 +186,9 @@ export default function search() {
onPress={() => router.push(`/items/${item.Id}`)}
>
<MoviePoster item={item} key={item.Id} />
<Text className="mt-2">{item.Name}</Text>
<Text numberOfLines={2} className="mt-2">
{item.Name}
</Text>
<Text className="opacity-50 text-xs">
{item.ProductionYear}
</Text>
@@ -121,9 +197,9 @@ export default function search() {
/>
)}
/>
<Text className="font-bold text-2xl px-4 my-2">Series</Text>
<SearchItemWrapper
ids={series?.map((m) => m.Id!)}
header="Series"
renderItem={(data) => (
<HorizontalScroll<BaseItemDto>
data={data}
@@ -133,12 +209,10 @@ export default function search() {
onPress={() => router.push(`/series/${item.Id}`)}
className="flex flex-col w-32"
>
<Poster
item={item}
key={item.Id}
url={getPrimaryImageUrl({ api, item })}
/>
<Text className="mt-2">{item.Name}</Text>
<SeriesPoster item={item} key={item.Id} />
<Text numberOfLines={2} className="mt-2">
{item.Name}
</Text>
<Text className="opacity-50 text-xs">
{item.ProductionYear}
</Text>
@@ -147,9 +221,9 @@ export default function search() {
/>
)}
/>
<Text className="font-bold text-2xl px-4 my-2">Episodes</Text>
<SearchItemWrapper
ids={episodes?.map((m) => m.Id!)}
header="Episodes"
renderItem={(data) => (
<HorizontalScroll<BaseItemDto>
data={data}
@@ -166,6 +240,89 @@ export default function search() {
/>
)}
/>
<SearchItemWrapper
ids={artists?.map((m) => m.Id!)}
header="Artists"
renderItem={(data) => (
<HorizontalScroll<BaseItemDto>
data={data}
renderItem={(item) => (
<TouchableItemRouter
item={item}
key={item.Id}
className="flex flex-col w-32"
>
<AlbumCover id={item.Id} />
<ItemCardText item={item} />
</TouchableItemRouter>
)}
/>
)}
/>
<SearchItemWrapper
ids={albums?.map((m) => m.Id!)}
header="Albums"
renderItem={(data) => (
<HorizontalScroll<BaseItemDto>
data={data}
renderItem={(item) => (
<TouchableItemRouter
item={item}
key={item.Id}
className="flex flex-col w-32"
>
<AlbumCover id={item.Id} />
<ItemCardText item={item} />
</TouchableItemRouter>
)}
/>
)}
/>
<SearchItemWrapper
ids={songs?.map((m) => m.Id!)}
header="Songs"
renderItem={(data) => (
<HorizontalScroll<BaseItemDto>
data={data}
renderItem={(item) => (
<TouchableItemRouter
item={item}
key={item.Id}
className="flex flex-col w-32"
>
<AlbumCover id={item.AlbumId} />
<ItemCardText item={item} />
</TouchableItemRouter>
)}
/>
)}
/>
{loading ? (
<View className="mt-4 flex justify-center items-center">
<Loader />
</View>
) : noResults && debouncedSearch.length > 0 ? (
<View>
<Text className="text-center text-lg font-bold mt-4">
No results found for
</Text>
<Text className="text-xs text-purple-600 text-center">
"{debouncedSearch}"
</Text>
</View>
) : debouncedSearch.length === 0 ? (
<View className="mt-4 flex flex-col items-center space-y-2">
{exampleSearches.map((e) => (
<TouchableOpacity
onPress={() => setSearch(e)}
key={e}
className="mb-2"
>
<Text className="text-purple-600">{e}</Text>
</TouchableOpacity>
))}
</View>
) : null}
</View>
</ScrollView>
</>
@@ -175,9 +332,10 @@ export default function search() {
type Props = {
ids?: string[] | null;
renderItem: (data: BaseItemDto[]) => React.ReactNode;
header?: string;
};
const SearchItemWrapper: React.FC<Props> = ({ ids, renderItem }) => {
const SearchItemWrapper: React.FC<Props> = ({ ids, renderItem, header }) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
@@ -193,21 +351,26 @@ const SearchItemWrapper: React.FC<Props> = ({ ids, renderItem }) => {
api,
userId: user.Id,
itemId: id,
}),
})
);
const results = await Promise.all(itemPromises);
// Filter out null items
return results.filter(
(item) => item !== null,
(item) => item !== null
) as unknown as BaseItemDto[];
},
enabled: !!ids && ids.length > 0 && !!api && !!user?.Id,
staleTime: Infinity,
});
if (!data) return <Text className="opacity-50 text-xs px-4">No results</Text>;
if (!data) return null;
return renderItem(data);
return (
<>
<Text className="font-bold text-2xl px-4 my-2">{header}</Text>
{renderItem(data)}
</>
);
};

View File

@@ -1,33 +1,14 @@
import ArtistPoster from "@/components/ArtistPoster";
import { Chromecast } from "@/components/Chromecast";
import { Text } from "@/components/common/Text";
import { Loading } from "@/components/Loading";
import MoviePoster from "@/components/MoviePoster";
import { SongsList } from "@/components/music/SongsList";
import ArtistPoster from "@/components/posters/ArtistPoster";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import { Ionicons } from "@expo/vector-icons";
import {
BaseItemDto,
BaseItemKind,
ItemSortBy,
} from "@jellyfin/sdk/lib/generated-client/models";
import {
getArtistsApi,
getItemsApi,
getUserApi,
getUserLibraryApi,
} from "@jellyfin/sdk/lib/utils/api";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { router, useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai";
import { useEffect, useMemo, useState } from "react";
import {
ActivityIndicator,
ScrollView,
TouchableOpacity,
View,
} from "react-native";
import { useEffect, useState } from "react";
import { ScrollView, TouchableOpacity, View } from "react-native";
export default function page() {
const searchParams = useLocalSearchParams();
@@ -40,20 +21,8 @@ export default function page() {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const [startIndex, setStartIndex] = useState<number>(0);
const navigation = useNavigation();
useEffect(() => {
navigation.setOptions({
headerRight: () => (
<View className="">
<Chromecast />
</View>
),
});
});
const { data: album } = useQuery({
queryKey: ["album", albumId, artistId],
queryFn: async () => {
@@ -119,6 +88,21 @@ export default function page() {
<View className="flex flex-col shrink">
<Text className="font-bold text-3xl">{album?.Name}</Text>
<Text className="">{album?.ProductionYear}</Text>
<View className="flex flex-row space-x-2 mt-1">
{album.AlbumArtists?.map((a) => (
<TouchableOpacity
key={a.Id}
onPress={() => {
router.push(`/artists/${a.Id}/page`);
}}
>
<Text className="font-bold text-purple-600">
{album?.AlbumArtist}
</Text>
</TouchableOpacity>
))}
</View>
</View>
</View>
<SongsList

View File

@@ -1,4 +1,4 @@
import ArtistPoster from "@/components/ArtistPoster";
import ArtistPoster from "@/components/posters/ArtistPoster";
import { Text } from "@/components/common/Text";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";

View File

@@ -1,26 +1,14 @@
import ArtistPoster from "@/components/ArtistPoster";
import { Text } from "@/components/common/Text";
import { Loading } from "@/components/Loading";
import MoviePoster from "@/components/MoviePoster";
import ArtistPoster from "@/components/posters/ArtistPoster";
import MoviePoster from "@/components/posters/MoviePoster";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { Ionicons } from "@expo/vector-icons";
import {
BaseItemDto,
BaseItemKind,
ItemSortBy,
} from "@jellyfin/sdk/lib/generated-client/models";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getArtistsApi, getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { router, useLocalSearchParams } from "expo-router";
import { useAtom } from "jotai";
import { useEffect, useMemo, useState } from "react";
import {
ActivityIndicator,
FlatList,
ScrollView,
TouchableOpacity,
View,
} from "react-native";
import { useMemo, useState } from "react";
import { FlatList, TouchableOpacity, View } from "react-native";
export default function page() {
const searchParams = useLocalSearchParams();

View File

@@ -1,7 +1,7 @@
import ArtistPoster from "@/components/ArtistPoster";
import { Text } from "@/components/common/Text";
import { Loading } from "@/components/Loading";
import MoviePoster from "@/components/MoviePoster";
import { Loader } from "@/components/Loader";
import ArtistPoster from "@/components/posters/ArtistPoster";
import MoviePoster from "@/components/posters/MoviePoster";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { Ionicons } from "@expo/vector-icons";
import {
@@ -13,13 +13,8 @@ import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { router, useLocalSearchParams } from "expo-router";
import { useAtom } from "jotai";
import { useEffect, useMemo, useState } from "react";
import {
ActivityIndicator,
ScrollView,
TouchableOpacity,
View,
} from "react-native";
import { useMemo, useState } from "react";
import { ScrollView, TouchableOpacity, View } from "react-native";
const page: React.FC = () => {
const searchParams = useLocalSearchParams();
@@ -155,7 +150,7 @@ const page: React.FC = () => {
</View>
{isLoading ? (
<View className="my-12">
<ActivityIndicator color={"white"} />
<Loader />
</View>
) : (
<View className="flex flex-row flex-wrap">

View File

@@ -1,184 +0,0 @@
import { Text } from "@/components/common/Text";
import { MovieCard } from "@/components/downloads/MovieCard";
import { SeriesCard } from "@/components/downloads/SeriesCard";
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 { useEffect, useMemo } from "react";
import {
ActivityIndicator,
ScrollView,
TouchableOpacity,
View,
} from "react-native";
import { useAtom } from "jotai";
import { runningProcesses } from "@/utils/atoms/downloads";
import { router } from "expo-router";
import { Ionicons } from "@expo/vector-icons";
import { FFmpegKit } from "ffmpeg-kit-react-native";
import * as FileSystem from "expo-file-system";
import { queueAtom } from "@/utils/atoms/queue";
const downloads: React.FC = () => {
const [process, setProcess] = useAtom(runningProcesses);
const [queue, setQueue] = useAtom(queueAtom);
const { data: downloadedFiles, isLoading } = useQuery({
queryKey: ["downloaded_files", process?.item.Id],
queryFn: async () =>
JSON.parse(
(await AsyncStorage.getItem("downloaded_files")) || "[]",
) as BaseItemDto[],
staleTime: 0,
});
const movies = useMemo(
() => downloadedFiles?.filter((f) => f.Type === "Movie") || [],
[downloadedFiles],
);
const groupedBySeries = useMemo(() => {
const episodes = downloadedFiles?.filter((f) => f.Type === "Episode");
const series: { [key: string]: BaseItemDto[] } = {};
episodes?.forEach((e) => {
if (!series[e.SeriesName!]) series[e.SeriesName!] = [];
series[e.SeriesName!].push(e);
});
return Object.values(series);
}, [downloadedFiles]);
const eta = useMemo(() => {
const length = process?.item?.RunTimeTicks || 0;
if (!process?.speed || !process?.progress) return "";
const timeLeft =
(length - length * (process.progress / 100)) / process.speed;
return formatNumber(timeLeft / 10000);
}, [process]);
if (isLoading) {
return (
<View className="h-full flex flex-col items-center justify-center -mt-6">
<ActivityIndicator size="small" color="white" />
</View>
);
}
return (
<ScrollView>
<View className="px-4 py-4">
<View className="mb-4 flex flex-col space-y-4">
<View>
<Text className="text-2xl font-bold mb-2">Queue</Text>
<View className="flex flex-col space-y-2">
{queue.map((q) => (
<TouchableOpacity
onPress={() => router.push(`/(auth)/items/${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"
>
<View>
<Text className="font-semibold">{q.item.Name}</Text>
<Text className="text-xs opacity-50">{q.item.Type}</Text>
</View>
<TouchableOpacity
onPress={() => {
setQueue((prev) => prev.filter((i) => i.id !== q.id));
}}
>
<Ionicons name="close" size={24} color="red" />
</TouchableOpacity>
</TouchableOpacity>
))}
</View>
{queue.length === 0 && (
<Text className="opacity-50">No items in queue</Text>
)}
</View>
<View>
<Text className="text-2xl font-bold mb-2">Active download</Text>
{process?.item ? (
<TouchableOpacity
onPress={() => router.push(`/(auth)/items/${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"
>
<View>
<Text className="font-semibold">{process.item.Name}</Text>
<Text className="text-xs opacity-50">
{process.item.Type}
</Text>
<View className="flex flex-row items-center space-x-2 mt-1 text-purple-600">
<Text className="text-xs">
{process.progress.toFixed(0)}%
</Text>
<Text className="text-xs">
{process.speed?.toFixed(2)}x
</Text>
<View>
<Text className="text-xs">ETA {eta}</Text>
</View>
</View>
</View>
<TouchableOpacity
onPress={() => {
FFmpegKit.cancel();
setProcess(null);
}}
>
<Ionicons name="close" size={24} color="red" />
</TouchableOpacity>
<View
className={`
absolute bottom-0 left-0 h-1 bg-purple-600
`}
style={{
width: process.progress
? `${Math.max(5, process.progress)}%`
: "5%",
}}
></View>
</TouchableOpacity>
) : (
<Text className="opacity-50">No active downloads</Text>
)}
</View>
</View>
{movies.length > 0 && (
<View className="mb-4">
<View className="flex flex-row items-center justify-between mb-2">
<Text className="text-2xl font-bold">Movies</Text>
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center">
<Text className="text-xs font-bold">{movies?.length}</Text>
</View>
</View>
{movies?.map((item: BaseItemDto) => (
<View className="mb-2 last:mb-0" key={item.Id}>
<MovieCard item={item} />
</View>
))}
</View>
)}
{groupedBySeries?.map((items: BaseItemDto[], index: number) => (
<SeriesCard items={items} key={items[0].SeriesId} />
))}
</View>
</ScrollView>
);
};
export default downloads;
/*
* Format a number (Date.getTime) to a human readable string ex. 2m 34s
* @param {number} num - The number to format
*
* @returns {string} - The formatted string
*/
const formatNumber = (num: number) => {
const minutes = Math.floor(num / 60000);
const seconds = ((num % 60000) / 1000).toFixed(0);
return `${minutes}m ${seconds}s`;
};

View File

@@ -5,7 +5,7 @@ import {
fullScreenAtom,
playingAtom,
} from "@/components/CurrentlyPlayingBar";
import { DownloadItem } from "@/components/DownloadItem";
import { Loader } from "@/components/Loader";
import { OverviewText } from "@/components/OverviewText";
import { PlayButton } from "@/components/PlayButton";
import { PlayedStatus } from "@/components/PlayedStatus";
@@ -19,29 +19,22 @@ import { CurrentSeries } from "@/components/series/CurrentSeries";
import { NextEpisodeButton } from "@/components/series/NextEpisodeButton";
import { SeriesTitleHeader } from "@/components/series/SeriesTitleHeader";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import { chromecastProfile } from "@/utils/profiles/chromecast";
import ios12 from "@/utils/profiles/ios12";
import ios from "@/utils/profiles/ios";
import native from "@/utils/profiles/native";
import old from "@/utils/profiles/old";
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useLocalSearchParams } from "expo-router";
import { useAtom } from "jotai";
import { useCallback, useMemo, useState } from "react";
import { ActivityIndicator, ScrollView, View } from "react-native";
import CastContext, {
PlayServicesState,
useCastDevice,
useRemoteMediaClient,
} from "react-native-google-cast";
import { View } from "react-native";
import { ParallaxScrollView } from "../../../components/ParallaxPage";
import { useSettings } from "@/utils/atoms/settings";
import ios from "@/utils/profiles/ios";
import native from "@/utils/profiles/native";
import old from "@/utils/profiles/old";
const page: React.FC = () => {
const local = useLocalSearchParams();
@@ -52,14 +45,10 @@ const page: React.FC = () => {
const [settings] = useSettings();
const castDevice = useCastDevice();
const [, setCurrentlyPlying] = useAtom(currentlyPlayingItemAtom);
const [, setPlaying] = useAtom(playingAtom);
const [, setFullscreen] = useAtom(fullScreenAtom);
const client = useRemoteMediaClient();
const chromecastReady = useMemo(() => !!castDevice?.deviceId, [castDevice]);
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
const [selectedSubtitleStream, setSelectedSubtitleStream] =
useState<number>(0);
@@ -100,7 +89,6 @@ const page: React.FC = () => {
"playbackUrl",
item?.Id,
maxBitrate,
castDevice,
selectedAudioStream,
selectedSubtitleStream,
settings,
@@ -110,9 +98,7 @@ const page: React.FC = () => {
let deviceProfile: any = ios;
if (castDevice?.deviceId) {
deviceProfile = chromecastProfile;
} else if (settings?.deviceProfile === "Native") {
if (settings?.deviceProfile === "Native") {
deviceProfile = native;
} else if (settings?.deviceProfile === "Old") {
deviceProfile = old;
@@ -143,37 +129,16 @@ const page: React.FC = () => {
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 {
setCurrentlyPlying({
item,
playbackUrl,
});
setPlaying(true);
if (settings?.openFullScreenVideoPlayerByDefault === true) {
setFullscreen(true);
}
setCurrentlyPlying({
item,
playbackUrl,
});
setPlaying(true);
if (settings?.openFullScreenVideoPlayerByDefault === true) {
setFullscreen(true);
}
},
[playbackUrl, item, settings],
[playbackUrl, item, settings]
);
const backdropUrl = useMemo(
@@ -184,18 +149,18 @@ const page: React.FC = () => {
quality: 90,
width: 1000,
}),
[item],
[item]
);
const logoUrl = useMemo(
() => (item?.Type === "Movie" ? getLogoImageUrlById({ api, item }) : null),
[item],
[item]
);
if (l1)
return (
<View className="justify-center items-center h-full">
<ActivityIndicator />
<Loader />
</View>
);
@@ -244,12 +209,7 @@ const page: React.FC = () => {
<Ratings item={item} />
</View>
<View className="flex flex-row justify-between items-center w-full my-4">
{playbackUrl ? (
<DownloadItem item={item} playbackUrl={playbackUrl} />
) : (
<View className="h-12 aspect-square flex items-center justify-center"></View>
)}
<View className="flex flex-row justify-between items-center mb-2">
<PlayedStatus item={item} />
</View>
@@ -276,36 +236,13 @@ const page: React.FC = () => {
<NextEpisodeButton item={item} type="previous" className="mr-2" />
<PlayButton
item={item}
chromecastReady={chromecastReady}
chromecastReady={false}
onPress={onPressPlay}
className="grow"
/>
<NextEpisodeButton item={item} className="ml-2" />
</View>
</View>
<ScrollView horizontal className="flex px-4 mb-4">
<View className="flex flex-row space-x-2 ">
<View className="flex flex-col">
<Text className="text-sm opacity-70">Video</Text>
<Text className="text-sm opacity-70">Audio</Text>
<Text className="text-sm opacity-70">Subtitles</Text>
</View>
<View className="flex flex-col">
<Text className="text-sm opacity-70">
{item.MediaStreams?.find((i) => i.Type === "Video")?.DisplayTitle}
</Text>
<Text className="text-sm opacity-70">
{item.MediaStreams?.find((i) => i.Type === "Audio")?.DisplayTitle}
</Text>
<Text className="text-sm opacity-70">
{
item.MediaStreams?.find((i) => i.Type === "Subtitle")
?.DisplayTitle
}
</Text>
</View>
</View>
</ScrollView>
<CastAndCrew item={item} />

View File

@@ -6,13 +6,9 @@ import { clearLogs, readFromLog } from "@/utils/log";
import { useQuery } from "@tanstack/react-query";
import { useAtom } from "jotai";
import { ScrollView, View } from "react-native";
import * as Haptics from "expo-haptics";
import { useFiles } from "@/hooks/useFiles";
import { SettingToggles } from "@/components/settings/SettingToggles";
export default function settings() {
const { logout } = useJellyfin();
const { deleteAllFiles } = useFiles();
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
@@ -33,30 +29,14 @@ export default function settings() {
<ListItem title="Server" subTitle={api?.basePath} />
</View>
<SettingToggles />
<View className="flex flex-col space-y-2">
<Button color="black" onPress={logout}>
Log out
</Button>
<Button
color="red"
onPress={async () => {
await deleteAllFiles();
Haptics.notificationAsync(
Haptics.NotificationFeedbackType.Success,
);
}}
>
Delete all downloaded files
</Button>
<Button
color="red"
onPress={async () => {
await clearLogs();
Haptics.notificationAsync(
Haptics.NotificationFeedbackType.Success,
);
}}
>
Delete all logs

View File

@@ -1,37 +1,30 @@
import { Text } from "@/components/common/Text";
import { DownloadItem } from "@/components/DownloadItem";
import { SimilarItems } from "@/components/SimilarItems";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
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 { ActivityIndicator, ScrollView, View } from "react-native";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
import { PlayButton } from "@/components/PlayButton";
import { AudioTrackSelector } from "@/components/AudioTrackSelector";
import { Bitrate, BitrateSelector } from "@/components/BitrateSelector";
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import CastContext, {
PlayServicesState,
useCastDevice,
useRemoteMediaClient,
} from "react-native-google-cast";
import { chromecastProfile } from "@/utils/profiles/chromecast";
import { Text } from "@/components/common/Text";
import {
currentlyPlayingItemAtom,
playingAtom,
} from "@/components/CurrentlyPlayingBar";
import { AudioTrackSelector } from "@/components/AudioTrackSelector";
import { SubtitleTrackSelector } from "@/components/SubtitleTrackSelector";
import { NextEpisodeButton } from "@/components/series/NextEpisodeButton";
import { Loader } from "@/components/Loader";
import { MoviesTitleHeader } from "@/components/movies/MoviesTitleHeader";
import { ParallaxScrollView } from "@/components/ParallaxPage";
import { Chromecast } from "@/components/Chromecast";
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 { 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 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, useMemo, useState } from "react";
import { ScrollView, View } from "react-native";
const page: React.FC = () => {
const local = useLocalSearchParams();
@@ -42,20 +35,8 @@ const page: React.FC = () => {
const [, setPlaying] = useAtom(playingAtom);
const castDevice = useCastDevice();
const navigation = useNavigation();
useEffect(() => {
navigation.setOptions({
headerRight: () => (
<View className="">
<Chromecast />
</View>
),
});
});
const chromecastReady = useMemo(() => !!castDevice?.deviceId, [castDevice]);
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
const [selectedSubtitleStream, setSelectedSubtitleStream] =
useState<number>(0);
@@ -84,12 +65,12 @@ const page: React.FC = () => {
quality: 90,
width: 1000,
}),
[item],
[item]
);
const logoUrl = useMemo(
() => (item?.Type === "Movie" ? getLogoImageUrlById({ api, item }) : null),
[item],
[item]
);
const { data: sessionData } = useQuery({
@@ -112,7 +93,6 @@ const page: React.FC = () => {
"playbackUrl",
item?.Id,
maxBitrate,
castDevice,
selectedAudioStream,
selectedSubtitleStream,
],
@@ -126,7 +106,7 @@ const page: React.FC = () => {
startTimeTicks: item?.UserData?.PlaybackPositionTicks || 0,
maxStreamingBitrate: maxBitrate.value,
sessionData,
deviceProfile: castDevice?.deviceId ? chromecastProfile : ios,
deviceProfile: ios,
audioStreamIndex: selectedAudioStream,
subtitleStreamIndex: selectedSubtitleStream,
});
@@ -140,46 +120,24 @@ const page: React.FC = () => {
});
const [, setCp] = useAtom(currentlyPlayingItemAtom);
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 {
setCp({
item,
playbackUrl,
});
setPlaying(true);
}
setCp({
item,
playbackUrl,
});
setPlaying(true);
},
[playbackUrl, item],
[playbackUrl, item]
);
if (l1)
return (
<View className="justify-center items-center h-full">
<ActivityIndicator />
<Loader />
</View>
);
@@ -220,14 +178,6 @@ const page: React.FC = () => {
<MoviesTitleHeader item={item} />
<Text className="text-center opacity-50">{item?.ProductionYear}</Text>
</View>
<View className="flex flex-row justify-between items-center w-full my-4">
{playbackUrl ? (
<DownloadItem item={item} playbackUrl={playbackUrl} />
) : (
<View className="h-12 aspect-square flex items-center justify-center"></View>
)}
</View>
</View>
<View className="flex flex-col p-4 w-full">
<View className="flex flex-row items-center space-x-2 w-full">
@@ -250,7 +200,7 @@ const page: React.FC = () => {
<NextEpisodeButton item={item} type="previous" className="mr-2" />
<PlayButton
item={item}
chromecastReady={chromecastReady}
chromecastReady={false}
onPress={onPressPlay}
className="grow"
/>

View File

@@ -8,10 +8,6 @@ import { useEffect } from "react";
export default function NotFoundScreen() {
const pathname = usePathname();
useEffect(() => {
console.log(`Navigated to ${pathname}`);
}, [pathname]);
return (
<>
<Stack.Screen options={{ title: "Oops!" }} />

View File

@@ -1,20 +1,19 @@
import { CurrentlyPlayingBar } from "@/components/CurrentlyPlayingBar";
import { JellyfinProvider } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { ActionSheetProvider } from "@expo/react-native-action-sheet";
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
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 * as SplashScreen from "expo-splash-screen";
import { Provider as JotaiProvider } from "jotai";
import { useEffect, useRef, useState } from "react";
import "react-native-reanimated";
import * as ScreenOrientation from "expo-screen-orientation";
import { StatusBar } from "expo-status-bar";
import { CurrentlyPlayingBar } from "@/components/CurrentlyPlayingBar";
import { ActionSheetProvider } from "@expo/react-native-action-sheet";
import { useJobProcessor } from "@/utils/atoms/queue";
import { JobQueueProvider } from "@/providers/JobQueueProvider";
import { useKeepAwake } from "expo-keep-awake";
import { useSettings } from "@/utils/atoms/settings";
import { Provider as JotaiProvider } from "jotai";
import { useEffect, useRef } from "react";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import "react-native-reanimated";
// Prevent the splash screen from auto-hiding before asset loading is complete.
SplashScreen.preventAutoHideAsync();
@@ -46,8 +45,6 @@ export default function RootLayout() {
}
function Layout() {
const [settings, updateSettings] = useSettings();
useKeepAwake();
const queryClientRef = useRef<QueryClient>(
@@ -61,119 +58,103 @@ function Layout() {
retryOnMount: true,
},
},
}),
})
);
useEffect(() => {
if (settings?.autoRotate === true)
ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.DEFAULT);
else
ScreenOrientation.lockAsync(
ScreenOrientation.OrientationLock.PORTRAIT_UP,
);
}, [settings]);
return (
<QueryClientProvider client={queryClientRef.current}>
<JobQueueProvider>
<GestureHandlerRootView style={{ flex: 1 }}>
<QueryClientProvider client={queryClientRef.current}>
<ActionSheetProvider>
<JellyfinProvider>
<StatusBar style="light" backgroundColor="#000" />
<ThemeProvider value={DarkTheme}>
<Stack initialRouteName="/home">
<Stack.Screen
name="(auth)/(tabs)"
options={{
headerShown: false,
title: "",
}}
/>
<Stack.Screen
name="(auth)/settings"
options={{
headerShown: true,
title: "Settings",
headerStyle: { backgroundColor: "black" },
headerShadowVisible: false,
}}
/>
<Stack.Screen
name="(auth)/downloads"
options={{
headerShown: true,
title: "Downloads",
headerStyle: { backgroundColor: "black" },
headerShadowVisible: false,
}}
/>
<Stack.Screen
name="(auth)/items/[id]"
options={{
title: "",
headerShown: false,
}}
/>
<Stack.Screen
name="(auth)/collections/[collectionId]"
options={{
title: "",
headerShown: true,
headerStyle: { backgroundColor: "black" },
headerShadowVisible: false,
}}
/>
<Stack.Screen
name="(auth)/artists/page"
options={{
title: "",
headerShown: true,
headerStyle: { backgroundColor: "black" },
headerShadowVisible: false,
}}
/>
<Stack.Screen
name="(auth)/artists/[artistId]/page"
options={{
title: "",
headerShown: true,
headerStyle: { backgroundColor: "black" },
headerShadowVisible: false,
}}
/>
<Stack.Screen
name="(auth)/albums/[albumId]"
options={{
title: "",
headerShown: true,
headerStyle: { backgroundColor: "black" },
headerShadowVisible: false,
}}
/>
<Stack.Screen
name="(auth)/songs/[songId]"
options={{
title: "",
headerShown: false,
}}
/>
<Stack.Screen
name="(auth)/series/[id]"
options={{
title: "",
headerShown: false,
}}
/>
<Stack.Screen
name="login"
options={{ headerShown: false, title: "Login" }}
/>
<Stack.Screen name="+not-found" />
</Stack>
<CurrentlyPlayingBar />
</ThemeProvider>
</JellyfinProvider>
<BottomSheetModalProvider>
<JellyfinProvider>
<StatusBar style="light" backgroundColor="#000" />
<ThemeProvider value={DarkTheme}>
<Stack initialRouteName="/home">
<Stack.Screen
name="(auth)/(tabs)"
options={{
headerShown: false,
title: "",
}}
/>
<Stack.Screen
name="(auth)/settings"
options={{
headerShown: true,
title: "Settings",
headerStyle: { backgroundColor: "black" },
headerShadowVisible: false,
}}
/>
<Stack.Screen
name="(auth)/items/[id]"
options={{
title: "",
headerShown: false,
}}
/>
<Stack.Screen
name="(auth)/collections/[collectionId]"
options={{
title: "",
headerShown: true,
headerStyle: { backgroundColor: "black" },
headerShadowVisible: false,
}}
/>
<Stack.Screen
name="(auth)/artists/page"
options={{
title: "",
headerShown: true,
headerStyle: { backgroundColor: "black" },
headerShadowVisible: false,
}}
/>
<Stack.Screen
name="(auth)/artists/[artistId]/page"
options={{
title: "",
headerShown: true,
headerStyle: { backgroundColor: "black" },
headerShadowVisible: false,
}}
/>
<Stack.Screen
name="(auth)/albums/[albumId]"
options={{
title: "",
headerShown: true,
headerStyle: { backgroundColor: "black" },
headerShadowVisible: false,
}}
/>
<Stack.Screen
name="(auth)/songs/[songId]"
options={{
title: "",
headerShown: false,
}}
/>
<Stack.Screen
name="(auth)/series/[id]"
options={{
title: "",
headerShown: false,
}}
/>
<Stack.Screen
name="login"
options={{ headerShown: false, title: "Login" }}
/>
<Stack.Screen name="+not-found" />
</Stack>
<CurrentlyPlayingBar />
</ThemeProvider>
</JellyfinProvider>
</BottomSheetModalProvider>
</ActionSheetProvider>
</JobQueueProvider>
</QueryClientProvider>
</QueryClientProvider>
</GestureHandlerRootView>
);
}

View File

@@ -6,7 +6,13 @@ import { Ionicons } from "@expo/vector-icons";
import { AxiosError } from "axios";
import { useAtom } from "jotai";
import React, { useMemo, useState } from "react";
import { KeyboardAvoidingView, Platform, View } from "react-native";
import {
Alert,
KeyboardAvoidingView,
Platform,
SafeAreaView,
View,
} from "react-native";
import { z } from "zod";
@@ -46,102 +52,134 @@ const Login: React.FC = () => {
};
const handleConnect = (url: string) => {
if (!url.startsWith("http")) {
Alert.alert("Error", "URL needs to start with http or https.");
return;
}
setServer({ address: url.trim() });
};
if (api?.basePath) {
return (
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : "height"}
style={{ flex: 1 }}
>
<View className="flex flex-col px-4 justify-center h-full gap-y-2">
<View>
<Text className="text-3xl font-bold">Streamyfin</Text>
<Text className="opacity-50 mb-2">Server: {api.basePath}</Text>
<SafeAreaView style={{ flex: 1 }}>
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : "height"}
style={{ flex: 1, height: "100%" }}
>
<View className="flex flex-col justify-between px-4 h-full gap-y-2">
<View></View>
<View>
<View className="mb-4">
<Text className="text-3xl font-bold mb-2">Streamyfin</Text>
<Text className="text-neutral-500 mb-2">
Server: {api.basePath}
</Text>
<Button
color="black"
onPress={() => {
removeServer();
setServerURL("");
}}
justify="between"
iconLeft={
<Ionicons
name="arrow-back-outline"
size={18}
color={"white"}
/>
}
>
Change server
</Button>
</View>
<View className="flex flex-col space-y-2">
<Text className="text-2xl font-bold">Log in</Text>
<Text className="text-neutral-500">
Log in to any user account
</Text>
<Input
placeholder="Username"
onChangeText={(text) =>
setCredentials({ ...credentials, username: text })
}
value={credentials.username}
autoFocus
secureTextEntry={false}
keyboardType="default"
returnKeyType="done"
autoCapitalize="none"
textContentType="username"
clearButtonMode="while-editing"
maxLength={500}
/>
<Input
className="mb-2"
placeholder="Password"
onChangeText={(text) =>
setCredentials({ ...credentials, password: text })
}
value={credentials.password}
secureTextEntry
keyboardType="default"
returnKeyType="done"
autoCapitalize="none"
textContentType="password"
clearButtonMode="while-editing"
maxLength={500}
/>
</View>
<Text className="text-red-600 mb-2">{error}</Text>
</View>
<Button
color="black"
onPress={() => {
removeServer();
setServerURL("");
}}
justify="between"
iconLeft={
<Ionicons name="arrow-back-outline" size={18} color={"white"} />
}
onPress={handleLogin}
loading={loading}
className="mt-auto mb-2"
>
Change server
Log in
</Button>
</View>
<View className="flex flex-col space-y-2">
<Text className="text-2xl font-bold">Log in</Text>
<Input
placeholder="Username"
onChangeText={(text) =>
setCredentials({ ...credentials, username: text })
}
value={credentials.username}
autoFocus
secureTextEntry={false}
keyboardType="default"
returnKeyType="done"
autoCapitalize="none"
textContentType="username"
clearButtonMode="while-editing"
maxLength={500}
/>
<Input
className="mb-2"
placeholder="Password"
onChangeText={(text) =>
setCredentials({ ...credentials, password: text })
}
value={credentials.password}
secureTextEntry
keyboardType="default"
returnKeyType="done"
autoCapitalize="none"
textContentType="password"
clearButtonMode="while-editing"
maxLength={500}
/>
</View>
<Text className="text-red-600 mb-2">{error}</Text>
<Button onPress={handleLogin} loading={loading}>
Log in
</Button>
</View>
</KeyboardAvoidingView>
</KeyboardAvoidingView>
</SafeAreaView>
);
}
return (
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : "height"}
style={{ flex: 1 }}
>
<View className="flex flex-col px-4 justify-center h-full">
<View className="flex flex-col gap-y-2">
<Text className="text-3xl font-bold">Streamyfin</Text>
<Text className="opacity-50">Enter a server adress</Text>
<Input
className="mb-2"
placeholder="http(s)://..."
onChangeText={setServerURL}
value={serverURL}
keyboardType="url"
returnKeyType="done"
autoCapitalize="none"
textContentType="URL"
maxLength={500}
/>
<Button onPress={() => handleConnect(serverURL)}>Connect</Button>
<SafeAreaView style={{ flex: 1 }}>
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : "height"}
style={{ flex: 1 }}
>
<View className="flex flex-col px-4 justify-between h-full">
<View></View>
<View className="flex flex-col gap-y-2">
<Text className="text-3xl font-bold">Streamyfin</Text>
<Text className="text-neutral-500">
Connect to your Jellyfin server
</Text>
<Input
placeholder="Server URL"
onChangeText={setServerURL}
value={serverURL}
keyboardType="url"
returnKeyType="done"
autoCapitalize="none"
textContentType="URL"
maxLength={500}
/>
<Text className="opacity-30">
Server URL requires http or https
</Text>
</View>
<Button onPress={() => handleConnect(serverURL)} className="mb-2">
Connect
</Button>
</View>
</View>
</KeyboardAvoidingView>
</KeyboardAvoidingView>
</SafeAreaView>
);
};

View File

@@ -1,7 +1,7 @@
module.exports = function (api) {
api.cache(true);
return {
presets: ['babel-preset-expo'],
plugins: ["nativewind/babel"],
presets: ["babel-preset-expo"],
plugins: ["nativewind/babel", "react-native-reanimated/plugin"],
};
};

BIN
bun.lockb

Binary file not shown.

View File

@@ -1,5 +1,4 @@
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";
@@ -22,12 +21,12 @@ export const AudioTrackSelector: React.FC<Props> = ({
const audioStreams = useMemo(
() =>
item.MediaSources?.[0].MediaStreams?.filter((x) => x.Type === "Audio"),
[item],
[item]
);
const selectedAudioSteam = useMemo(
() => audioStreams?.find((x) => x.Index === selected),
[audioStreams, selected],
[audioStreams, selected]
);
useEffect(() => {
@@ -36,45 +35,9 @@ export const AudioTrackSelector: React.FC<Props> = ({
}, []);
return (
<View className="flex flex-row items-center justify-between" {...props}>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<View className="flex flex-col mb-2">
<Text className="opacity-50 mb-1 text-xs">Audio streams</Text>
<View className="flex flex-row">
<TouchableOpacity className="bg-neutral-900 max-w-32 h-12 rounded-2xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
<Text className="">
{tc(selectedAudioSteam?.DisplayTitle, 13)}
</Text>
</TouchableOpacity>
</View>
</View>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side="bottom"
align="start"
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>Audio streams</DropdownMenu.Label>
{audioStreams?.map((audio, idx: number) => (
<DropdownMenu.Item
key={idx.toString()}
onSelect={() => {
if (audio.Index !== null && audio.Index !== undefined)
onChange(audio.Index);
}}
>
<DropdownMenu.ItemTitle>
{audio.DisplayTitle}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
</View>
<View
className="flex flex-row items-center justify-between"
{...props}
></View>
);
};

View File

@@ -1,5 +1,4 @@
import { TouchableOpacity, View } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "./common/Text";
import { atom, useAtom } from "jotai";
@@ -46,42 +45,9 @@ export const BitrateSelector: React.FC<Props> = ({
...props
}) => {
return (
<View className="flex flex-row items-center justify-between" {...props}>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<View className="flex flex-col mb-2">
<Text className="opacity-50 mb-1 text-xs">Bitrate</Text>
<View className="flex flex-row">
<TouchableOpacity className="bg-neutral-900 h-12 rounded-2xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
<Text>
{BITRATES.find((b) => b.value === selected.value)?.key}
</Text>
</TouchableOpacity>
</View>
</View>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side="bottom"
align="start"
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>Bitrates</DropdownMenu.Label>
{BITRATES?.map((b, index: number) => (
<DropdownMenu.Item
key={index.toString()}
onSelect={() => {
onChange(b);
}}
>
<DropdownMenu.ItemTitle>{b.key}</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
</View>
<View
className="flex flex-row items-center justify-between"
{...props}
></View>
);
};

View File

@@ -1,6 +1,6 @@
import React, { PropsWithChildren, ReactNode, useMemo } from "react";
import { TouchableOpacity, Text, ActivityIndicator, View } from "react-native";
import * as Haptics from "expo-haptics";
import { Text, TouchableOpacity, View } from "react-native";
import { Loader } from "./Loader";
interface ButtonProps extends React.ComponentProps<typeof TouchableOpacity> {
onPress?: () => void;
@@ -50,14 +50,13 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
onPress={() => {
if (!loading && !disabled && onPress) {
onPress();
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
}
}}
disabled={disabled || loading}
{...props}
>
{loading ? (
<ActivityIndicator color={"white"} size={24} />
<Loader />
) : (
<View
className={`

View File

@@ -1,5 +1,4 @@
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
import { reportPlaybackProgress } from "@/utils/jellyfin/playstate/reportPlaybackProgress";
@@ -14,13 +13,7 @@ import { BlurView } from "expo-blur";
import { useRouter, useSegments } from "expo-router";
import { atom, useAtom } from "jotai";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
ActivityIndicator,
Alert,
Platform,
TouchableOpacity,
View,
} from "react-native";
import { Alert, Platform, TouchableOpacity, View } from "react-native";
import Animated, {
useAnimatedStyle,
useSharedValue,
@@ -28,6 +21,7 @@ import Animated, {
} from "react-native-reanimated";
import Video, { OnProgressData, VideoRef } from "react-native-video";
import { Text } from "./common/Text";
import { Loader } from "./Loader";
export const currentlyPlayingItemAtom = atom<{
item: BaseItemDto;
@@ -45,7 +39,7 @@ export const CurrentlyPlayingBar: React.FC = () => {
const [user] = useAtom(userAtom);
const [playing, setPlaying] = useAtom(playingAtom);
const [currentlyPlaying, setCurrentlyPlaying] = useAtom(
currentlyPlayingItemAtom,
currentlyPlayingItemAtom
);
const [fullScreen, setFullScreen] = useAtom(fullScreenAtom);
@@ -143,7 +137,7 @@ export const CurrentlyPlayingBar: React.FC = () => {
sessionId: sessionData.PlaySessionId,
});
},
[sessionData?.PlaySessionId, api, playing, currentlyPlaying?.item.Id],
[sessionData?.PlaySessionId, api, playing, currentlyPlaying?.item.Id]
);
useEffect(() => {
@@ -173,7 +167,6 @@ export const CurrentlyPlayingBar: React.FC = () => {
}, [playing, progress, item, sessionData]);
useEffect(() => {
console.log("Full screen changed", fullScreen);
if (fullScreen === true) {
videoRef.current?.presentFullscreenPlayer();
} else {
@@ -186,7 +179,7 @@ export const CurrentlyPlayingBar: React.FC = () => {
item?.UserData?.PlaybackPositionTicks
? Math.round(item.UserData.PlaybackPositionTicks / 10000)
: 0,
[item],
[item]
);
const backdropUrl = useMemo(
@@ -197,7 +190,7 @@ export const CurrentlyPlayingBar: React.FC = () => {
quality: 70,
width: 200,
}),
[item],
[item]
);
if (!currentlyPlaying || !api) return null;
@@ -284,7 +277,7 @@ export const CurrentlyPlayingBar: React.FC = () => {
console.log(e);
writeToLog(
"ERROR",
"Video playback error: " + JSON.stringify(e),
"Video playback error: " + JSON.stringify(e)
);
Alert.alert("Error", "Cannot play this video file.");
setPlaying(false);
@@ -293,7 +286,7 @@ export const CurrentlyPlayingBar: React.FC = () => {
renderLoader={
item?.Type !== "Audio" && (
<View className="flex flex-col items-center justify-center h-full">
<ActivityIndicator size={"small"} color={"white"} />
<Loader />
</View>
)
}
@@ -303,7 +296,6 @@ export const CurrentlyPlayingBar: React.FC = () => {
<View className="shrink text-xs">
<TouchableOpacity
onPress={() => {
console.log(JSON.stringify(item));
if (item?.Type === "Audio")
router.push(`/albums/${item?.AlbumId}`);
else router.push(`/items/${item?.Id}`);
@@ -331,7 +323,6 @@ export const CurrentlyPlayingBar: React.FC = () => {
{item?.Type === "Audio" && (
<TouchableOpacity
onPress={() => {
console.log(JSON.stringify(item));
router.push(`/albums/${item?.AlbumId}`);
}}
>

View File

@@ -1,137 +0,0 @@
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 Ionicons from "@expo/vector-icons/Ionicons";
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 } from "expo-router";
import { useAtom } from "jotai";
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
import ProgressCircle from "./ProgressCircle";
type DownloadProps = {
item: BaseItemDto;
playbackUrl: string;
};
export const DownloadItem: React.FC<DownloadProps> = ({
item,
playbackUrl,
}) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const [process] = useAtom(runningProcesses);
const [queue, setQueue] = useAtom(queueAtom);
const { startRemuxing } = useRemuxHlsToMp4(playbackUrl, item);
const { data: playbackInfo, isLoading } = useQuery({
queryKey: ["playbackInfo", item.Id],
queryFn: async () => getPlaybackInfo(api, item.Id, user?.Id),
});
const { data: downloaded, isLoading: isLoadingDownloaded } = useQuery({
queryKey: ["downloaded", item.Id],
queryFn: async () => {
if (!item.Id) return false;
const data: BaseItemDto[] = JSON.parse(
(await AsyncStorage.getItem("downloaded_files")) || "[]",
);
return data.some((d) => d.Id === item.Id);
},
enabled: !!item.Id,
});
if (isLoading || isLoadingDownloaded) {
return (
<View className="rounded h-10 aspect-square flex items-center justify-center">
<ActivityIndicator size={"small"} color={"white"} />
</View>
);
}
if (playbackInfo?.MediaSources?.[0].SupportsDirectPlay === false) {
return (
<View className="rounded h-10 aspect-square flex items-center justify-center opacity-50">
<Ionicons name="cloud-download-outline" size={24} color="white" />
</View>
);
}
if (process && process?.item.Id === item.Id) {
return (
<TouchableOpacity
onPress={() => {
router.push("/downloads");
}}
>
<View className="rounded h-10 aspect-square flex items-center justify-center">
{process.progress === 0 ? (
<ActivityIndicator size={"small"} color={"white"} />
) : (
<View className="-rotate-45">
<ProgressCircle
size={24}
fill={process.progress}
width={4}
tintColor="#9334E9"
backgroundColor="#bdc3c7"
/>
</View>
)}
</View>
</TouchableOpacity>
);
}
if (queue.some((i) => i.id === item.Id)) {
return (
<TouchableOpacity
onPress={() => {
router.push("/downloads");
}}
>
<View className="rounded h-10 aspect-square flex items-center justify-center opacity-50">
<Ionicons name="hourglass" size={24} color="white" />
</View>
</TouchableOpacity>
);
}
if (downloaded) {
return (
<TouchableOpacity
onPress={() => {
router.push("/downloads");
}}
>
<View className="rounded h-10 aspect-square flex items-center justify-center">
<Ionicons name="cloud-download" size={26} color="#9333ea" />
</View>
</TouchableOpacity>
);
} else {
return (
<TouchableOpacity
onPress={() => {
queueActions.enqueue(queue, setQueue, {
id: item.Id!,
execute: async () => {
await startRemuxing();
},
item,
});
}}
>
<View className="rounded h-10 aspect-square flex items-center justify-center">
<Ionicons name="cloud-download-outline" size={26} color="white" />
</View>
</TouchableOpacity>
);
}
};

View File

@@ -36,7 +36,7 @@ export const ItemCardText: React.FC<ItemCardProps> = ({ item }) => {
</>
) : (
<>
<Text>{item.Name}</Text>
<Text numberOfLines={2}>{item.Name}</Text>
<Text className="text-xs opacity-50">{item.ProductionYear}</Text>
</>
)}

18
components/Loader.tsx Normal file
View File

@@ -0,0 +1,18 @@
import {
ActivityIndicator,
ActivityIndicatorProps,
Platform,
View,
} from "react-native";
interface Props extends ActivityIndicatorProps {}
export const Loader: React.FC<Props> = ({ ...props }) => {
return (
<ActivityIndicator
size={"small"}
color={Platform.OS === "ios" ? "white" : "#9333ea"}
{...props}
/>
);
};

View File

@@ -1,9 +0,0 @@
import { ActivityIndicator, View } from "react-native";
export const Loading: React.FC = () => {
return (
<View>
<ActivityIndicator />
</View>
);
};

View File

@@ -9,7 +9,6 @@ import Animated, {
useScrollViewOffset,
} from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Chromecast } from "./Chromecast";
const HEADER_HEIGHT = 400;
@@ -33,14 +32,14 @@ export const ParallaxScrollView: React.FC<Props> = ({
translateY: interpolate(
scrollOffset.value,
[-HEADER_HEIGHT, 0, HEADER_HEIGHT],
[-HEADER_HEIGHT / 2, 0, HEADER_HEIGHT * 0.75],
[-HEADER_HEIGHT / 2, 0, HEADER_HEIGHT * 0.75]
),
},
{
scale: interpolate(
scrollOffset.value,
[-HEADER_HEIGHT, 0, HEADER_HEIGHT],
[2, 1, 1],
[2, 1, 1]
),
},
],
@@ -73,15 +72,6 @@ export const ParallaxScrollView: React.FC<Props> = ({
/>
</TouchableOpacity>
<View
className="absolute right-4 z-50 bg-black rounded-full p-0.5"
style={{
top: inset.top + 17,
}}
>
<Chromecast width={22} height={22} />
</View>
{logo && (
<View className="absolute top-[250px] h-[130px] left-0 w-full z-40 px-4 flex justify-center items-center">
{logo}

View File

@@ -4,9 +4,8 @@ import { markAsPlayed } from "@/utils/jellyfin/playstate/markAsPlayed";
import { Ionicons } from "@expo/vector-icons";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useQueryClient } from "@tanstack/react-query";
import * as Haptics from "expo-haptics";
import { useAtom } from "jotai";
import React, { useCallback } from "react";
import React from "react";
import { TouchableOpacity, View } from "react-native";
export const PlayedStatus: React.FC<{ item: BaseItemDto }> = ({ item }) => {
@@ -15,15 +14,15 @@ export const PlayedStatus: React.FC<{ item: BaseItemDto }> = ({ item }) => {
const queryClient = useQueryClient();
const invalidateQueries = useCallback(() => {
const invalidateQueries = () => {
queryClient.invalidateQueries({
queryKey: ["item", item.Id],
queryKey: ["item"],
});
queryClient.invalidateQueries({
queryKey: ["resumeItems", user?.Id],
queryKey: ["resumeItems"],
});
queryClient.invalidateQueries({
queryKey: ["nextUp", item.SeriesId],
queryKey: ["nextUp"],
});
queryClient.invalidateQueries({
queryKey: ["episodes"],
@@ -31,14 +30,16 @@ export const PlayedStatus: React.FC<{ item: BaseItemDto }> = ({ item }) => {
queryClient.invalidateQueries({
queryKey: ["seasons"],
});
}, [api, item.Id, queryClient, user?.Id]);
queryClient.invalidateQueries({
queryKey: ["nextUp-all"],
});
};
return (
<View>
{item.UserData?.Played ? (
<TouchableOpacity
onPress={async () => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
await markAsNotPlayed({
api: api,
itemId: item?.Id,
@@ -54,7 +55,6 @@ export const PlayedStatus: React.FC<{ item: BaseItemDto }> = ({ item }) => {
) : (
<TouchableOpacity
onPress={async () => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
await markAsPlayed({
api: api,
item: item,

View File

@@ -1,20 +1,15 @@
import MoviePoster from "@/components/posters/MoviePoster";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getLibraryApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { router } from "expo-router";
import { useAtom } from "jotai";
import {
ActivityIndicator,
ScrollView,
TouchableOpacity,
View,
} from "react-native";
import ContinueWatchingPoster from "./ContinueWatchingPoster";
import { ItemCardText } from "./ItemCardText";
import { Text } from "./common/Text";
import MoviePoster from "./MoviePoster";
import { useMemo } from "react";
import { ScrollView, TouchableOpacity, View } from "react-native";
import { Text } from "./common/Text";
import { ItemCardText } from "./ItemCardText";
import { Loader } from "./Loader";
type SimilarItemsProps = {
itemId: string;
@@ -42,7 +37,7 @@ export const SimilarItems: React.FC<SimilarItemsProps> = ({ itemId }) => {
const movies = useMemo(
() => similarItems?.filter((i) => i.Type === "Movie") || [],
[similarItems],
[similarItems]
);
return (
@@ -50,7 +45,7 @@ export const SimilarItems: React.FC<SimilarItemsProps> = ({ itemId }) => {
<Text className="px-4 text-2xl font-bold mb-2">Similar items</Text>
{isLoading ? (
<View className="my-12">
<ActivityIndicator />
<Loader />
</View>
) : (
<ScrollView horizontal>

View File

@@ -1,5 +1,4 @@
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";
@@ -22,14 +21,14 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
const subtitleStreams = useMemo(
() =>
item.MediaSources?.[0].MediaStreams?.filter(
(x) => x.Type === "Subtitle",
(x) => x.Type === "Subtitle"
) ?? [],
[item],
[item]
);
const selectedSubtitleSteam = useMemo(
() => subtitleStreams.find((x) => x.Index === selected),
[subtitleStreams, selected],
[subtitleStreams, selected]
);
useEffect(() => {
@@ -37,56 +36,16 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
if (index !== undefined && index !== null) {
onChange(index);
} else {
// Get first subtitle stream
const firstSubtitle = subtitleStreams.find((x) => x.Index !== undefined);
if (firstSubtitle?.Index !== undefined) {
onChange(firstSubtitle.Index);
}
onChange(-1);
}
}, []);
if (subtitleStreams.length === 0) return null;
return (
<View className="flex flex-row items-center justify-between" {...props}>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<View className="flex flex-col mb-2">
<Text className="opacity-50 mb-1 text-xs">Subtitles</Text>
<View className="flex flex-row">
<TouchableOpacity className="bg-neutral-900 max-w-32 h-12 rounded-2xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
<Text className="">
{tc(selectedSubtitleSteam?.DisplayTitle, 13)}
</Text>
</TouchableOpacity>
</View>
</View>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side="bottom"
align="start"
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>Subtitles</DropdownMenu.Label>
{subtitleStreams?.map((subtitle, idx: number) => (
<DropdownMenu.Item
key={idx.toString()}
onSelect={() => {
if (subtitle.Index !== undefined && subtitle.Index !== null)
onChange(subtitle.Index);
}}
>
<DropdownMenu.ItemTitle>
{subtitle.DisplayTitle}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
</View>
<View
className="flex flex-row items-center justify-between"
{...props}
></View>
);
};

View File

@@ -0,0 +1,42 @@
import { useMemo } from "react";
import { StyleSheet, View, ViewProps } from "react-native";
const getItemStyle = (index: number, numColumns: number) => {
const alignItems = (() => {
if (numColumns < 2 || index % numColumns === 0) return "flex-start";
if ((index + 1) % numColumns === 0) return "flex-end";
return "center";
})();
return {
padding: 20,
alignItems,
width: "100%",
} as const;
};
type ColumnItemProps = ViewProps & {
children: React.ReactNode;
index: number;
numColumns: number;
};
export const ColumnItem = ({
children,
index,
numColumns,
...rest
}: ColumnItemProps) => {
return (
<View className="flex flex-col mb-2 p-4" style={{ width: "33.3%" }}>
<View
className={`
`}
{...rest}
>
{children}
</View>
</View>
);
};

View File

@@ -1,16 +1,11 @@
import React, { useEffect } from "react";
import {
ScrollView,
View,
ViewStyle,
ActivityIndicator,
ScrollViewProps,
} from "react-native";
import { ScrollView, ScrollViewProps, View, ViewStyle } from "react-native";
import Animated, {
useAnimatedStyle,
useSharedValue,
withTiming,
} from "react-native-reanimated";
import { Loader } from "../Loader";
import { Text } from "./Text";
interface HorizontalScrollProps<T> extends ScrollViewProps {
@@ -58,7 +53,7 @@ export function HorizontalScroll<T>({
loadingContainerStyle,
]}
>
<ActivityIndicator size="small" />
<Loader />
</View>
);
}

View File

@@ -0,0 +1,164 @@
import React, { useEffect } from "react";
import {
NativeScrollEvent,
ScrollView,
ScrollViewProps,
View,
ViewStyle,
} from "react-native";
import Animated, {
useAnimatedStyle,
useSharedValue,
withTiming,
} from "react-native-reanimated";
import { Loader } from "../Loader";
import { Text } from "./Text";
import { useInfiniteQuery } from "@tanstack/react-query";
import {
BaseItemDto,
BaseItemDtoQueryResult,
} from "@jellyfin/sdk/lib/generated-client/models";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useNavigation } from "expo-router";
import { useAtom } from "jotai";
interface HorizontalScrollProps extends ScrollViewProps {
queryFn: ({
pageParam,
}: {
pageParam: number;
}) => Promise<BaseItemDtoQueryResult | null>;
queryKey: string[];
initialData?: BaseItemDto[];
renderItem: (item: BaseItemDto, index: number) => React.ReactNode;
containerStyle?: ViewStyle;
contentContainerStyle?: ViewStyle;
loadingContainerStyle?: ViewStyle;
height?: number;
loading?: boolean;
}
const isCloseToBottom = ({
layoutMeasurement,
contentOffset,
contentSize,
}: NativeScrollEvent) => {
const paddingToBottom = 50;
return (
layoutMeasurement.height + contentOffset.y >=
contentSize.height - paddingToBottom
);
};
export function InfiniteHorizontalScroll({
queryFn,
queryKey,
initialData = [],
renderItem,
containerStyle,
contentContainerStyle,
loadingContainerStyle,
loading = false,
height = 164,
...props
}: HorizontalScrollProps): React.ReactElement {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const navigation = useNavigation();
const animatedOpacity = useSharedValue(0);
const animatedStyle1 = useAnimatedStyle(() => {
return {
opacity: withTiming(animatedOpacity.value, { duration: 250 }),
};
});
const { data, isFetching, fetchNextPage } = useInfiniteQuery({
queryKey,
queryFn,
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
);
if (accumulatedItems < totalItems) {
return lastPage?.Items?.length * pages.length;
} else {
return undefined;
}
},
initialPageParam: 0,
enabled: !!api && !!user?.Id,
});
useEffect(() => {
if (data) {
animatedOpacity.value = 1;
}
}, [data]);
if (data === undefined || data === null || loading) {
return (
<View
style={[
{
flex: 1,
justifyContent: "center",
alignItems: "center",
},
loadingContainerStyle,
]}
>
<Loader />
</View>
);
}
return (
<ScrollView
horizontal
onScroll={({ nativeEvent }) => {
if (isCloseToBottom(nativeEvent)) {
fetchNextPage();
}
}}
scrollEventThrottle={400}
style={containerStyle}
contentContainerStyle={contentContainerStyle}
showsHorizontalScrollIndicator={false}
{...props}
>
<Animated.View
className={`
flex flex-row px-4
`}
style={[animatedStyle1]}
>
{data?.pages
.flatMap((page) => page?.Items)
.map(
(item, index) =>
item && (
<View className="mr-2" key={index}>
<React.Fragment>{renderItem(item, index)}</React.Fragment>
</View>
)
)}
{data?.pages.flatMap((page) => page?.Items).length === 0 && (
<View className="flex-1 justify-center items-center">
<Text className="text-center text-gray-500">No data available</Text>
</View>
)}
</Animated.View>
</ScrollView>
);
}

View File

@@ -1,17 +1,9 @@
import { useFocusEffect } from "expo-router";
import React, { useEffect } from "react";
import { TextInputProps, TextProps } from "react-native";
import { TextInput } from "react-native";
import React from "react";
import { TextInput, TextInputProps } from "react-native";
export function Input(props: TextInputProps) {
const { style, ...otherProps } = props;
const inputRef = React.useRef<TextInput>(null);
useFocusEffect(
React.useCallback(() => {
inputRef.current?.focus();
}, []),
);
return (
<TextInput
ref={inputRef}

View File

@@ -0,0 +1,59 @@
import {
TouchableOpacity,
TouchableOpacityProps,
View,
ViewProps,
} from "react-native";
import { Text } from "@/components/common/Text";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { PropsWithChildren } from "react";
import { useRouter } from "expo-router";
interface Props extends TouchableOpacityProps {
item: BaseItemDto;
}
export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
item,
children,
...props
}) => {
const router = useRouter();
return (
<TouchableOpacity
onPress={() => {
if (item.Type === "Series") {
router.push(`/series/${item.Id}`);
return;
}
if (item.Type === "Episode") {
router.push(`/items/${item.Id}`);
return;
}
if (item.Type === "MusicAlbum") {
router.push(`/albums/${item.Id}`);
return;
}
if (item.Type === "Audio") {
router.push(`/albums/${item.AlbumId}`);
return;
}
if (item.Type === "MusicArtist") {
router.push(`/artists/${item.Id}/page`);
return;
}
// Movies and all other cases
if (item.Type === "BoxSet") {
router.push(`/collections/${item.Id}`);
return;
}
router.push(`/items/${item.Id}`);
}}
{...props}
>
{children}
</TouchableOpacity>
);
};

View File

@@ -1,96 +0,0 @@
import React, { useCallback } from "react";
import { TouchableOpacity } from "react-native";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import * as ContextMenu from "zeego/context-menu";
import * as Haptics from "expo-haptics";
import * as FileSystem from "expo-file-system";
import { useAtom } from "jotai";
import { Text } from "../common/Text";
import { useFiles } from "@/hooks/useFiles";
import {
currentlyPlayingItemAtom,
fullScreenAtom,
playingAtom,
} from "../CurrentlyPlayingBar";
import { useSettings } from "@/utils/atoms/settings";
interface EpisodeCardProps {
item: BaseItemDto;
}
/**
* EpisodeCard component displays an episode with context menu options.
* @param {EpisodeCardProps} props - The component props.
* @returns {React.ReactElement} The rendered EpisodeCard component.
*/
export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item }) => {
const { deleteFile } = useFiles();
const [, setCurrentlyPlaying] = useAtom(currentlyPlayingItemAtom);
const [, setPlaying] = useAtom(playingAtom);
const [, setFullscreen] = useAtom(fullScreenAtom);
const [settings] = useSettings();
/**
* Handles opening the file for playback.
*/
const handleOpenFile = useCallback(async () => {
setCurrentlyPlaying({
item,
playbackUrl: `${FileSystem.documentDirectory}/${item.Id}.mp4`,
});
setPlaying(true);
if (settings?.openFullScreenVideoPlayerByDefault === true)
setFullscreen(true);
}, [item, setCurrentlyPlaying, settings]);
/**
* Handles deleting the file with haptic feedback.
*/
const handleDeleteFile = useCallback(() => {
if (item.Id) {
deleteFile(item.Id);
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
}
}, [deleteFile, item.Id]);
const contextMenuOptions = [
{
label: "Delete",
onSelect: handleDeleteFile,
destructive: true,
},
];
return (
<ContextMenu.Root>
<ContextMenu.Trigger>
<TouchableOpacity
onPress={handleOpenFile}
className="bg-neutral-900 border border-neutral-800 rounded-2xl p-4"
>
<Text className="font-bold">{item.Name}</Text>
<Text className="text-xs opacity-50">Episode {item.IndexNumber}</Text>
</TouchableOpacity>
</ContextMenu.Trigger>
<ContextMenu.Content
alignOffset={0}
avoidCollisions
collisionPadding={10}
loop={false}
>
{contextMenuOptions.map((option) => (
<ContextMenu.Item
key={option.label}
onSelect={option.onSelect}
destructive={option.destructive}
>
<ContextMenu.ItemTitle style={{ color: "red" }}>
{option.label}
</ContextMenu.ItemTitle>
</ContextMenu.Item>
))}
</ContextMenu.Content>
</ContextMenu.Root>
);
};

View File

@@ -1,99 +0,0 @@
import React, { useCallback } from "react";
import { TouchableOpacity, View } from "react-native";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import * as ContextMenu from "zeego/context-menu";
import * as FileSystem from "expo-file-system";
import * as Haptics from "expo-haptics";
import { useAtom } from "jotai";
import { Text } from "../common/Text";
import { useFiles } from "@/hooks/useFiles";
import { runtimeTicksToMinutes } from "@/utils/time";
import {
currentlyPlayingItemAtom,
fullScreenAtom,
playingAtom,
} from "../CurrentlyPlayingBar";
import { useSettings } from "@/utils/atoms/settings";
interface MovieCardProps {
item: BaseItemDto;
}
/**
* MovieCard component displays a movie with context menu options.
* @param {MovieCardProps} props - The component props.
* @returns {React.ReactElement} The rendered MovieCard component.
*/
export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
const { deleteFile } = useFiles();
const [, setCurrentlyPlaying] = useAtom(currentlyPlayingItemAtom);
const [, setPlaying] = useAtom(playingAtom);
const [, setFullscreen] = useAtom(fullScreenAtom);
const [settings] = useSettings();
/**
* Handles opening the file for playback.
*/
const handleOpenFile = useCallback(() => {
console.log("Open movie file", item.Name);
setCurrentlyPlaying({
item,
playbackUrl: `${FileSystem.documentDirectory}/${item.Id}.mp4`,
});
setPlaying(true);
if (settings?.openFullScreenVideoPlayerByDefault === true) {
setFullscreen(true);
}
}, [item, setCurrentlyPlaying, setPlaying, settings]);
/**
* Handles deleting the file with haptic feedback.
*/
const handleDeleteFile = useCallback(() => {
if (item.Id) {
deleteFile(item.Id);
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
}
}, [deleteFile, item.Id]);
const contextMenuOptions = [
{
label: "Delete",
onSelect: handleDeleteFile,
destructive: true,
},
];
return (
<ContextMenu.Root>
<ContextMenu.Trigger>
<TouchableOpacity
onPress={handleOpenFile}
className="bg-neutral-900 border border-neutral-800 rounded-2xl p-4"
>
<Text className="font-bold">{item.Name}</Text>
<View className="flex flex-col">
<Text className="text-xs opacity-50">{item.ProductionYear}</Text>
<Text className="text-xs opacity-50">
{runtimeTicksToMinutes(item.RunTimeTicks)}
</Text>
</View>
</TouchableOpacity>
</ContextMenu.Trigger>
<ContextMenu.Content>
{contextMenuOptions.map((option) => (
<ContextMenu.Item
key={option.label}
onSelect={option.onSelect}
destructive={option.destructive}
>
<ContextMenu.ItemTitle style={{ color: "red" }}>
{option.label}
</ContextMenu.ItemTitle>
</ContextMenu.Item>
))}
</ContextMenu.Content>
</ContextMenu.Root>
);
};

View File

@@ -1,49 +0,0 @@
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { View } from "react-native";
import { EpisodeCard } from "./EpisodeCard";
import { Text } from "../common/Text";
import { useMemo } from "react";
import { SeasonPicker } from "../series/SeasonPicker";
export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({ items }) => {
const groupBySeason = useMemo(() => {
const seasons: Record<string, BaseItemDto[]> = {};
items.forEach((item) => {
if (!seasons[item.SeasonName!]) {
seasons[item.SeasonName!] = [];
}
seasons[item.SeasonName!].push(item);
});
return Object.values(seasons).sort(
(a, b) => a[0].IndexNumber! - b[0].IndexNumber!
);
}, [items]);
return (
<View>
<View className="flex flex-row items-center justify-between">
<Text className="text-2xl font-bold">{items[0].SeriesName}</Text>
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center">
<Text className="text-xs font-bold">{items.length}</Text>
</View>
</View>
<Text className="opacity-50 mb-2">TV-Series</Text>
{groupBySeason.map((seasonItems, seasonIndex) => (
<View key={seasonIndex}>
<Text className="mb-2 font-semibold">
{seasonItems[0].SeasonName}
</Text>
{seasonItems.map((item, index) => (
<View className="mb-2" key={index}>
<EpisodeCard item={item} />
</View>
))}
</View>
))}
</View>
);
};

View File

@@ -0,0 +1,97 @@
import { Text } from "@/components/common/Text";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { FontAwesome, Ionicons } from "@expo/vector-icons";
import { useQuery } from "@tanstack/react-query";
import { useAtom } from "jotai";
import { useState } from "react";
import { TouchableOpacity, View, ViewProps } from "react-native";
import { FilterSheet } from "./FilterSheet";
interface FilterButtonProps<T> extends ViewProps {
collectionId: string;
showSearch?: boolean;
queryKey: string;
values: T[];
title: string;
set: (value: T[]) => void;
queryFn: (params: any) => Promise<any>;
searchFilter: (item: T, query: string) => boolean;
renderItemLabel: (item: T) => React.ReactNode;
icon?: "filter" | "sort";
}
export const FilterButton = <T,>({
collectionId,
queryFn,
queryKey,
set,
values,
title,
renderItemLabel,
searchFilter,
showSearch = true,
icon = "filter",
...props
}: FilterButtonProps<T>) => {
const [open, setOpen] = useState(false);
const { data: filters } = useQuery<T[]>({
queryKey: [queryKey, collectionId],
queryFn,
staleTime: 0,
});
if (filters?.length === 0) return null;
return (
<>
<TouchableOpacity onPress={() => setOpen(true)}>
<View
className={`
px-3 py-1.5 rounded-full flex flex-row items-center space-x-1
${
values.length > 0
? "bg-purple-600 border border-purple-700"
: "bg-neutral-900 border border-neutral-900"
}
`}
{...props}
>
<Text
className={`
${values.length > 0 ? "text-purple-100" : "text-neutral-100"}
text-xs font-semibold`}
>
{title}
</Text>
{icon === "filter" ? (
<Ionicons
name="filter"
size={14}
color="white"
style={{ opacity: 0.5 }}
/>
) : (
<FontAwesome
name="sort"
size={14}
color="white"
style={{ opacity: 0.5 }}
/>
)}
</View>
</TouchableOpacity>
<FilterSheet<T>
title={title}
open={open}
setOpen={setOpen}
data={filters}
values={values}
set={set}
renderItemLabel={renderItemLabel}
searchFilter={searchFilter}
showSearch={showSearch}
/>
</>
);
};

View File

@@ -0,0 +1,190 @@
import {
BottomSheetBackdrop,
BottomSheetBackdropProps,
BottomSheetFlatList,
BottomSheetModal,
BottomSheetScrollView,
BottomSheetView,
} from "@gorhom/bottom-sheet";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { Text } from "@/components/common/Text";
import { StyleSheet, TouchableOpacity, View, ViewProps } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { Button } from "../Button";
import { Input } from "../common/Input";
interface Props<T> extends ViewProps {
open: boolean;
setOpen: (open: boolean) => void;
data?: T[] | null;
values: T[];
set: (value: T[]) => void;
title: string;
searchFilter: (item: T, query: string) => boolean;
renderItemLabel: (item: T) => React.ReactNode;
showSearch?: boolean;
}
const LIMIT = 100;
export const FilterSheet = <T,>({
values,
data: _data,
open,
set,
setOpen,
title,
searchFilter,
renderItemLabel,
showSearch = true,
...props
}: Props<T>) => {
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const snapPoints = useMemo(() => ["80%"], []);
const [data, setData] = useState<T[]>([]);
const [offset, setOffset] = useState<number>(0);
const [search, setSearch] = useState<string>("");
const filteredData = useMemo(() => {
if (!search) return _data;
const results = [];
for (let i = 0; i < (_data?.length || 0); i++) {
if (_data && searchFilter(_data[i], search)) {
results.push(_data[i]);
}
}
return results.slice(0, 100);
}, [search, _data, searchFilter]);
useEffect(() => {
if (!_data || _data.length === 0) return;
const tmp = new Set(data);
for (let i = offset; i < Math.min(_data.length, offset + LIMIT); i++) {
tmp.add(_data[i]);
}
setData(Array.from(tmp));
}, [offset, _data]);
useEffect(() => {
if (open) bottomSheetModalRef.current?.present();
else bottomSheetModalRef.current?.dismiss();
}, [open]);
const handleSheetChanges = useCallback((index: number) => {
if (index === -1) {
setOpen(false);
}
}, []);
const renderData = useMemo(() => {
if (search.length > 0 && showSearch) return filteredData;
return data;
}, [search, filteredData, data]);
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
),
[]
);
return (
<BottomSheetModal
ref={bottomSheetModalRef}
index={0}
snapPoints={snapPoints}
onChange={handleSheetChanges}
backdropComponent={renderBackdrop}
handleIndicatorStyle={{
backgroundColor: "white",
}}
backgroundStyle={{
backgroundColor: "#171717",
}}
style={{}}
>
<BottomSheetScrollView
style={{
flex: 1,
}}
>
<View className="px-4 mt-2 mb-8">
<Text className="font-bold text-2xl">{title}</Text>
<Text className="mb-2 text-neutral-500">{_data?.length} items</Text>
{showSearch && (
<Input
placeholder="Search..."
className="my-2"
value={search}
onChangeText={(text) => {
setSearch(text);
}}
returnKeyType="done"
/>
)}
<View
style={{
borderRadius: 20,
overflow: "hidden",
}}
className="mb-4 flex flex-col rounded-xl overflow-hidden"
>
{renderData?.map((item, index) => (
<>
<TouchableOpacity
onPress={() => {
set(
values.includes(item)
? values.filter((i) => i !== item)
: [item]
);
setTimeout(() => {
setOpen(false);
}, 250);
}}
key={index}
className=" bg-neutral-800 px-4 py-3 flex flex-row items-center justify-between"
>
<Text>{renderItemLabel(item)}</Text>
{values.includes(item) ? (
<Ionicons name="radio-button-on" size={24} color="white" />
) : (
<Ionicons name="radio-button-off" size={24} color="white" />
)}
</TouchableOpacity>
<View
style={{
height: StyleSheet.hairlineWidth,
}}
className="h-1 divide-neutral-700 "
></View>
</>
))}
</View>
{data.length < (_data?.length || 0) && (
<Button
onPress={() => {
setOffset(offset + 100);
}}
>
Load more
</Button>
)}
</View>
</BottomSheetScrollView>
</BottomSheetModal>
);
};

View File

@@ -0,0 +1,38 @@
import {
genreFilterAtom,
tagsFilterAtom,
yearFilterAtom,
} from "@/utils/atoms/filters";
import { Ionicons } from "@expo/vector-icons";
import { useAtom } from "jotai";
import { TouchableOpacity, TouchableOpacityProps } from "react-native";
interface Props extends TouchableOpacityProps {}
export const ResetFiltersButton: React.FC<Props> = ({ ...props }) => {
const [selectedGenres, setSelectedGenres] = useAtom(genreFilterAtom);
const [selectedTags, setSelectedTags] = useAtom(tagsFilterAtom);
const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom);
if (
selectedGenres.length === 0 &&
selectedTags.length === 0 &&
selectedYears.length === 0
) {
return null;
}
return (
<TouchableOpacity
onPress={() => {
setSelectedGenres([]);
setSelectedTags([]);
setSelectedYears([]);
}}
className="bg-purple-600 rounded-full w-8 h-8 flex items-center justify-center"
{...props}
>
<Ionicons name="close" size={20} color="white" />
</TouchableOpacity>
);
};

View File

@@ -0,0 +1,176 @@
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 { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useRouter } from "expo-router";
import { useAtom } from "jotai";
import React, { useMemo } from "react";
import { Dimensions, View, ViewProps } from "react-native";
import { useSharedValue } from "react-native-reanimated";
import Carousel, {
ICarouselInstance,
Pagination,
} from "react-native-reanimated-carousel";
import { TouchableItemRouter } from "../common/TouchableItemRouter";
import { Loader } from "../Loader";
interface Props extends ViewProps {}
export const LargeMovieCarousel: React.FC<Props> = ({ ...props }) => {
const router = useRouter();
const queryClient = useQueryClient();
const [settings] = useSettings();
const ref = React.useRef<ICarouselInstance>(null);
const progress = useSharedValue<number>(0);
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const onPressPagination = (index: number) => {
ref.current?.scrollTo({
/**
* Calculate the difference between the current index and the target index
* to ensure that the carousel scrolls to the nearest index
*/
count: index - progress.value,
animated: true,
});
};
const { data: mediaListCollection, isLoading: l1 } = useQuery<string | null>({
queryKey: ["mediaListCollection", user?.Id],
queryFn: async () => {
if (!api || !user?.Id) return null;
const response = await getItemsApi(api).getItems({
userId: user.Id,
tags: ["medialist", "promoted"],
recursive: true,
fields: ["Tags"],
includeItemTypes: ["BoxSet"],
});
const id = response.data.Items?.find((c) => c.Name === "sf_carousel")?.Id;
return id || null;
},
enabled: !!api && !!user?.Id && settings?.usePopularPlugin === true,
staleTime: 0,
});
const { data: popularItems, isLoading: l2 } = useQuery<BaseItemDto[]>({
queryKey: ["popular", user?.Id],
queryFn: async () => {
if (!api || !user?.Id || !mediaListCollection) return [];
const response = await getItemsApi(api).getItems({
userId: user.Id,
parentId: mediaListCollection,
limit: 10,
});
return response.data.Items || [];
},
enabled: !!api && !!user?.Id && !!mediaListCollection,
staleTime: 0,
});
const width = Dimensions.get("screen").width;
if (l1 || l2)
return (
<View className="h-[242px] flex items-center justify-center">
<Loader />
</View>
);
if (!popularItems) return null;
return (
<View className="flex flex-col items-center" {...props}>
<Carousel
autoPlay={true}
autoPlayInterval={2000}
loop={true}
ref={ref}
width={width}
height={204}
data={popularItems}
onProgressChange={progress}
renderItem={({ item, index }) => <RenderItem item={item} />}
/>
<Pagination.Basic
progress={progress}
data={popularItems}
dotStyle={{
backgroundColor: "rgba(255,255,255,0.2)",
borderRadius: 50,
}}
activeDotStyle={{
backgroundColor: "rgba(255,255,255,0.8)",
borderRadius: 50,
}}
containerStyle={{ gap: 5, marginTop: 12 }}
onPress={onPressPagination}
/>
</View>
);
};
const RenderItem: React.FC<{ item: BaseItemDto }> = ({ item }) => {
const [api] = useAtom(apiAtom);
const uri = useMemo(() => {
if (!api) return null;
return getBackdropUrl({
api,
item,
quality: 90,
width: 1000,
});
}, [api, item]);
const logoUri = useMemo(() => {
if (!api) return null;
return getLogoImageUrlById({ api, item, height: 100 });
}, [item]);
if (!uri || !logoUri) return null;
return (
<TouchableItemRouter item={item}>
<View className="px-4">
<View className="relative flex justify-center rounded-2xl overflow-hidden border border-neutral-800">
<Image
source={{
uri,
}}
style={{
width: "100%",
height: 200,
borderRadius: 16,
overflow: "hidden",
}}
/>
<View className="absolute bottom-0 left-0 w-full h-24 p-4 flex items-center">
<Image
source={{
uri: logoUri,
}}
style={{
width: "100%",
height: "100%",
resizeMode: "contain",
}}
/>
</View>
</View>
</View>
</TouchableItemRouter>
);
};

View File

@@ -1,11 +1,11 @@
import { TouchableOpacity, View, ViewProps } from "react-native";
import { Text } from "@/components/common/Text";
import MoviePoster from "@/components/posters/MoviePoster";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { router } from "expo-router";
import { View, ViewProps } from "react-native";
import ContinueWatchingPoster from "../ContinueWatchingPoster";
import { ItemCardText } from "../ItemCardText";
import { HorizontalScroll } from "../common/HorrizontalScroll";
import MoviePoster from "../MoviePoster";
import { TouchableItemRouter } from "../common/TouchableItemRouter";
interface Props extends ViewProps {
title: string;
@@ -29,22 +29,17 @@ export const ScrollingCollectionList: React.FC<Props> = ({
return (
<View {...props}>
<Text className="px-4 text-2xl font-bold mb-2">{title}</Text>
<Text className="px-4 text-2xl font-bold mb-2 text-neutral-100">
{title}
</Text>
<HorizontalScroll<BaseItemDto>
data={data}
height={orientation === "vertical" ? 247 : 164}
loading={loading}
renderItem={(item, index) => (
<TouchableOpacity
<TouchableItemRouter
key={index}
onPress={() => {
if (item.Type === "Series") router.push(`/series/${item.Id}`);
else if (item.CollectionType === "music")
router.push(`/artists/page?collectionId=${item.Id}`);
else if (item.Type === "CollectionFolder")
router.push(`/collections/${item.Id}`);
else router.push(`/items/${item.Id}`);
}}
item={item}
className={`flex flex-col
${orientation === "vertical" ? "w-32" : "w-48"}
`}
@@ -57,7 +52,7 @@ export const ScrollingCollectionList: React.FC<Props> = ({
)}
<ItemCardText item={item} />
</View>
</TouchableOpacity>
</TouchableItemRouter>
)}
/>
</View>

View File

@@ -0,0 +1,72 @@
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import {
BaseItemDto,
BaseItemDtoQueryResult,
} from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { useAtom } from "jotai";
import { View, ViewProps } from "react-native";
import { ScrollingCollectionList } from "../home/ScrollingCollectionList";
import { Text } from "../common/Text";
import { InfiniteHorizontalScroll } from "../common/InfiniteHorrizontalScroll";
import { TouchableItemRouter } from "../common/TouchableItemRouter";
import MoviePoster from "../posters/MoviePoster";
import { useCallback } from "react";
interface Props extends ViewProps {
collection: BaseItemDto;
}
export const MediaListSection: React.FC<Props> = ({ collection, ...props }) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const fetchItems = useCallback(
async ({
pageParam,
}: {
pageParam: number;
}): Promise<BaseItemDtoQueryResult | null> => {
if (!api || !user?.Id) return null;
const response = await getItemsApi(api).getItems({
userId: user.Id,
parentId: collection.Id,
startIndex: pageParam,
limit: 10,
});
return response.data;
},
[api, user?.Id, collection.Id]
);
if (!collection) return null;
return (
<View {...props}>
<Text className="px-4 text-2xl font-bold mb-2 text-neutral-100">
{collection.Name}
</Text>
<InfiniteHorizontalScroll
height={247}
renderItem={(item, index) => (
<TouchableItemRouter
key={index}
item={item}
className={`flex flex-col
${"w-32"}
`}
>
<View>
<MoviePoster item={item} />
</View>
</TouchableItemRouter>
)}
queryFn={fetchItems}
queryKey={["media-list", collection.Id!]}
/>
</View>
);
};

View File

@@ -12,13 +12,7 @@ import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { useAtom } from "jotai";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { chromecastProfile } from "@/utils/profiles/chromecast";
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
import CastContext, {
PlayServicesState,
useCastDevice,
useRemoteMediaClient,
} from "react-native-google-cast";
import { currentlyPlayingItemAtom, playingAtom } from "../CurrentlyPlayingBar";
import { useActionSheet } from "@expo/react-native-action-sheet";
import ios from "@/utils/profiles/ios";
@@ -41,40 +35,14 @@ export const SongsListItem: React.FC<Props> = ({
}) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const castDevice = useCastDevice();
const [, setCp] = useAtom(currentlyPlayingItemAtom);
const [, setPlaying] = useAtom(playingAtom);
const client = useRemoteMediaClient();
const { showActionSheetWithOptions } = useActionSheet();
const openSelect = () => {
if (!castDevice?.deviceId) {
play("device");
return;
}
const options = ["Chromecast", "Device", "Cancel"];
const cancelButtonIndex = 2;
showActionSheetWithOptions(
{
options,
cancelButtonIndex,
},
(selectedIndex: number | undefined) => {
switch (selectedIndex) {
case 0:
play("cast");
break;
case 1:
play("device");
break;
case cancelButtonIndex:
break;
}
},
);
play("device");
return;
};
const play = async (type: "device" | "cast") => {
@@ -93,37 +61,16 @@ export const SongsListItem: React.FC<Props> = ({
item,
startTimeTicks: item?.UserData?.PlaybackPositionTicks || 0,
sessionData,
deviceProfile: castDevice?.deviceId ? chromecastProfile : ios,
deviceProfile: ios,
});
if (!url || !item) return;
if (type === "cast" && client) {
await CastContext.getPlayServicesState().then((state) => {
if (state && state !== PlayServicesState.SUCCESS)
CastContext.showPlayServicesErrorDialog(state);
else {
client.loadMedia({
mediaInfo: {
contentUrl: url,
contentType: "video/mp4",
metadata: {
type: item.Type === "Episode" ? "tvShow" : "movie",
title: item.Name || "",
subtitle: item.Overview || "",
},
},
startTime: 0,
});
}
});
} else {
setCp({
item,
playbackUrl: url,
});
setPlaying(true);
}
setCp({
item,
playbackUrl: url,
});
setPlaying(true);
};
return (

View File

@@ -1,9 +1,12 @@
// You can explore the built-in icon families and icons on the web at https://icons.expo.fyi/
import Ionicons from '@expo/vector-icons/Ionicons';
import { type IconProps } from '@expo/vector-icons/build/createIconSet';
import { type ComponentProps } from 'react';
import Ionicons from "@expo/vector-icons/Ionicons";
import { type IconProps } from "@expo/vector-icons/build/createIconSet";
import { type ComponentProps } from "react";
export function TabBarIcon({ style, ...rest }: IconProps<ComponentProps<typeof Ionicons>['name']>) {
return <Ionicons size={28} style={[{ marginBottom: -3 }, style]} {...rest} />;
export function TabBarIcon({
style,
...rest
}: IconProps<ComponentProps<typeof Ionicons>["name"]>) {
return <Ionicons size={26} style={[{ marginBottom: -3 }, style]} {...rest} />;
}

View File

@@ -0,0 +1,82 @@
import { apiAtom } from "@/providers/JellyfinProvider";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { Image } from "expo-image";
import { useAtom } from "jotai";
import { useMemo } from "react";
import { View } from "react-native";
type ArtistPosterProps = {
item?: BaseItemDto | null;
id?: string | null;
showProgress?: boolean;
};
const AlbumCover: React.FC<ArtistPosterProps> = ({ item, id }) => {
const [api] = useAtom(apiAtom);
const url = useMemo(() => {
const u = getPrimaryImageUrl({
api,
item,
});
return u;
}, [item]);
const url2 = useMemo(() => {
const u = getPrimaryImageUrlById({
api,
id,
quality: 85,
width: 300,
});
return u;
}, [item]);
if (!item && id)
return (
<View className="relative rounded-md overflow-hidden border border-neutral-900">
<Image
key={id}
id={id}
source={
url2
? {
uri: url2,
}
: null
}
cachePolicy={"memory-disk"}
contentFit="cover"
style={{
aspectRatio: "1/1",
}}
/>
</View>
);
if (item)
return (
<View className="relative rounded-md overflow-hidden border border-neutral-900">
<Image
key={item.Id}
id={item.Id}
source={
url
? {
uri: url,
}
: null
}
cachePolicy={"memory-disk"}
contentFit="cover"
style={{
aspectRatio: "1/1",
}}
/>
</View>
);
};
export default AlbumCover;

View File

@@ -1,11 +1,10 @@
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 { useMemo } from "react";
import { View } from "react-native";
import { WatchedIndicator } from "./WatchedIndicator";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
type ArtistPosterProps = {
item: BaseItemDto;
@@ -24,7 +23,7 @@ const ArtistPoster: React.FC<ArtistPosterProps> = ({
api,
item,
}),
[item],
[item]
);
if (!url)

View File

@@ -1,11 +1,11 @@
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 "./WatchedIndicator";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { WatchedIndicator } from "@/components/WatchedIndicator";
type MoviePosterProps = {
item: BaseItemDto;
@@ -24,35 +24,38 @@ const MoviePoster: React.FC<MoviePosterProps> = ({
api,
item,
}),
[item],
[item]
);
const [progress, setProgress] = useState(
item.UserData?.PlayedPercentage || 0,
item.UserData?.PlayedPercentage || 0
);
if (!url)
return (
<View
className="rounded-md overflow-hidden border border-neutral-900"
style={{
aspectRatio: "10/15",
}}
></View>
);
const blurhash = useMemo(() => {
const key = item.ImageTags?.["Primary"] as string;
return item.ImageBlurHashes?.["Primary"]?.[key];
}, [item]);
return (
<View className="relative rounded-md overflow-hidden border border-neutral-900">
<Image
placeholder={{
blurhash,
}}
key={item.Id}
id={item.Id}
source={{
uri: url,
}}
source={
url
? {
uri: url,
}
: null
}
cachePolicy={"memory-disk"}
contentFit="cover"
style={{
aspectRatio: "10/15",
width: "100%",
}}
/>
<WatchedIndicator item={item} />

View File

@@ -1,5 +1,4 @@
import { apiAtom } from "@/providers/JellyfinProvider";
import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useAtom } from "jotai";
import { useMemo } from "react";

View File

@@ -9,10 +9,11 @@ type PosterProps = {
item?: BaseItemDto | BaseItemPerson | null;
url?: string | null;
showProgress?: boolean;
blurhash?: string | null;
};
const Poster: React.FC<PosterProps> = ({ item, url }) => {
if (!url || !item)
const Poster: React.FC<PosterProps> = ({ item, url, blurhash }) => {
if (!item)
return (
<View
className="border border-neutral-900"
@@ -25,11 +26,22 @@ const Poster: React.FC<PosterProps> = ({ item, url }) => {
return (
<View className="rounded-md overflow-hidden border border-neutral-900">
<Image
placeholder={
blurhash
? {
blurhash,
}
: null
}
key={item.Id}
id={item.Id}
source={{
uri: url,
}}
source={
url
? {
uri: url,
}
: null
}
cachePolicy={"memory-disk"}
contentFit="cover"
style={{

View File

@@ -0,0 +1,58 @@
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;
};
const SeriesPoster: React.FC<MoviePosterProps> = ({ item }) => {
const [api] = useAtom(apiAtom);
const url = useMemo(
() =>
getPrimaryImageUrl({
api,
item,
}),
[item]
);
const blurhash = useMemo(() => {
const key = item.ImageTags?.["Primary"] as string;
return item.ImageBlurHashes?.["Primary"]?.[key];
}, [item]);
return (
<View className="relative rounded-md overflow-hidden border border-neutral-900">
<Image
placeholder={{
blurhash,
}}
key={item.Id}
id={item.Id}
source={
url
? {
uri: url,
}
: null
}
cachePolicy={"memory-disk"}
contentFit="cover"
style={{
aspectRatio: "10/15",
width: "100%",
}}
/>
</View>
);
};
export default SeriesPoster;

View File

@@ -6,7 +6,7 @@ import React from "react";
import { Linking, TouchableOpacity, View } from "react-native";
import { HorizontalScroll } from "../common/HorrizontalScroll";
import { Text } from "../common/Text";
import Poster from "../Poster";
import Poster from "../posters/Poster";
import { useAtom } from "jotai";
import { apiAtom } from "@/providers/JellyfinProvider";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";

View File

@@ -4,7 +4,7 @@ import { router } from "expo-router";
import { useAtom } from "jotai";
import React from "react";
import { TouchableOpacity, View } from "react-native";
import Poster from "../Poster";
import Poster from "../posters/Poster";
import { HorizontalScroll } from "../common/HorrizontalScroll";
import { Text } from "../common/Text";
import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById";

View File

@@ -3,7 +3,7 @@ import React from "react";
import { TouchableOpacity, View } from "react-native";
import { HorizontalScroll } from "../common/HorrizontalScroll";
import { Text } from "../common/Text";
import Poster from "../Poster";
import Poster from "../posters/Poster";
import ContinueWatchingPoster from "../ContinueWatchingPoster";
import { ItemCardText } from "../ItemCardText";
import { router } from "expo-router";

View File

@@ -5,7 +5,6 @@ import { useRouter } from "expo-router";
import { atom, useAtom } from "jotai";
import { useMemo } from "react";
import { TouchableOpacity, View } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu";
import ContinueWatchingPoster from "../ContinueWatchingPoster";
import { ItemCardText } from "../ItemCardText";
import { HorizontalScroll } from "../common/HorrizontalScroll";
@@ -40,7 +39,7 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
headers: {
Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
},
},
}
);
return response.data.Items;
@@ -51,7 +50,7 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
const selectedSeasonId: string | null = useMemo(
() =>
seasons?.find((season: any) => season.IndexNumber === seasonIndex)?.Id,
[seasons, seasonIndex],
[seasons, seasonIndex]
);
const { data: episodes } = useQuery({
@@ -70,7 +69,7 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
headers: {
Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
},
},
}
);
return response.data.Items as BaseItemDto[];
@@ -80,36 +79,6 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
return (
<View className="mb-2">
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<View className="flex flex-row px-4">
<TouchableOpacity className="bg-neutral-900 rounded-2xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
<Text>Season {seasonIndex}</Text>
</TouchableOpacity>
</View>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side="bottom"
align="start"
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>Seasons</DropdownMenu.Label>
{seasons?.map((season: any) => (
<DropdownMenu.Item
key={season.Name}
onSelect={() => {
setSeasonIndex(season.IndexNumber);
}}
>
<DropdownMenu.ItemTitle>{season.Name}</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
{episodes && (
<View className="mt-4">
<HorizontalScroll<BaseItemDto>

View File

@@ -1,11 +1,43 @@
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { useAtom } from "jotai";
import { Linking, Switch, TouchableOpacity, View } from "react-native";
import { Text } from "../common/Text";
import { useSettings } from "@/utils/atoms/settings";
import * as DropdownMenu from "zeego/dropdown-menu";
import { Loader } from "../Loader";
export const SettingToggles: React.FC = () => {
const [settings, updateSettings] = useSettings();
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const {
data: mediaListCollections,
isLoading: isLoadingMediaListCollections,
} = useQuery({
queryKey: ["mediaListCollections", user?.Id],
queryFn: async () => {
if (!api || !user?.Id) return [];
const response = await getItemsApi(api).getItems({
userId: user.Id,
tags: ["medialist", "promoted"],
recursive: true,
fields: ["Tags"],
includeItemTypes: ["BoxSet"],
});
const ids =
response.data.Items?.filter((c) => c.Name !== "sf_carousel") ?? [];
return ids;
},
enabled: !!api && !!user?.Id && settings?.usePopularPlugin === true,
staleTime: 0,
});
return (
<View className="flex flex-col rounded-xl mb-4 overflow-hidden border-neutral-800 divide-y-2 divide-solid divide-neutral-800 ">
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
@@ -36,25 +68,76 @@ export const SettingToggles: React.FC = () => {
}
/>
</View>
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
<View className="flex flex-col">
<Text className="font-semibold">Use popular lists plugin</Text>
<Text className="text-xs opacity-50">Made by: lostb1t</Text>
<TouchableOpacity
onPress={() => {
Linking.openURL(
"https://github.com/lostb1t/jellyfin-plugin-media-lists",
);
}}
>
<Text className="text-xs text-purple-600">More info</Text>
</TouchableOpacity>
<View className="flex flex-col">
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
<View className="flex flex-col">
<Text className="font-semibold">Use popular lists plugin</Text>
<Text className="text-xs opacity-50">Made by: lostb1t</Text>
<TouchableOpacity
onPress={() => {
Linking.openURL(
"https://github.com/lostb1t/jellyfin-plugin-media-lists"
);
}}
>
<Text className="text-xs text-purple-600">More info</Text>
</TouchableOpacity>
</View>
<Switch
value={settings?.usePopularPlugin}
onValueChange={(value) =>
updateSettings({ usePopularPlugin: value })
}
/>
</View>
<Switch
value={settings?.usePopularPlugin}
onValueChange={(value) => updateSettings({ usePopularPlugin: value })}
/>
{settings?.usePopularPlugin && (
<View className="flex flex-col py-2 bg-neutral-900">
{mediaListCollections?.map((mlc) => (
<View
key={mlc.Id}
className="flex flex-row items-center justify-between bg-neutral-900 px-4 py-2"
>
<View className="flex flex-col">
<Text className="font-semibold">{mlc.Name}</Text>
</View>
<Switch
value={settings?.mediaListCollectionIds?.includes(mlc.Id!)}
onValueChange={(value) => {
if (!settings.mediaListCollectionIds) {
updateSettings({
mediaListCollectionIds: [mlc.Id!],
});
return;
}
updateSettings({
mediaListCollectionIds:
settings?.mediaListCollectionIds.includes(mlc.Id!)
? settings?.mediaListCollectionIds.filter(
(id) => id !== mlc.Id
)
: [...settings?.mediaListCollectionIds, mlc.Id!],
});
}}
/>
</View>
))}
{isLoadingMediaListCollections && (
<View className="flex flex-row items-center justify-center bg-neutral-900 p-4">
<Loader />
</View>
)}
{mediaListCollections?.length === 0 && (
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
<Text className="text-xs opacity-50">
No collections found. Add some in Jellyfin.
</Text>
</View>
)}
</View>
)}
</View>
<View className="flex flex-row space-x-2 items-center justify-between bg-neutral-900 p-4">
<View className="flex flex-col shrink">
<Text className="font-semibold">Force direct play</Text>
@@ -81,48 +164,6 @@ export const SettingToggles: React.FC = () => {
supports.
</Text>
</View>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<TouchableOpacity className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
<Text>{settings?.deviceProfile}</Text>
</TouchableOpacity>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side="bottom"
align="start"
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>Profiles</DropdownMenu.Label>
<DropdownMenu.Item
key="1"
onSelect={() => {
updateSettings({ deviceProfile: "Expo" });
}}
>
<DropdownMenu.ItemTitle>Expo</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
<DropdownMenu.Item
key="2"
onSelect={() => {
updateSettings({ deviceProfile: "Native" });
}}
>
<DropdownMenu.ItemTitle>Native</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
<DropdownMenu.Item
key="3"
onSelect={() => {
updateSettings({ deviceProfile: "Old" });
}}
>
<DropdownMenu.ItemTitle>Old</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</View>
</View>
);

View File

@@ -21,13 +21,13 @@
}
},
"production": {
"channel": "0.6.0",
"channel": "0.6.1",
"android": {
"image": "latest"
}
},
"production-apk": {
"channel": "0.6.0",
"channel": "0.6.1",
"android": {
"buildType": "apk",
"image": "latest"

View File

@@ -1,117 +0,0 @@
import { useCallback, useRef, useState } from "react";
import { useAtom } from "jotai";
import AsyncStorage from "@react-native-async-storage/async-storage";
import * as FileSystem from "expo-file-system";
import { Api } from "@jellyfin/sdk";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { runningProcesses } from "@/utils/atoms/downloads";
/**
* Custom hook for downloading media using the Jellyfin API.
*
* @param api - The Jellyfin API instance
* @param userId - The user ID
* @returns An object with download-related functions and state
*/
export const useDownloadMedia = (api: Api | null, userId?: string | null) => {
const [isDownloading, setIsDownloading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [_, setProgress] = useAtom(runningProcesses);
const downloadResumableRef = useRef<FileSystem.DownloadResumable | null>(
null,
);
const downloadMedia = useCallback(
async (item: BaseItemDto | null): Promise<boolean> => {
if (!item?.Id || !api || !userId) {
setError("Invalid item or API");
return false;
}
setIsDownloading(true);
setError(null);
setProgress({ item, progress: 0 });
try {
const filename = item.Id;
const fileUri = `${FileSystem.documentDirectory}${filename}`;
const url = `${api.basePath}/Items/${item.Id}/File`;
downloadResumableRef.current = FileSystem.createDownloadResumable(
url,
fileUri,
{
headers: {
Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
},
},
(downloadProgress) => {
const currentProgress =
downloadProgress.totalBytesWritten /
downloadProgress.totalBytesExpectedToWrite;
setProgress({ item, progress: currentProgress * 100 });
},
);
const res = await downloadResumableRef.current.downloadAsync();
if (!res?.uri) {
throw new Error("Download failed: No URI returned");
}
await updateDownloadedFiles(item);
setIsDownloading(false);
setProgress(null);
return true;
} catch (error) {
console.error("Error downloading media:", error);
setError("Failed to download media");
setIsDownloading(false);
setProgress(null);
return false;
}
},
[api, userId, setProgress],
);
const cancelDownload = useCallback(async (): Promise<void> => {
if (!downloadResumableRef.current) return;
try {
await downloadResumableRef.current.pauseAsync();
setIsDownloading(false);
setError("Download cancelled");
setProgress(null);
downloadResumableRef.current = null;
} catch (error) {
console.error("Error cancelling download:", error);
setError("Failed to cancel download");
}
}, [setProgress]);
return { downloadMedia, isDownloading, error, cancelDownload };
};
/**
* Updates the list of downloaded files in AsyncStorage.
*
* @param item - The item to add to the downloaded files list
*/
async function updateDownloadedFiles(item: BaseItemDto): Promise<void> {
try {
const currentFiles: BaseItemDto[] = JSON.parse(
(await AsyncStorage.getItem("downloaded_files")) ?? "[]",
);
const updatedFiles = [
...currentFiles.filter((file) => file.Id !== item.Id),
item,
];
await AsyncStorage.setItem(
"downloaded_files",
JSON.stringify(updatedFiles),
);
} catch (error) {
console.error("Error updating downloaded files:", error);
}
}

View File

@@ -1,84 +0,0 @@
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { useQueryClient } from "@tanstack/react-query";
import * as FileSystem from "expo-file-system";
/**
* Custom hook for managing downloaded files.
* @returns An object with functions to delete individual files and all files.
*/
export const useFiles = () => {
const queryClient = useQueryClient();
/**
* Deletes all downloaded files and clears the download record.
*/
const deleteAllFiles = async (): Promise<void> => {
const directoryUri = FileSystem.documentDirectory;
if (!directoryUri) {
console.error("Document directory is undefined");
return;
}
try {
const fileNames = await FileSystem.readDirectoryAsync(directoryUri);
await Promise.all(
fileNames.map((item) =>
FileSystem.deleteAsync(`${directoryUri}/${item}`, {
idempotent: true,
}),
),
);
await AsyncStorage.removeItem("downloaded_files");
queryClient.invalidateQueries({ queryKey: ["downloaded_files"] });
} catch (error) {
console.error("Failed to delete all files:", error);
}
};
/**
* Deletes a specific file and updates the download record.
* @param id - The ID of the file to delete.
*/
const deleteFile = async (id: string): Promise<void> => {
if (!id) {
console.error("Invalid file ID");
return;
}
try {
await FileSystem.deleteAsync(
`${FileSystem.documentDirectory}/${id}.mp4`,
{ idempotent: true },
);
const currentFiles = await getDownloadedFiles();
const updatedFiles = currentFiles.filter((f) => f.Id !== id);
await AsyncStorage.setItem(
"downloaded_files",
JSON.stringify(updatedFiles),
);
queryClient.invalidateQueries({ queryKey: ["downloaded_files"] });
} catch (error) {
console.error(`Failed to delete file with ID ${id}:`, error);
}
};
return { deleteFile, deleteAllFiles };
};
/**
* Retrieves the list of downloaded files from AsyncStorage.
* @returns An array of BaseItemDto objects representing downloaded files.
*/
async function getDownloadedFiles(): Promise<BaseItemDto[]> {
try {
const filesJson = await AsyncStorage.getItem("downloaded_files");
return filesJson ? JSON.parse(filesJson) : [];
} catch (error) {
console.error("Failed to retrieve downloaded files:", error);
return [];
}
}

View File

@@ -1,138 +0,0 @@
import { useCallback } from "react";
import { useAtom } from "jotai";
import AsyncStorage from "@react-native-async-storage/async-storage";
import * as FileSystem from "expo-file-system";
import { FFmpegKit, FFmpegKitConfig } from "ffmpeg-kit-react-native";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { runningProcesses } from "@/utils/atoms/downloads";
import { writeToLog } from "@/utils/log";
/**
* Custom hook for remuxing HLS to MP4 using FFmpeg.
*
* @param url - The URL of the HLS stream
* @param item - The BaseItemDto object representing the media item
* @returns An object with remuxing-related functions
*/
export const useRemuxHlsToMp4 = (url: string, item: BaseItemDto) => {
const [_, setProgress] = useAtom(runningProcesses);
if (!item.Id || !item.Name) {
writeToLog("ERROR", "useRemuxHlsToMp4 ~ missing arguments");
throw new Error("Item must have an Id and Name");
}
const output = `${FileSystem.documentDirectory}${item.Id}.mp4`;
const command = `-y -loglevel quiet -thread_queue_size 512 -protocol_whitelist file,http,https,tcp,tls,crypto -multiple_requests 1 -tcp_nodelay 1 -fflags +genpts -i ${url} -c copy -bufsize 50M -max_muxing_queue_size 4096 ${output}`;
const startRemuxing = useCallback(async () => {
writeToLog(
"INFO",
`useRemuxHlsToMp4 ~ startRemuxing for item ${item.Name}`,
);
try {
setProgress({ item, progress: 0, startTime: new Date(), speed: 0 });
FFmpegKitConfig.enableStatisticsCallback((statistics) => {
const videoLength =
(item.MediaSources?.[0]?.RunTimeTicks || 0) / 10000000; // In seconds
const fps = item.MediaStreams?.[0]?.RealFrameRate || 25;
const totalFrames = videoLength * fps;
const processedFrames = statistics.getVideoFrameNumber();
const speed = statistics.getSpeed();
const percentage =
totalFrames > 0
? Math.floor((processedFrames / totalFrames) * 100)
: 0;
setProgress((prev) =>
prev?.item.Id === item.Id!
? { ...prev, progress: percentage, speed }
: prev,
);
});
// Await the execution of the FFmpeg command and ensure that the callback is awaited properly.
await new Promise<void>((resolve, reject) => {
FFmpegKit.executeAsync(command, async (session) => {
try {
const returnCode = await session.getReturnCode();
if (returnCode.isValueSuccess()) {
await updateDownloadedFiles(item);
writeToLog(
"INFO",
`useRemuxHlsToMp4 ~ remuxing completed successfully for item: ${item.Name}`,
);
resolve();
} else if (returnCode.isValueError()) {
writeToLog(
"ERROR",
`useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}`,
);
reject(new Error("Remuxing failed")); // Reject the promise on error
} else if (returnCode.isValueCancel()) {
writeToLog(
"INFO",
`useRemuxHlsToMp4 ~ remuxing was canceled for item: ${item.Name}`,
);
resolve();
}
setProgress(null);
} catch (error) {
reject(error);
}
});
});
} catch (error) {
console.error("Failed to remux:", error);
writeToLog(
"ERROR",
`useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}`,
);
setProgress(null);
throw error; // Re-throw the error to propagate it to the caller
}
}, [output, item, command, setProgress]);
const cancelRemuxing = useCallback(() => {
FFmpegKit.cancel();
setProgress(null);
writeToLog(
"INFO",
`useRemuxHlsToMp4 ~ remuxing cancelled for item: ${item.Name}`,
);
}, [item.Name, setProgress]);
return { startRemuxing, cancelRemuxing };
};
/**
* Updates the list of downloaded files in AsyncStorage.
*
* @param item - The item to add to the downloaded files list
*/
async function updateDownloadedFiles(item: BaseItemDto): Promise<void> {
try {
const currentFiles: BaseItemDto[] = JSON.parse(
(await AsyncStorage.getItem("downloaded_files")) || "[]",
);
const updatedFiles = [
...currentFiles.filter((i) => i.Id !== item.Id),
item,
];
await AsyncStorage.setItem(
"downloaded_files",
JSON.stringify(updatedFiles),
);
} catch (error) {
console.error("Error updating downloaded files:", error);
writeToLog(
"ERROR",
`Failed to update downloaded files for item: ${item.Name}`,
);
}
}

View File

@@ -15,52 +15,50 @@
"preset": "jest-expo"
},
"dependencies": {
"@config-plugins/ffmpeg-kit-react-native": "^8.0.0",
"@expo/react-native-action-sheet": "^4.1.0",
"@expo/vector-icons": "^14.0.2",
"@gorhom/bottom-sheet": "^4",
"@jellyfin/sdk": "^0.10.0",
"@kesha-antonov/react-native-background-downloader": "^3.2.0",
"@react-native-async-storage/async-storage": "1.23.1",
"@react-native-community/netinfo": "11.3.1",
"@react-native-menu/menu": "^1.1.2",
"@react-native-tvos/config-tv": "^0.0.10",
"@react-navigation/native": "^6.0.2",
"@shopify/flash-list": "1.6.4",
"@tanstack/react-query": "^5.51.16",
"@types/lodash": "^4.17.7",
"@types/uuid": "^10.0.0",
"axios": "^1.7.3",
"expo": "~51.0.27",
"expo": "~51.0.28",
"expo-blur": "~13.0.2",
"expo-build-properties": "~0.12.5",
"expo-constants": "~16.0.2",
"expo-dev-client": "~4.0.22",
"expo-dev-client": "~4.0.23",
"expo-device": "~6.0.2",
"expo-font": "~12.0.9",
"expo-haptics": "~13.0.1",
"expo-image": "~1.12.13",
"expo-keep-awake": "~13.0.2",
"expo-linking": "~6.3.1",
"expo-navigation-bar": "~3.0.7",
"expo-router": "~3.5.21",
"expo-screen-orientation": "~7.0.5",
"expo-sensors": "~13.0.9",
"expo-router": "~3.5.23",
"expo-splash-screen": "~0.27.5",
"expo-status-bar": "~1.12.1",
"expo-system-ui": "~3.0.7",
"expo-updates": "~0.25.22",
"expo-web-browser": "~13.0.3",
"ffmpeg-kit-react-native": "^6.0.2",
"jotai": "^2.9.1",
"lodash": "^4.17.21",
"nativewind": "^2.0.11",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-native": "0.74.5",
"react-native": "npm:react-native-tvos@latest",
"react-native-circular-progress": "^1.4.0",
"react-native-compressor": "^1.8.25",
"react-native-gesture-handler": "~2.16.1",
"react-native-get-random-values": "^1.11.0",
"react-native-google-cast": "^4.8.2",
"react-native-ios-context-menu": "^2.5.1",
"react-native-ios-utilities": "^4.4.5",
"react-native-ios-utilities": "^4.5.0",
"react-native-reanimated": "~3.10.1",
"react-native-reanimated-carousel": "4.0.0-alpha.12",
"react-native-safe-area-context": "4.10.5",
"react-native-screens": "3.31.1",
"react-native-svg": "15.2.0",
@@ -69,8 +67,8 @@
"react-native-video": "^6.4.3",
"react-native-web": "~0.19.10",
"tailwindcss": "3.3.2",
"use-debounce": "^10.0.3",
"uuid": "^10.0.0",
"zeego": "^1.10.0",
"zod": "^3.23.8"
},
"devDependencies": {
@@ -83,5 +81,12 @@
"react-test-renderer": "18.2.0",
"typescript": "~5.3.3"
},
"expo": {
"install": {
"exclude": [
"react-native"
]
}
},
"private": true
}

View File

@@ -1,8 +1,6 @@
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 { isLoaded } from "expo-font";
import { useMutation } from "@tanstack/react-query";
import { router, useSegments } from "expo-router";
import { atom, useAtom } from "jotai";
import React, {
@@ -31,15 +29,14 @@ interface JellyfinContextValue {
}
const JellyfinContext = createContext<JellyfinContextValue | undefined>(
undefined,
undefined
);
const getOrSetDeviceId = async () => {
let deviceId = await AsyncStorage.getItem("deviceId");
let deviceId = null;
if (!deviceId) {
deviceId = uuid.v4() as string;
await AsyncStorage.setItem("deviceId", deviceId);
}
return deviceId;
@@ -56,9 +53,9 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
setJellyfin(
() =>
new Jellyfin({
clientInfo: { name: "Streamyfin", version: "0.6.0" },
clientInfo: { name: "Streamyfin", version: "0.6.1" },
deviceInfo: { name: Platform.OS === "ios" ? "iOS" : "Android", id },
}),
})
);
})();
}, []);
@@ -67,8 +64,9 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
const [user, setUser] = useAtom(userAtom);
const discoverServers = async (url: string): Promise<Server[]> => {
const servers =
await jellyfin?.discovery.getRecommendedServerCandidates(url);
const servers = await jellyfin?.discovery.getRecommendedServerCandidates(
url
);
return servers?.map((server) => ({ address: server.address })) || [];
};
@@ -79,7 +77,6 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
if (!apiInstance?.basePath) throw new Error("Failed to connect");
setApi(apiInstance);
await AsyncStorage.setItem("serverUrl", server.address);
},
onError: (error) => {
console.error("Failed to set server:", error);
@@ -88,7 +85,6 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
const removeServerMutation = useMutation({
mutationFn: async () => {
await AsyncStorage.removeItem("serverUrl");
setApi(null);
},
onError: (error) => {
@@ -110,9 +106,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
if (auth.data.AccessToken && auth.data.User) {
setUser(auth.data.User);
await AsyncStorage.setItem("user", JSON.stringify(auth.data.User));
setApi(jellyfin.createApi(api?.basePath, auth.data?.AccessToken));
await AsyncStorage.setItem("token", auth.data?.AccessToken);
} else {
throw new Error("Invalid username or password");
}
@@ -124,7 +118,6 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
const logoutMutation = useMutation({
mutationFn: async () => {
await AsyncStorage.removeItem("token");
setUser(null);
},
onError: (error) => {
@@ -132,36 +125,6 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
},
});
const { isLoading, isFetching } = useQuery({
queryKey: [
"initializeJellyfin",
user?.Id,
api?.basePath,
jellyfin?.clientInfo,
],
queryFn: async () => {
try {
const token = await AsyncStorage.getItem("token");
const serverUrl = await AsyncStorage.getItem("serverUrl");
const user = JSON.parse(
(await AsyncStorage.getItem("user")) as string,
) as UserDto;
if (serverUrl && token && user.Id && jellyfin) {
const apiInstance = jellyfin.createApi(serverUrl, token);
setApi(apiInstance);
setUser(user);
}
return true;
} catch (e) {
console.error(e);
}
},
staleTime: 0,
enabled: !user?.Id || !api || !jellyfin,
});
const contextValue: JellyfinContextValue = {
discoverServers,
setServer: (server) => setServerMutation.mutateAsync(server),
@@ -171,7 +134,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
logout: () => logoutMutation.mutateAsync(),
};
useProtectedRoute(user, isLoading || isFetching);
useProtectedRoute(user);
return (
<JellyfinContext.Provider value={contextValue}>

View File

@@ -1,14 +0,0 @@
import React, { createContext } from "react";
import { useJobProcessor } from "@/utils/atoms/queue";
const JobQueueContext = createContext(null);
export const JobQueueProvider: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
useJobProcessor();
return (
<JobQueueContext.Provider value={null}>{children}</JobQueueContext.Provider>
);
};

47
utils/atoms/filters.ts Normal file
View File

@@ -0,0 +1,47 @@
import {
ItemFilter,
ItemSortBy,
NameGuidPair,
SortOrder,
} from "@jellyfin/sdk/lib/generated-client/models";
import { atom, useAtom } from "jotai";
export const sortOptions: {
key: ItemSortBy;
value: string;
}[] = [
{ key: "SortName", value: "Name" },
{ key: "CommunityRating", value: "Community Rating" },
{ key: "CriticRating", value: "Critics Rating" },
{ key: "DateLastContentAdded", value: "Content Added" },
{ key: "DatePlayed", value: "Date Played" },
{ key: "PlayCount", value: "Play Count" },
{ key: "ProductionYear", value: "Production Year" },
{ key: "Runtime", value: "Runtime" },
{ key: "OfficialRating", value: "Official Rating" },
{ key: "PremiereDate", value: "Premiere Date" },
{ key: "StartDate", value: "Start Date" },
{ key: "IsUnplayed", value: "Is Unplayed" },
{ key: "IsPlayed", value: "Is Played" },
{ key: "VideoBitRate", value: "Video Bit Rate" },
{ key: "AirTime", value: "Air Time" },
{ key: "Studio", value: "Studio" },
{ key: "IsFavoriteOrLiked", value: "Is Favorite Or Liked" },
{ key: "Random", value: "Random" },
];
export const sortOrderOptions: {
key: SortOrder;
value: string;
}[] = [
{ key: "Ascending", value: "Ascending" },
{ key: "Descending", value: "Descending" },
];
export const genreFilterAtom = atom<string[]>([]);
export const tagsFilterAtom = atom<string[]>([]);
export const yearFilterAtom = atom<string[]>([]);
export const sortByAtom = atom<[typeof sortOptions][number]>([sortOptions[0]]);
export const sortOrderAtom = atom<[typeof sortOrderOptions][number]>([
sortOrderOptions[0],
]);

View File

@@ -46,7 +46,6 @@ export const useJobProcessor = () => {
const [isProcessing, setProcessing] = useAtom(isProcessingAtom);
useEffect(() => {
console.info("Queue changed", queue, isProcessing);
if (queue.length > 0 && !isProcessing) {
console.info("Processing queue", queue);
queueActions.processJob(queue, setQueue, setProcessing);

View File

@@ -1,6 +1,4 @@
import { atom, useAtom } from "jotai";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { useEffect } from "react";
type Settings = {
autoRotate?: boolean;
@@ -9,56 +7,30 @@ type Settings = {
usePopularPlugin?: boolean;
deviceProfile?: "Expo" | "Native" | "Old";
forceDirectPlay?: boolean;
mediaListCollectionIds?: string[];
};
/**
*
* The settings atom is a Jotai atom that stores the user's settings.
* It is initialized with a default value of null, which indicates that the settings have not been loaded yet.
* The settings are loaded from AsyncStorage when the atom is read for the first time.
*
*/
// Utility function to load settings from AsyncStorage
const loadSettings = async (): Promise<Settings> => {
const jsonValue = await AsyncStorage.getItem("settings");
return jsonValue != null
? JSON.parse(jsonValue)
: {
autoRotate: true,
forceLandscapeInVideoPlayer: false,
openFullScreenVideoPlayerByDefault: false,
usePopularPlugin: false,
deviceProfile: "Expo",
forceDirectPlay: false,
};
// Default settings
const defaultSettings: Settings = {
autoRotate: true,
forceLandscapeInVideoPlayer: false,
openFullScreenVideoPlayerByDefault: true,
usePopularPlugin: false,
deviceProfile: "Expo",
forceDirectPlay: false,
mediaListCollectionIds: [],
};
// Utility function to save settings to AsyncStorage
const saveSettings = async (settings: Settings) => {
const jsonValue = JSON.stringify(settings);
await AsyncStorage.setItem("settings", jsonValue);
};
// Create an atom to store the settings in memory, initialized with default settings
const settingsAtom = atom<Settings>(defaultSettings);
// Create an atom to store the settings in memory
const settingsAtom = atom<Settings | null>(null);
// A hook to manage settings, loading them on initial mount and providing a way to update them
// A hook to manage settings, providing a way to update them
export const useSettings = () => {
const [settings, setSettings] = useAtom(settingsAtom);
useEffect(() => {
if (settings === null) {
loadSettings().then(setSettings);
}
}, [settings, setSettings]);
const updateSettings = async (update: Partial<Settings>) => {
if (settings) {
const newSettings = { ...settings, ...update };
setSettings(newSettings);
await saveSettings(newSettings);
}
const updateSettings = (update: Partial<Settings>) => {
const newSettings = { ...settings, ...update };
setSettings(newSettings);
};
return [settings, updateSettings] as const;

View File

@@ -11,9 +11,11 @@ import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
export const getLogoImageUrlById = ({
api,
item,
height = 130,
}: {
api?: Api | null;
item?: BaseItemDto | null;
height?: number;
}) => {
if (!api || !item) {
return null;
@@ -27,7 +29,7 @@ export const getLogoImageUrlById = ({
params.append("tag", imageTags);
params.append("quality", "90");
params.append("fillHeight", "130");
params.append("fillHeight", height.toString());
return `${api.basePath}/Items/${item.Id}/Images/Logo?${params.toString()}`;
};

View File

@@ -1,4 +1,4 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import { atom } from "jotai";
import { atomWithStorage, createJSONStorage } from "jotai/utils";
type LogLevel = "INFO" | "WARN" | "ERROR";
@@ -10,8 +10,7 @@ interface LogEntry {
data?: any;
}
const asyncStorage = createJSONStorage(() => AsyncStorage);
const logsAtom = atomWithStorage("logs", [], asyncStorage);
const logsAtom = atom([]);
export const writeToLog = async (
level: LogLevel,
@@ -25,23 +24,16 @@ export const writeToLog = async (
data: data,
};
const currentLogs = await AsyncStorage.getItem("logs");
const logs: LogEntry[] = currentLogs ? JSON.parse(currentLogs) : [];
const logs: LogEntry[] = [];
logs.push(newEntry);
const maxLogs = 100;
const recentLogs = logs.slice(Math.max(logs.length - maxLogs, 0));
await AsyncStorage.setItem("logs", JSON.stringify(recentLogs));
};
export const readFromLog = async (): Promise<LogEntry[]> => {
const logs = await AsyncStorage.getItem("logs");
return logs ? JSON.parse(logs) : [];
return [];
};
export const clearLogs = async () => {
await AsyncStorage.removeItem("logs");
};
export const clearLogs = async () => {};
export default logsAtom;

9201
yarn.lock Normal file

File diff suppressed because it is too large Load Diff