diff --git a/README.md b/README.md
index 6b3bcf99..87a6420c 100644
--- a/README.md
+++ b/README.md
@@ -8,7 +8,7 @@ Welcome to Streamyfin, a simple and user-friendly Jellyfin client built with Exp
-
+
## 🌟 Features
diff --git a/app.json b/app.json
index 8a0f4f54..d0023760 100644
--- a/app.json
+++ b/app.json
@@ -111,7 +111,8 @@
{ "android": { "parentTheme": "Material3" } }
],
["react-native-bottom-tabs"],
- ["./plugins/withChangeNativeAndroidTextToWhite.js"]
+ ["./plugins/withChangeNativeAndroidTextToWhite.js"],
+ ["./plugins/withGoogleCastActivity.js"]
],
"experiments": {
"typedRoutes": true
diff --git a/app/(auth)/(tabs)/(home)/_layout.tsx b/app/(auth)/(tabs)/(home)/_layout.tsx
index 3509be51..6a1d95ca 100644
--- a/app/(auth)/(tabs)/(home)/_layout.tsx
+++ b/app/(auth)/(tabs)/(home)/_layout.tsx
@@ -77,6 +77,20 @@ export default function IndexLayout() {
title: "",
}}
/>
+
+
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
))}
diff --git a/app/(auth)/(tabs)/(home)/index.tsx b/app/(auth)/(tabs)/(home)/index.tsx
index 0f777a45..95cfd856 100644
--- a/app/(auth)/(tabs)/(home)/index.tsx
+++ b/app/(auth)/(tabs)/(home)/index.tsx
@@ -23,7 +23,7 @@ import {
getUserViewsApi,
} from "@jellyfin/sdk/lib/utils/api";
import NetInfo from "@react-native-community/netinfo";
-import { QueryFunction, useQuery, useQueryClient } from "@tanstack/react-query";
+import { QueryFunction, useQuery } from "@tanstack/react-query";
import { useNavigation, useRouter } from "expo-router";
import { useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo, useState } from "react";
@@ -116,7 +116,7 @@ export default function index() {
}, []);
const {
- data: userViews,
+ data,
isError: e1,
isLoading: l1,
} = useQuery({
@@ -136,6 +136,11 @@ export default function index() {
staleTime: 60 * 1000,
});
+ const userViews = useMemo(
+ () => data?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!)),
+ [data, settings?.hiddenLibraries]
+ );
+
const {
data: mediaListCollections,
isError: e2,
diff --git a/app/(auth)/(tabs)/(home)/intro/page.tsx b/app/(auth)/(tabs)/(home)/intro/page.tsx
new file mode 100644
index 00000000..cef9db31
--- /dev/null
+++ b/app/(auth)/(tabs)/(home)/intro/page.tsx
@@ -0,0 +1,109 @@
+import { Button } from "@/components/Button";
+import { Text } from "@/components/common/Text";
+import { storage } from "@/utils/mmkv";
+import { Feather, Ionicons } from "@expo/vector-icons";
+import { Image } from "expo-image";
+import { useFocusEffect, useRouter } from "expo-router";
+import { useCallback } from "react";
+import { TouchableOpacity, View } from "react-native";
+
+export default function page() {
+ const router = useRouter();
+
+ useFocusEffect(
+ useCallback(() => {
+ storage.set("hasShownIntro", true);
+ }, [])
+ );
+
+ return (
+
+
+
+ Welcome to Streamyfin
+
+
+ A free and open source client for Jellyfin.
+
+
+
+
+ Features
+
+ Streamyfin has a bunch of features and integrates with a wide array of
+ software which you can find in the settings menu, these include:
+
+
+
+
+ Jellyseerr
+
+ Connect to your Jellyseerr instance and request movies directly in
+ the app.
+
+
+
+
+
+
+
+
+ Downloads
+
+ Download movies and tv-shows to view offline. Use either the
+ default method or install the optimize server to download files in
+ the background.
+
+
+
+
+
+
+
+
+ Chromecast
+
+ Cast movies and tv-shows to your Chromecast devices.
+
+
+
+
+
+
+ {
+ router.back();
+ router.push("/settings");
+ }}
+ className="mt-4"
+ >
+ Go to settings
+
+
+ );
+}
diff --git a/app/(auth)/(tabs)/(home)/settings.tsx b/app/(auth)/(tabs)/(home)/settings.tsx
index 8f6d102a..38a0d34a 100644
--- a/app/(auth)/(tabs)/(home)/settings.tsx
+++ b/app/(auth)/(tabs)/(home)/settings.tsx
@@ -13,20 +13,22 @@ import { SubtitleToggles } from "@/components/settings/SubtitleToggles";
import { UserInfo } from "@/components/settings/UserInfo";
import { useJellyfin } from "@/providers/JellyfinProvider";
import { clearLogs } from "@/utils/log";
-import * as Haptics from "expo-haptics";
+import { useHaptic } from "@/hooks/useHaptic";
import { useNavigation, useRouter } from "expo-router";
import { useEffect } from "react";
import { ScrollView, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { storage } from "@/utils/mmkv";
export default function settings() {
const router = useRouter();
const insets = useSafeAreaInsets();
const { logout } = useJellyfin();
+ const successHapticFeedback = useHaptic("success");
const onClearLogsClicked = async () => {
clearLogs();
- Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
+ successHapticFeedback();
};
const navigation = useNavigation();
@@ -66,6 +68,22 @@ export default function settings() {
+
+ {
+ router.push("/intro/page");
+ }}
+ title={"Show intro"}
+ />
+ {
+ storage.set("hasShownIntro", false);
+ }}
+ title={"Reset intro"}
+ />
+
+
{
+ const response = await getUserViewsApi(api!).getUserViews({
+ userId: user?.Id,
+ });
+
+ return response.data.Items || null;
+ },
+ });
+
+ if (!settings) return null;
+
+ if (isLoading)
+ return (
+
+
+
+ );
+
+ return (
+
+
+ {data?.map((view) => (
+ {}}>
+ {
+ updateSettings({
+ hiddenLibraries: value
+ ? [...(settings.hiddenLibraries || []), view.Id!]
+ : settings.hiddenLibraries?.filter((id) => id !== view.Id),
+ });
+ }}
+ />
+
+ ))}
+
+
+ Select the libraries you want to hide from the Library tab and home page
+ sections.
+
+
+ );
+}
diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/[personId].tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/[personId].tsx
new file mode 100644
index 00000000..5a930982
--- /dev/null
+++ b/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/[personId].tsx
@@ -0,0 +1,247 @@
+import {
+ router,
+ useLocalSearchParams,
+ useNavigation,
+ useSegments,
+} from "expo-router";
+import React, {
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
+import { TouchableOpacity, View } from "react-native";
+import { useQuery } from "@tanstack/react-query";
+import { useJellyseerr } from "@/hooks/useJellyseerr";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { ParallaxScrollView } from "@/components/ParallaxPage";
+import { Text } from "@/components/common/Text";
+import { Animated } from "react-native";
+import { Image } from "expo-image";
+import { OverviewText } from "@/components/OverviewText";
+import { orderBy } from "lodash";
+import { FlashList } from "@shopify/flash-list";
+import { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person";
+import Poster from "@/components/posters/Poster";
+import JellyseerrMediaIcon from "@/components/jellyseerr/JellyseerrMediaIcon";
+
+const ANIMATION_ENTER = 250;
+const ANIMATION_EXIT = 250;
+const BACKDROP_DURATION = 5000;
+
+export default function page() {
+ const insets = useSafeAreaInsets();
+ const local = useLocalSearchParams();
+ const segments = useSegments();
+ const { jellyseerrApi, jellyseerrUser } = useJellyseerr();
+
+ const { personId } = local as { personId: string };
+ const from = segments[2];
+
+ const [currentIndex, setCurrentIndex] = useState(0);
+ const fadeAnim = useRef(new Animated.Value(0)).current;
+
+ const { data, isLoading, isFetching } = useQuery({
+ queryKey: ["jellyseerr", "person", personId],
+ queryFn: async () => ({
+ details: await jellyseerrApi?.personDetails(personId),
+ combinedCredits: await jellyseerrApi?.personCombinedCredits(personId),
+ }),
+ enabled: !!jellyseerrApi && !!personId,
+ });
+
+ const locale = useMemo(() => {
+ return jellyseerrUser?.settings?.locale || "en";
+ }, [jellyseerrUser]);
+
+ const region = useMemo(
+ () => jellyseerrUser?.settings?.region || "US",
+ [jellyseerrUser]
+ );
+
+ const castedRoles: PersonCreditCast[] = useMemo(
+ () =>
+ orderBy(
+ data?.combinedCredits?.cast,
+ ["voteCount", "voteAverage"],
+ "desc"
+ ),
+ [data?.combinedCredits]
+ );
+
+ const backdrops = useMemo(
+ () => castedRoles.map((c) => c.backdropPath),
+ [data?.combinedCredits]
+ );
+
+ const enterAnimation = useCallback(
+ () =>
+ Animated.timing(fadeAnim, {
+ toValue: 1,
+ duration: ANIMATION_ENTER,
+ useNativeDriver: true,
+ }),
+ [fadeAnim]
+ );
+
+ const exitAnimation = useCallback(
+ () =>
+ Animated.timing(fadeAnim, {
+ toValue: 0,
+ duration: ANIMATION_EXIT,
+ useNativeDriver: true,
+ }),
+ [fadeAnim]
+ );
+
+ useEffect(() => {
+ if (backdrops?.length) {
+ enterAnimation().start();
+ const intervalId = setInterval(() => {
+ exitAnimation().start((end) => {
+ if (end.finished)
+ setCurrentIndex((prevIndex) => (prevIndex + 1) % backdrops?.length);
+ });
+ }, BACKDROP_DURATION);
+
+ return () => clearInterval(intervalId);
+ }
+ }, [backdrops, enterAnimation, exitAnimation, setCurrentIndex, currentIndex]);
+
+ const viewDetails = (credit: PersonCreditCast) => {
+ router.push({
+ //@ts-ignore
+ pathname: `/(auth)/(tabs)/${from}/jellyseerr/page`,
+ //@ts-ignore
+ params: {
+ ...credit,
+ mediaTitle: credit.title,
+ releaseYear: new Date(credit.releaseDate).getFullYear(),
+ canRequest: "false",
+ posterSrc: jellyseerrApi?.imageProxy(
+ credit.posterPath,
+ "w300_and_h450_face"
+ ),
+ },
+ });
+ };
+
+ return (
+
+
+ }
+ logo={
+
+ }
+ >
+
+
+
+
+ {data?.details?.name}
+
+
+ Born{" "}
+ {new Date(data?.details?.birthday!!).toLocaleDateString(
+ `${locale}-${region}`,
+ {
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+ }
+ )}{" "}
+ | {data?.details?.placeOfBirth}
+
+
+
+
+
+
+
+
+ No results
+
+
+ }
+ contentInsetAdjustmentBehavior="automatic"
+ ListHeaderComponent={
+ Appearances
+ }
+ renderItem={({ item }) => (
+ viewDetails(item)}
+ >
+
+
+ {/*{item.title}*/}
+ {item.character && (
+
+ as {item.character}
+
+ )}
+
+ )}
+ keyExtractor={(item) => item.id.toString()}
+ estimatedItemSize={255}
+ numColumns={3}
+ contentContainerStyle={{ paddingBottom: 24 }}
+ ItemSeparatorComponent={() => }
+ />
+
+
+
+
+ );
+}
diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/page.tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/page.tsx
index 42edcb59..b839708d 100644
--- a/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/page.tsx
+++ b/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/page.tsx
@@ -1,22 +1,33 @@
-import React, { useCallback, useRef, useState } from "react";
-import { useLocalSearchParams } from "expo-router";
+import React, {
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
+import { useLocalSearchParams, useNavigation } from "expo-router";
import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search";
import { Text } from "@/components/common/Text";
import { ParallaxScrollView } from "@/components/ParallaxPage";
import { Image } from "expo-image";
-import { TouchableOpacity, View} from "react-native";
+import { TouchableOpacity, View } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { OverviewText } from "@/components/OverviewText";
import { GenreTags } from "@/components/GenreTags";
-import { MediaType } from "@/utils/jellyseerr/server/constants/media";
+import {
+ MediaRequestStatus,
+ MediaStatus,
+ MediaType,
+} from "@/utils/jellyseerr/server/constants/media";
import { useQuery } from "@tanstack/react-query";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { Button } from "@/components/Button";
import {
BottomSheetBackdrop,
BottomSheetBackdropProps,
- BottomSheetModal, BottomSheetTextInput,
+ BottomSheetModal,
+ BottomSheetTextInput,
BottomSheetView,
} from "@gorhom/bottom-sheet";
import {
@@ -27,24 +38,24 @@ import * as DropdownMenu from "zeego/dropdown-menu";
import { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
import JellyseerrSeasons from "@/components/series/JellyseerrSeasons";
import { JellyserrRatings } from "@/components/Ratings";
+import MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest";
+import DetailFacts from "@/components/jellyseerr/DetailFacts";
+import { ItemActions } from "@/components/series/SeriesActions";
+import Cast from "@/components/jellyseerr/Cast";
+import { useJellyseerrCanRequest } from "@/utils/_jellyseerr/useJellyseerrCanRequest";
const Page: React.FC = () => {
const insets = useSafeAreaInsets();
const params = useLocalSearchParams();
- const {
- mediaTitle,
- releaseYear,
- canRequest: canRequestString,
- posterSrc,
- ...result
- } = params as unknown as {
- mediaTitle: string;
- releaseYear: number;
- canRequest: string;
- posterSrc: string;
- } & Partial;
+ const { mediaTitle, releaseYear, posterSrc, ...result } =
+ params as unknown as {
+ mediaTitle: string;
+ releaseYear: number;
+ canRequest: string;
+ posterSrc: string;
+ } & Partial;
- const canRequest = canRequestString === "true";
+ const navigation = useNavigation();
const { jellyseerrApi, requestMedia } = useJellyseerr();
const [issueType, setIssueType] = useState();
@@ -55,7 +66,7 @@ const Page: React.FC = () => {
data: details,
isFetching,
isLoading,
- refetch
+ refetch,
} = useQuery({
enabled: !!jellyseerrApi && !!result && !!result.id,
queryKey: ["jellyseerr", "detail", result.mediaType, result.id],
@@ -72,6 +83,8 @@ const Page: React.FC = () => {
},
});
+ const canRequest = useJellyseerrCanRequest(details);
+
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
{
}
}, [jellyseerrApi, details, result, issueType, issueMessage]);
- const request = useCallback(
- async () => {
- requestMedia(mediaTitle, {
- mediaId: Number(result.id!!),
- mediaType: result.mediaType!!,
- tvdbId: details?.externalIds?.tvdbId,
- seasons: (details as TvDetails)?.seasons
- ?.filter?.((s) => s.seasonNumber !== 0)
- ?.map?.((s) => s.seasonNumber),
- },
- refetch
- )
- },
- [details, result, requestMedia]
- );
+ const request = useCallback(async () => {
+ requestMedia(
+ mediaTitle,
+ {
+ mediaId: Number(result.id!!),
+ mediaType: result.mediaType!!,
+ tvdbId: details?.externalIds?.tvdbId,
+ seasons: (details as TvDetails)?.seasons
+ ?.filter?.((s) => s.seasonNumber !== 0)
+ ?.map?.((s) => s.seasonNumber),
+ },
+ refetch
+ );
+ }, [details, result, requestMedia]);
+
+ useEffect(() => {
+ if (details) {
+ navigation.setOptions({
+ headerRight: () => (
+
+
+
+ ),
+ });
+ }
+ }, [details]);
return (
{
height: "100%",
}}
source={{
- uri: `https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${result.backdropPath}`,
+ uri: jellyseerrApi?.imageProxy(
+ result.backdropPath,
+ "w1920_and_h800_multi_faces"
+ ),
}}
/>
) : (
@@ -182,7 +209,9 @@ const Page: React.FC = () => {
g.name) || []} />
- {canRequest ? (
+ {isLoading || isFetching ? (
+
+ ) : canRequest ? (
@@ -213,6 +242,11 @@ const Page: React.FC = () => {
refetch={refetch}
/>
)}
+
+
@@ -279,13 +313,11 @@ const Page: React.FC = () => {
-
+
{
- if (!api || !user?.Id) {
- return null;
- }
-
- const response = await getUserViewsApi(api).getUserViews({
- userId: user.Id,
+ const response = await getUserViewsApi(api!).getUserViews({
+ userId: user?.Id,
});
return response.data.Items || null;
},
- enabled: !!api && !!user?.Id,
- staleTime: 60 * 1000 * 60,
+ staleTime: 60,
});
+ const libraries = useMemo(
+ () => data?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!)),
+ [data, settings?.hiddenLibraries]
+ );
+
useEffect(() => {
for (const item of data || []) {
queryClient.prefetchQuery({
@@ -63,7 +63,7 @@ export default function index() {
);
- if (!data)
+ if (!libraries)
return (
No libraries found
@@ -81,7 +81,7 @@ export default function index() {
paddingLeft: insets.left,
paddingRight: insets.right,
}}
- data={data}
+ data={libraries}
renderItem={({ item }) => }
keyExtractor={(item) => item.Id || ""}
ItemSeparatorComponent={() =>
diff --git a/app/(auth)/(tabs)/(search)/_layout.tsx b/app/(auth)/(tabs)/(search)/_layout.tsx
index 12cbad20..1119e2a4 100644
--- a/app/(auth)/(tabs)/(search)/_layout.tsx
+++ b/app/(auth)/(tabs)/(search)/_layout.tsx
@@ -36,6 +36,7 @@ export default function SearchLayout() {
}}
/>
+
);
}
diff --git a/app/(auth)/(tabs)/(search)/index.tsx b/app/(auth)/(tabs)/(search)/index.tsx
index 7d9ecebe..1ae0059c 100644
--- a/app/(auth)/(tabs)/(search)/index.tsx
+++ b/app/(auth)/(tabs)/(search)/index.tsx
@@ -31,12 +31,18 @@ import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useDebounce } from "use-debounce";
import { useJellyseerr } from "@/hooks/useJellyseerr";
-import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search";
+import {
+ MovieResult,
+ PersonResult,
+ TvResult,
+} from "@/utils/jellyseerr/server/models/Search";
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
import { Tag } from "@/components/GenreTags";
import DiscoverSlide from "@/components/jellyseerr/DiscoverSlide";
import { sortBy } from "lodash";
+import PersonPoster from "@/components/jellyseerr/PersonPoster";
+import { useReactNavigationQuery } from "@/utils/useReactNavigationQuery";
type SearchType = "Library" | "Discover";
@@ -149,8 +155,8 @@ export default function search() {
enabled: searchType === "Library" && debouncedSearch.length > 0,
});
- const { data: jellyseerrResults, isFetching: j1 } = useQuery({
- queryKey: ["search", "jellyseerrResults", debouncedSearch],
+ const { data: jellyseerrResults, isFetching: j1 } = useReactNavigationQuery({
+ queryKey: ["search", "jellyseerr", "results", debouncedSearch],
queryFn: async () => {
const response = await jellyseerrApi?.search({
query: new URLSearchParams(debouncedSearch).toString(),
@@ -166,14 +172,15 @@ export default function search() {
debouncedSearch.length > 0,
});
- const { data: jellyseerrDiscoverSettings, isFetching: j2 } = useQuery({
- queryKey: ["search", "jellyseerrDiscoverSettings", debouncedSearch],
- queryFn: async () => jellyseerrApi?.discoverSettings(),
- enabled:
- !!jellyseerrApi &&
- searchType === "Discover" &&
- debouncedSearch.length == 0,
- });
+ const { data: jellyseerrDiscoverSettings, isFetching: j2 } =
+ useReactNavigationQuery({
+ queryKey: ["search", "jellyseerr", "discoverSettings", debouncedSearch],
+ queryFn: async () => jellyseerrApi?.discoverSettings(),
+ enabled:
+ !!jellyseerrApi &&
+ searchType === "Discover" &&
+ debouncedSearch.length == 0,
+ });
const jellyseerrMovieResults: MovieResult[] | undefined = useMemo(
() =>
@@ -191,6 +198,14 @@ export default function search() {
[jellyseerrResults]
);
+ const jellyseerrPersonResults: PersonResult[] | undefined = useMemo(
+ () =>
+ jellyseerrResults?.filter(
+ (r) => r.mediaType === "person"
+ ) as PersonResult[],
+ [jellyseerrResults]
+ );
+
const { data: series, isFetching: l2 } = useQuery({
queryKey: ["search", "series", debouncedSearch],
queryFn: () =>
@@ -300,7 +315,7 @@ export default function search() {
paddingRight: insets.right,
}}
>
-
+
{Platform.OS === "android" && (
)}
/>
+ (
+
+ )}
+ />
>
)}
diff --git a/app/(auth)/(tabs)/_layout.tsx b/app/(auth)/(tabs)/_layout.tsx
index 47e5bfaa..be8f24b0 100644
--- a/app/(auth)/(tabs)/_layout.tsx
+++ b/app/(auth)/(tabs)/_layout.tsx
@@ -1,7 +1,7 @@
-import React from "react";
+import React, { useCallback, useRef } from "react";
import { Platform } from "react-native";
-import { withLayoutContext } from "expo-router";
+import { useFocusEffect, useRouter, withLayoutContext } from "expo-router";
import {
createNativeBottomTabNavigator,
@@ -13,12 +13,13 @@ const { Navigator } = createNativeBottomTabNavigator();
import { BottomTabNavigationOptions } from "@react-navigation/bottom-tabs";
import { Colors } from "@/constants/Colors";
+import { useSettings } from "@/utils/atoms/settings";
+import { storage } from "@/utils/mmkv";
import type {
ParamListBase,
TabNavigationState,
} from "@react-navigation/native";
import { SystemBars } from "react-native-edge-to-edge";
-import { useSettings } from "@/utils/atoms/settings";
export const NativeTabs = withLayoutContext<
BottomTabNavigationOptions,
@@ -29,6 +30,23 @@ export const NativeTabs = withLayoutContext<
export default function TabLayout() {
const [settings] = useSettings();
+ const router = useRouter();
+
+ useFocusEffect(
+ useCallback(() => {
+ const hasShownIntro = storage.getBoolean("hasShownIntro");
+ if (!hasShownIntro) {
+ const timer = setTimeout(() => {
+ router.push("/intro/page");
+ }, 1000);
+
+ return () => {
+ clearTimeout(timer);
+ };
+ }
+ }, [])
+ );
+
return (
<>
diff --git a/app/(auth)/player/direct-player.tsx b/app/(auth)/player/direct-player.tsx
index 4d924938..44b0eb34 100644
--- a/app/(auth)/player/direct-player.tsx
+++ b/app/(auth)/player/direct-player.tsx
@@ -27,7 +27,7 @@ import {
getUserLibraryApi,
} from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
-import * as Haptics from "expo-haptics";
+import { useHaptic } from "@/hooks/useHaptic";
import { useFocusEffect, useGlobalSearchParams } from "expo-router";
import { useAtomValue } from "jotai";
import React, {
@@ -68,9 +68,11 @@ export default function page() {
const { getDownloadedItem } = useDownload();
const revalidateProgressCache = useInvalidatePlaybackProgressCache();
+ const lightHapticFeedback = useHaptic("light");
+
const setShowControls = useCallback((show: boolean) => {
_setShowControls(show);
- Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
+ lightHapticFeedback();
}, []);
const {
@@ -175,7 +177,7 @@ export default function page() {
const togglePlay = useCallback(async () => {
if (!api) return;
- Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
+ lightHapticFeedback();
if (isPlaying) {
await videoRef.current?.pause();
diff --git a/app/(auth)/player/music-player.tsx b/app/(auth)/player/music-player.tsx
index eca16b4c..fc4b8863 100644
--- a/app/(auth)/player/music-player.tsx
+++ b/app/(auth)/player/music-player.tsx
@@ -17,7 +17,7 @@ import {
getUserLibraryApi,
} from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
-import * as Haptics from "expo-haptics";
+import { useHaptic } from "@/hooks/useHaptic";
import { Image } from "expo-image";
import { useFocusEffect, useLocalSearchParams } from "expo-router";
import { useAtomValue } from "jotai";
@@ -45,6 +45,8 @@ export default function page() {
const isSeeking = useSharedValue(false);
const cacheProgress = useSharedValue(0);
+ const lightHapticFeedback = useHaptic("light");
+
const {
itemId,
audioIndex: audioIndexStr,
@@ -124,7 +126,7 @@ export default function page() {
const togglePlay = useCallback(
async (ticks: number) => {
- Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
+ lightHapticFeedback();
if (isPlaying) {
videoRef.current?.pause();
await getPlaystateApi(api!).onPlaybackProgress({
diff --git a/app/(auth)/player/transcoding-player.tsx b/app/(auth)/player/transcoding-player.tsx
index bcb9a6e4..971410f4 100644
--- a/app/(auth)/player/transcoding-player.tsx
+++ b/app/(auth)/player/transcoding-player.tsx
@@ -20,7 +20,7 @@ import {
getUserLibraryApi,
} from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
-import * as Haptics from "expo-haptics";
+import { useHaptic } from "@/hooks/useHaptic";
import { useFocusEffect, useLocalSearchParams } from "expo-router";
import { useAtomValue } from "jotai";
import React, {
@@ -48,6 +48,7 @@ const Player = () => {
const firstTime = useRef(true);
const revalidateProgressCache = useInvalidatePlaybackProgressCache();
+ const lightHapticFeedback = useHaptic("light");
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
const [showControls, _setShowControls] = useState(true);
@@ -58,7 +59,7 @@ const Player = () => {
const setShowControls = useCallback((show: boolean) => {
_setShowControls(show);
- Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
+ lightHapticFeedback();
}, []);
const progress = useSharedValue(0);
@@ -167,7 +168,7 @@ const Player = () => {
const videoSource = useVideoSource(item, api, poster, stream?.url);
const togglePlay = useCallback(async () => {
- Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
+ lightHapticFeedback();
if (isPlaying) {
videoRef.current?.pause();
await getPlaystateApi(api!).onPlaybackProgress({
diff --git a/app/(auth)/trailer/page.tsx b/app/(auth)/trailer/page.tsx
deleted file mode 100644
index 9f331795..00000000
--- a/app/(auth)/trailer/page.tsx
+++ /dev/null
@@ -1,45 +0,0 @@
-import { useGlobalSearchParams } from "expo-router";
-import { useCallback, useEffect, useMemo, useState } from "react";
-import { Alert, Dimensions, View } from "react-native";
-import YoutubePlayer, { PLAYER_STATES } from "react-native-youtube-iframe";
-
-export default function page() {
- const searchParams = useGlobalSearchParams();
-
- const { url } = searchParams as { url: string };
-
- const videoId = useMemo(() => {
- return url.split("v=")[1];
- }, [url]);
-
- const [playing, setPlaying] = useState(false);
-
- const onStateChange = useCallback((state: PLAYER_STATES) => {
- if (state === "ended") {
- setPlaying(false);
- Alert.alert("video has finished playing!");
- }
- }, []);
-
- const togglePlaying = useCallback(() => {
- setPlaying((prev) => !prev);
- }, []);
-
- useEffect(() => {
- togglePlaying();
- }, []);
-
- const screenWidth = Dimensions.get("screen").width;
-
- return (
-
-
-
- );
-}
diff --git a/app/_layout.tsx b/app/_layout.tsx
index bf779be5..23512523 100644
--- a/app/_layout.tsx
+++ b/app/_layout.tsx
@@ -336,14 +336,6 @@ function Layout() {
header: () => null,
}}
/>
-
{
const [serverURL, setServerURL] = useState(_apiUrl);
const [serverName, setServerName] = useState("");
- const [error, setError] = useState("");
const [credentials, setCredentials] = useState<{
username: string;
password: string;
@@ -77,8 +81,10 @@ const Login: React.FC = () => {
onPress={() => {
removeServer();
}}
+ className="flex flex-row items-center"
>
-
+
+ Change server
) : null,
});
@@ -95,9 +101,9 @@ const Login: React.FC = () => {
}
} catch (error) {
if (error instanceof Error) {
- setError(error.message);
+ Alert.alert("Connection failed", error.message);
} else {
- setError("An unexpected error occurred");
+ Alert.alert("Connection failed", "An unexpected error occurred");
}
} finally {
setLoading(false);
@@ -136,6 +142,8 @@ const Login: React.FC = () => {
return url;
}
+ return undefined;
+ } catch {
return undefined;
} finally {
setLoadingServerCheck(false);
@@ -230,7 +238,6 @@ const Login: React.FC = () => {
/>
setCredentials({ ...credentials, password: text })
@@ -244,28 +251,34 @@ const Login: React.FC = () => {
clearButtonMode="while-editing"
maxLength={500}
/>
+
+
+
+
+
+
-
- {error}
-
-
-
-
+
>
) : (
<>
-
+
{
Enter the URL to your Jellyfin server
{
textContentType="URL"
maxLength={500}
/>
-
- Make sure to include http or https
-
- {
- handleConnect(s.address);
- }}
- />
-
-
+
+ {
+ handleConnect(s.address);
+ }}
+ />
>
diff --git a/assets/icons/jellyseerr-logo.svg b/assets/icons/jellyseerr-logo.svg
new file mode 100644
index 00000000..cda2394d
--- /dev/null
+++ b/assets/icons/jellyseerr-logo.svg
@@ -0,0 +1,118 @@
+
+
\ No newline at end of file
diff --git a/assets/images/jellyseerr.PNG b/assets/images/jellyseerr.PNG
new file mode 100644
index 00000000..c72a8da1
Binary files /dev/null and b/assets/images/jellyseerr.PNG differ
diff --git a/bun.lockb b/bun.lockb
index fc89caa0..3a1947ce 100755
Binary files a/bun.lockb and b/bun.lockb differ
diff --git a/components/Button.tsx b/components/Button.tsx
index 1a73ad01..4f7e25c4 100644
--- a/components/Button.tsx
+++ b/components/Button.tsx
@@ -1,4 +1,4 @@
-import * as Haptics from "expo-haptics";
+import { useHaptic } from "@/hooks/useHaptic";
import React, { PropsWithChildren, ReactNode, useMemo } from "react";
import { Text, TouchableOpacity, View } from "react-native";
import { Loader } from "./Loader";
@@ -37,12 +37,14 @@ export const Button: React.FC> = ({
case "red":
return "bg-red-600";
case "black":
- return "bg-neutral-900 border border-neutral-800";
+ return "bg-neutral-900";
case "transparent":
return "bg-transparent";
}
}, [color]);
+ const lightHapticFeedback = useHaptic("light");
+
return (
> = ({
onPress={() => {
if (!loading && !disabled && onPress) {
onPress();
- Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
+ lightHapticFeedback();
}
}}
disabled={disabled || loading}
{...props}
>
{loading ? (
-
+
+
+
) : (
= ({
+export const Tag: React.FC<{ text: string, textClass?: ViewProps["className"], textStyle?: StyleProp} & ViewProps> = ({
text,
textClass,
+ textStyle,
...props
}) => {
return (
- {text}
+ {text}
);
};
diff --git a/components/PlayButton.tsx b/components/PlayButton.tsx
index e5c5dd87..e432f2a8 100644
--- a/components/PlayButton.tsx
+++ b/components/PlayButton.tsx
@@ -32,7 +32,7 @@ import Animated, {
import { Button } from "./Button";
import { SelectedOptions } from "./ItemContent";
import { chromecastProfile } from "@/utils/profiles/chromecast";
-import * as Haptics from "expo-haptics";
+import { useHaptic } from "@/hooks/useHaptic";
interface Props extends React.ComponentProps {
item: BaseItemDto;
@@ -64,6 +64,7 @@ export const PlayButton: React.FC = ({
const widthProgress = useSharedValue(0);
const colorChangeProgress = useSharedValue(0);
const [settings] = useSettings();
+ const lightHapticFeedback = useHaptic("light");
const goToPlayer = useCallback(
(q: string, bitrateValue: number | undefined) => {
@@ -79,7 +80,7 @@ export const PlayButton: React.FC = ({
const onPress = useCallback(async () => {
if (!item) return;
- Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
+ lightHapticFeedback();
const queryParams = new URLSearchParams({
itemId: item.Id!,
diff --git a/components/RoundButton.tsx b/components/RoundButton.tsx
index 049c5ed0..5d2faf73 100644
--- a/components/RoundButton.tsx
+++ b/components/RoundButton.tsx
@@ -6,7 +6,7 @@ import {
TouchableOpacity,
TouchableOpacityProps,
} from "react-native";
-import * as Haptics from "expo-haptics";
+import { useHaptic } from "@/hooks/useHaptic";
interface Props extends TouchableOpacityProps {
onPress?: () => void;
@@ -29,10 +29,11 @@ export const RoundButton: React.FC> = ({
}) => {
const buttonSize = size === "large" ? "h-10 w-10" : "h-9 w-9";
const fillColorClass = fillColor === "primary" ? "bg-purple-600" : "";
+ const lightHapticFeedback = useHaptic("light");
const handlePress = () => {
if (hapticFeedback) {
- Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
+ lightHapticFeedback();
}
onPress?.();
};
diff --git a/components/common/Input.tsx b/components/common/Input.tsx
index ba4ab45b..d82a3225 100644
--- a/components/common/Input.tsx
+++ b/components/common/Input.tsx
@@ -7,7 +7,7 @@ export function Input(props: TextInputProps) {
return (
{
- console.log(item.Type, item?.CollectionType);
-
if ("CollectionType" in item && item.CollectionType === "livetv") {
return `/(auth)/(tabs)/${from}/livetv`;
}
@@ -68,10 +68,33 @@ export const TouchableItemRouter: React.FC> = ({
}) => {
const router = useRouter();
const segments = useSegments();
+ const { showActionSheetWithOptions } = useActionSheet();
+ const markAsPlayedStatus = useMarkAsPlayed(item);
const from = segments[2];
- const markAsPlayedStatus = useMarkAsPlayed(item);
+ const showActionSheet = useCallback(() => {
+ if (!(item.Type === "Movie" || item.Type === "Episode")) return;
+
+ const options = ["Mark as Played", "Mark as Not Played", "Cancel"];
+ const cancelButtonIndex = 2;
+
+ showActionSheetWithOptions(
+ {
+ options,
+ cancelButtonIndex,
+ },
+ async (selectedIndex) => {
+ if (selectedIndex === 0) {
+ await markAsPlayedStatus(true);
+ Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
+ } else if (selectedIndex === 1) {
+ await markAsPlayedStatus(false);
+ Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
+ }
+ }
+ );
+ }, [showActionSheetWithOptions, markAsPlayedStatus]);
if (
from === "(home)" ||
@@ -80,78 +103,16 @@ export const TouchableItemRouter: React.FC> = ({
from === "(favorites)"
)
return (
-
-
- {
- const url = itemRouter(item, from);
- // @ts-ignore
- router.push(url);
- }}
- {...props}
- >
- {children}
-
-
-
- Actions
- {
- markAsPlayedStatus(true);
- }}
- shouldDismissMenuOnSelect
- >
-
- Mark as watched
-
-
-
- {
- markAsPlayedStatus(false);
- }}
- shouldDismissMenuOnSelect
- destructive
- >
-
- Mark as not watched
-
-
-
-
-
+ {
+ const url = itemRouter(item, from);
+ // @ts-expect-error
+ router.push(url);
+ }}
+ {...props}
+ >
+ {children}
+
);
};
diff --git a/components/downloads/EpisodeCard.tsx b/components/downloads/EpisodeCard.tsx
index e8387da5..53b3ecec 100644
--- a/components/downloads/EpisodeCard.tsx
+++ b/components/downloads/EpisodeCard.tsx
@@ -1,5 +1,5 @@
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
-import * as Haptics from "expo-haptics";
+import { useHaptic } from "@/hooks/useHaptic";
import React, { useCallback, useMemo } from "react";
import { TouchableOpacity, TouchableOpacityProps, View } from "react-native";
import {
@@ -26,6 +26,7 @@ export const EpisodeCard: React.FC = ({ item, ...props }) => {
const { deleteFile } = useDownload();
const { openFile } = useDownloadedFileOpener();
const { showActionSheetWithOptions } = useActionSheet();
+ const successHapticFeedback = useHaptic("success");
const base64Image = useMemo(() => {
return storage.getString(item.Id!);
@@ -41,7 +42,7 @@ export const EpisodeCard: React.FC = ({ item, ...props }) => {
const handleDeleteFile = useCallback(() => {
if (item.Id) {
deleteFile(item.Id);
- Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
+ successHapticFeedback();
}
}, [deleteFile, item.Id]);
diff --git a/components/downloads/MovieCard.tsx b/components/downloads/MovieCard.tsx
index 3073bd0a..bb61f3c8 100644
--- a/components/downloads/MovieCard.tsx
+++ b/components/downloads/MovieCard.tsx
@@ -3,7 +3,7 @@ import {
useActionSheet,
} from "@expo/react-native-action-sheet";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
-import * as Haptics from "expo-haptics";
+import { useHaptic } from "@/hooks/useHaptic";
import React, { useCallback, useMemo } from "react";
import { TouchableOpacity, View } from "react-native";
@@ -28,6 +28,7 @@ export const MovieCard: React.FC = ({ item }) => {
const { deleteFile } = useDownload();
const { openFile } = useDownloadedFileOpener();
const { showActionSheetWithOptions } = useActionSheet();
+ const successHapticFeedback = useHaptic("success");
const handleOpenFile = useCallback(() => {
openFile(item);
@@ -43,7 +44,7 @@ export const MovieCard: React.FC = ({ item }) => {
const handleDeleteFile = useCallback(() => {
if (item.Id) {
deleteFile(item.Id);
- Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
+ successHapticFeedback();
}
}, [deleteFile, item.Id]);
diff --git a/components/home/LargeMovieCarousel.tsx b/components/home/LargeMovieCarousel.tsx
index a22c586f..00767621 100644
--- a/components/home/LargeMovieCarousel.tsx
+++ b/components/home/LargeMovieCarousel.tsx
@@ -22,7 +22,7 @@ import { itemRouter, TouchableItemRouter } from "../common/TouchableItemRouter";
import { Loader } from "../Loader";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import { useRouter, useSegments } from "expo-router";
-import * as Haptics from "expo-haptics";
+import { useHaptic } from "@/hooks/useHaptic";
interface Props extends ViewProps {}
@@ -128,6 +128,7 @@ const RenderItem: React.FC<{ item: BaseItemDto }> = ({ item }) => {
const [api] = useAtom(apiAtom);
const router = useRouter();
const screenWidth = Dimensions.get("screen").width;
+ const lightHapticFeedback = useHaptic("light");
const uri = useMemo(() => {
if (!api) return null;
@@ -153,7 +154,7 @@ const RenderItem: React.FC<{ item: BaseItemDto }> = ({ item }) => {
const handleRoute = useCallback(() => {
if (!from) return;
const url = itemRouter(item, from);
- Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
+ lightHapticFeedback();
// @ts-ignore
if (url) router.push(url);
}, [item, from]);
diff --git a/components/jellyseerr/Cast.tsx b/components/jellyseerr/Cast.tsx
new file mode 100644
index 00000000..f5474caf
--- /dev/null
+++ b/components/jellyseerr/Cast.tsx
@@ -0,0 +1,39 @@
+import { View, ViewProps } from "react-native";
+import { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
+import { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
+import React from "react";
+import { FlashList } from "@shopify/flash-list";
+import { Text } from "@/components/common/Text";
+import PersonPoster from "@/components/jellyseerr/PersonPoster";
+
+const CastSlide: React.FC<
+ { details?: MovieDetails | TvDetails } & ViewProps
+> = ({ details, ...props }) => {
+ return (
+ details?.credits?.cast?.length &&
+ details?.credits?.cast?.length > 0 && (
+
+ Cast
+ }
+ estimatedItemSize={15}
+ keyExtractor={(item) => item?.id?.toString()}
+ contentContainerStyle={{ paddingHorizontal: 16 }}
+ renderItem={({ item }) => (
+
+ )}
+ />
+
+ )
+ );
+};
+
+export default CastSlide;
diff --git a/components/jellyseerr/DetailFacts.tsx b/components/jellyseerr/DetailFacts.tsx
new file mode 100644
index 00000000..782ede8b
--- /dev/null
+++ b/components/jellyseerr/DetailFacts.tsx
@@ -0,0 +1,218 @@
+import { View, ViewProps } from "react-native";
+import { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
+import { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
+import { Text } from "@/components/common/Text";
+import { useMemo } from "react";
+import { useJellyseerr } from "@/hooks/useJellyseerr";
+import { uniqBy } from "lodash";
+import { TmdbRelease } from "@/utils/jellyseerr/server/api/themoviedb/interfaces";
+import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
+import CountryFlag from "react-native-country-flag";
+import { ANIME_KEYWORD_ID } from "@/utils/jellyseerr/server/api/themoviedb/constants";
+
+interface Release {
+ certification: string;
+ iso_639_1?: string;
+ note?: string;
+ release_date: string;
+ type: number;
+}
+
+const dateOpts: Intl.DateTimeFormatOptions = {
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+};
+
+const Facts: React.FC<
+ { title: string; facts?: string[] | React.ReactNode[] } & ViewProps
+> = ({ title, facts, ...props }) =>
+ facts &&
+ facts?.length > 0 && (
+
+ {title}
+
+
+ {facts.map((f, idx) =>
+ typeof f === "string" ? {f} : f
+ )}
+
+
+ );
+
+const Fact: React.FC<{ title: string; fact?: string | null } & ViewProps> = ({
+ title,
+ fact,
+ ...props
+}) => fact && ;
+
+const DetailFacts: React.FC<
+ { details?: MovieDetails | TvDetails } & ViewProps
+> = ({ details, className, ...props }) => {
+ const { jellyseerrUser } = useJellyseerr();
+
+ const locale = useMemo(() => {
+ return jellyseerrUser?.settings?.locale || "en";
+ }, [jellyseerrUser]);
+
+ const region = useMemo(
+ () => jellyseerrUser?.settings?.region || "US",
+ [jellyseerrUser]
+ );
+
+ const releases = useMemo(
+ () =>
+ (details as MovieDetails)?.releases?.results.find(
+ (r: TmdbRelease) => r.iso_3166_1 === region
+ )?.release_dates as TmdbRelease["release_dates"],
+ [details]
+ );
+
+ // Release date types:
+ // 1. Premiere
+ // 2. Theatrical (limited)
+ // 3. Theatrical
+ // 4. Digital
+ // 5. Physical
+ // 6. TV
+ const filteredReleases = useMemo(
+ () =>
+ uniqBy(
+ releases?.filter((r: Release) => r.type > 2 && r.type < 6),
+ "type"
+ ),
+ [releases]
+ );
+
+ const firstAirDate = useMemo(() => {
+ const firstAirDate = (details as TvDetails)?.firstAirDate;
+ if (firstAirDate) {
+ return new Date(firstAirDate).toLocaleDateString(
+ `${locale}-${region}`,
+ dateOpts
+ );
+ }
+ }, [details]);
+
+ const nextAirDate = useMemo(() => {
+ const firstAirDate = (details as TvDetails)?.firstAirDate;
+ const nextAirDate = (details as TvDetails)?.nextEpisodeToAir?.airDate;
+ if (nextAirDate && firstAirDate !== nextAirDate) {
+ return new Date(nextAirDate).toLocaleDateString(
+ `${locale}-${region}`,
+ dateOpts
+ );
+ }
+ }, [details]);
+
+ const revenue = useMemo(
+ () =>
+ (details as MovieDetails)?.revenue?.toLocaleString?.(
+ `${locale}-${region}`,
+ { style: "currency", currency: "USD" }
+ ),
+ [details]
+ );
+
+ const budget = useMemo(
+ () =>
+ (details as MovieDetails)?.budget?.toLocaleString?.(
+ `${locale}-${region}`,
+ { style: "currency", currency: "USD" }
+ ),
+ [details]
+ );
+
+ const streamingProviders = useMemo(
+ () =>
+ details?.watchProviders?.find(
+ (provider) => provider.iso_3166_1 === region
+ )?.flatrate,
+ [details]
+ );
+
+ const networks = useMemo(() => (details as TvDetails)?.networks, [details]);
+
+ const spokenLanguage = useMemo(
+ () =>
+ details?.spokenLanguages.find(
+ (lng) => lng.iso_639_1 === details.originalLanguage
+ )?.name,
+ [details]
+ );
+
+ return (
+ details && (
+
+ Details
+
+
+
+ {details.keywords.some(
+ (keyword) => keyword.id === ANIME_KEYWORD_ID
+ ) && }
+ (
+
+ {r.type === 3 ? (
+ // Theatrical
+
+ ) : r.type === 4 ? (
+ // Digital
+
+ ) : (
+ // Physical
+
+ )}
+
+ {new Date(r.release_date).toLocaleDateString(
+ `${locale}-${region}`,
+ dateOpts
+ )}
+
+
+ ))}
+ />
+
+
+
+
+
+ (
+
+
+ {n.name}
+
+ ))}
+ />
+ n.name
+ )}
+ />
+ n.name)} />
+ s.name)}
+ />
+
+
+ )
+ );
+};
+
+export default DetailFacts;
diff --git a/components/jellyseerr/JellyseerrMediaIcon.tsx b/components/jellyseerr/JellyseerrMediaIcon.tsx
new file mode 100644
index 00000000..97a5ab69
--- /dev/null
+++ b/components/jellyseerr/JellyseerrMediaIcon.tsx
@@ -0,0 +1,37 @@
+import {useMemo} from "react";
+import {MediaType} from "@/utils/jellyseerr/server/constants/media";
+import {Feather, MaterialCommunityIcons} from "@expo/vector-icons";
+import {View, ViewProps} from "react-native";
+
+const JellyseerrMediaIcon: React.FC<{ mediaType: "tv" | "movie" } & ViewProps> = ({
+ mediaType,
+ className,
+ ...props
+}) => {
+ const style = useMemo(
+ () => mediaType === MediaType.MOVIE
+ ? 'bg-blue-600/90 border-blue-400/40'
+ : 'bg-purple-600/90 border-purple-400/40',
+ [mediaType]
+ );
+ return (
+ mediaType &&
+
+ {mediaType === MediaType.MOVIE ? (
+
+ ) : (
+
+ )}
+
+ )
+}
+
+export default JellyseerrMediaIcon;
\ No newline at end of file
diff --git a/components/icons/JellyseerrIconStatus.tsx b/components/jellyseerr/JellyseerrStatusIcon.tsx
similarity index 93%
rename from components/icons/JellyseerrIconStatus.tsx
rename to components/jellyseerr/JellyseerrStatusIcon.tsx
index 4c1bda37..8fc593fa 100644
--- a/components/icons/JellyseerrIconStatus.tsx
+++ b/components/jellyseerr/JellyseerrStatusIcon.tsx
@@ -2,7 +2,6 @@ import {useEffect, useState} from "react";
import {MediaStatus} from "@/utils/jellyseerr/server/constants/media";
import {MaterialCommunityIcons} from "@expo/vector-icons";
import {TouchableOpacity, View, ViewProps} from "react-native";
-import {MovieResult, TvResult} from "@/utils/jellyseerr/server/models/Search";
interface Props {
mediaStatus?: MediaStatus;
@@ -10,7 +9,7 @@ interface Props {
onPress?: () => void;
}
-const JellyseerrIconStatus: React.FC = ({
+const JellyseerrStatusIcon: React.FC = ({
mediaStatus,
showRequestIcon,
onPress,
@@ -69,4 +68,4 @@ const JellyseerrIconStatus: React.FC = ({
)
}
-export default JellyseerrIconStatus;
\ No newline at end of file
+export default JellyseerrStatusIcon;
\ No newline at end of file
diff --git a/components/jellyseerr/PersonPoster.tsx b/components/jellyseerr/PersonPoster.tsx
new file mode 100644
index 00000000..57ff9f58
--- /dev/null
+++ b/components/jellyseerr/PersonPoster.tsx
@@ -0,0 +1,42 @@
+import {TouchableOpacity, View, ViewProps} from "react-native";
+import React from "react";
+import {Text} from "@/components/common/Text";
+import Poster from "@/components/posters/Poster";
+import {useRouter, useSegments} from "expo-router";
+import {useJellyseerr} from "@/hooks/useJellyseerr";
+
+interface Props {
+ id: string
+ posterPath?: string
+ name: string
+ subName?: string
+}
+
+const PersonPoster: React.FC = ({
+ id,
+ posterPath,
+ name,
+ subName,
+ ...props
+}) => {
+ const {jellyseerrApi} = useJellyseerr();
+ const router = useRouter();
+ const segments = useSegments();
+ const from = segments[2];
+
+ if (from === "(home)" || from === "(search)" || from === "(libraries)")
+ return (
+ router.push(`/(auth)/(tabs)/${from}/jellyseerr/${id}`)}>
+
+
+ {name}
+ {subName && {subName}}
+
+
+ )
+}
+
+export default PersonPoster;
\ No newline at end of file
diff --git a/components/posters/JellyseerrPoster.tsx b/components/posters/JellyseerrPoster.tsx
index 5a9647ae..5f363d42 100644
--- a/components/posters/JellyseerrPoster.tsx
+++ b/components/posters/JellyseerrPoster.tsx
@@ -1,55 +1,47 @@
-import {View, ViewProps} from "react-native";
-import {Image} from "expo-image";
-import {MaterialCommunityIcons} from "@expo/vector-icons";
-import {Text} from "@/components/common/Text";
-import {useEffect, useMemo, useState} from "react";
-import {MovieResult, Results, TvResult} from "@/utils/jellyseerr/server/models/Search";
-import {MediaStatus, MediaType} from "@/utils/jellyseerr/server/constants/media";
-import {useJellyseerr} from "@/hooks/useJellyseerr";
-import {hasPermission, Permission} from "@/utils/jellyseerr/server/lib/permissions";
-import {TouchableJellyseerrRouter} from "@/components/common/JellyseerrItemRouter";
-import JellyseerrIconStatus from "@/components/icons/JellyseerrIconStatus";
+import { View, ViewProps } from "react-native";
+import { Image } from "expo-image";
+import { Text } from "@/components/common/Text";
+import { useMemo } from "react";
+import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search";
+import {
+ MediaStatus,
+ MediaType,
+} from "@/utils/jellyseerr/server/constants/media";
+import { useJellyseerr } from "@/hooks/useJellyseerr";
+import {
+ hasPermission,
+ Permission,
+} from "@/utils/jellyseerr/server/lib/permissions";
+import { TouchableJellyseerrRouter } from "@/components/common/JellyseerrItemRouter";
+import JellyseerrStatusIcon from "@/components/jellyseerr/JellyseerrStatusIcon";
+import JellyseerrMediaIcon from "@/components/jellyseerr/JellyseerrMediaIcon";
+import { useJellyseerrCanRequest } from "@/utils/_jellyseerr/useJellyseerrCanRequest";
interface Props extends ViewProps {
item: MovieResult | TvResult;
}
-const JellyseerrPoster: React.FC = ({
- item,
- ...props
-}) => {
- const {jellyseerrUser, jellyseerrApi} = useJellyseerr();
- // const imageSource =
+const JellyseerrPoster: React.FC = ({ item, ...props }) => {
+ const { jellyseerrApi } = useJellyseerr();
- const imageSrc = useMemo(() =>
- item.posterPath ?
- `https://image.tmdb.org/t/p/w300_and_h450_face${item.posterPath}`
- : jellyseerrApi?.axios?.defaults.baseURL + `/images/overseerr_poster_not_found_logo_top.png`,
+ const imageSrc = useMemo(
+ () => jellyseerrApi?.imageProxy(item.posterPath, "w300_and_h450_face"),
[item, jellyseerrApi]
- )
- const title = useMemo(() => item.mediaType === MediaType.MOVIE ? item.title : item.name, [item])
- const releaseYear = useMemo(() =>
- new Date(item.mediaType === MediaType.MOVIE ? item.releaseDate : item.firstAirDate).getFullYear(),
+ );
+ const title = useMemo(
+ () => (item.mediaType === MediaType.MOVIE ? item.title : item.name),
[item]
- )
+ );
+ const releaseYear = useMemo(
+ () =>
+ new Date(
+ item.mediaType === MediaType.MOVIE
+ ? item.releaseDate
+ : item.firstAirDate
+ ).getFullYear(),
+ [item]
+ );
- const showRequestButton = useMemo(() =>
- jellyseerrUser && hasPermission(
- [
- Permission.REQUEST,
- item.mediaType === 'movie'
- ? Permission.REQUEST_MOVIE
- : Permission.REQUEST_TV,
- ],
- jellyseerrUser.permissions,
- {type: 'or'}
- ),
- [item, jellyseerrUser]
- )
-
- const canRequest = useMemo(() => {
- const status = item?.mediaInfo?.status
- return showRequestButton && !status || status === MediaStatus.UNKNOWN
- }, [item])
+ const canRequest = useJellyseerrCanRequest(item);
return (
= ({
mediaTitle={title}
releaseYear={releaseYear}
canRequest={canRequest}
- posterSrc={imageSrc}
+ posterSrc={imageSrc!!}
>
= ({
width: "100%",
}}
/>
-
+
{title}
- {releaseYear}
+ {releaseYear}
- )
-}
+ );
+};
-
-export default JellyseerrPoster;
\ No newline at end of file
+export default JellyseerrPoster;
diff --git a/components/posters/Poster.tsx b/components/posters/Poster.tsx
index 1787506e..68799f47 100644
--- a/components/posters/Poster.tsx
+++ b/components/posters/Poster.tsx
@@ -1,19 +1,15 @@
-import {
- BaseItemDto,
- BaseItemPerson,
-} from "@jellyfin/sdk/lib/generated-client/models";
import { Image } from "expo-image";
import { View } from "react-native";
type PosterProps = {
- item?: BaseItemDto | BaseItemPerson | null;
+ id?: string | null;
url?: string | null;
showProgress?: boolean;
blurhash?: string | null;
};
-const Poster: React.FC = ({ item, url, blurhash }) => {
- if (!item)
+const Poster: React.FC = ({ id, url, blurhash }) => {
+ if (!id && !url)
return (
= ({ item, url, blurhash }) => {
}
: null
}
- key={item.Id}
- id={item.Id}
+ key={id}
+ id={id!!}
source={
url
? {
diff --git a/components/series/CastAndCrew.tsx b/components/series/CastAndCrew.tsx
index 2b312f0e..01cd1e84 100644
--- a/components/series/CastAndCrew.tsx
+++ b/components/series/CastAndCrew.tsx
@@ -55,7 +55,7 @@ export const CastAndCrew: React.FC = ({ item, loading, ...props }) => {
}}
className="flex flex-col w-28"
>
-
+
{i.Name}
{i.Role}
diff --git a/components/series/CurrentSeries.tsx b/components/series/CurrentSeries.tsx
index e573929a..52851533 100644
--- a/components/series/CurrentSeries.tsx
+++ b/components/series/CurrentSeries.tsx
@@ -29,7 +29,7 @@ export const CurrentSeries: React.FC = ({ item, ...props }) => {
className="flex flex-col space-y-2 w-28"
>
{item.SeriesName}
diff --git a/components/series/JellyseerrSeasons.tsx b/components/series/JellyseerrSeasons.tsx
index bcd9b336..80bfd254 100644
--- a/components/series/JellyseerrSeasons.tsx
+++ b/components/series/JellyseerrSeasons.tsx
@@ -5,7 +5,7 @@ import { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
import { FlashList } from "@shopify/flash-list";
import { orderBy } from "lodash";
import { Tags } from "@/components/GenreTags";
-import JellyseerrIconStatus from "@/components/icons/JellyseerrIconStatus";
+import JellyseerrStatusIcon from "@/components/jellyseerr/JellyseerrStatusIcon";
import Season from "@/utils/jellyseerr/server/entity/Season";
import {
MediaStatus,
@@ -61,7 +61,7 @@ const RenderItem = ({ item, index }: any) => {
key={item.id}
id={item.id}
source={{
- uri: jellyseerrApi?.tvStillImageProxy(item.stillPath),
+ uri: jellyseerrApi?.imageProxy(item.stillPath),
}}
cachePolicy={"memory-disk"}
contentFit="cover"
@@ -246,7 +246,7 @@ const JellyseerrSeasons: React.FC<{
seasons?.find((s) => s.seasonNumber === season.seasonNumber)
?.status === MediaStatus.UNKNOWN;
return (
- requestSeason(canRequest, season.seasonNumber)}
className={canRequest ? "bg-gray-700/40" : undefined}
diff --git a/components/series/SeriesActions.tsx b/components/series/SeriesActions.tsx
index 80d219f5..569f719d 100644
--- a/components/series/SeriesActions.tsx
+++ b/components/series/SeriesActions.tsx
@@ -1,24 +1,45 @@
+import { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
+import { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
import { Ionicons } from "@expo/vector-icons";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
-import { useRouter } from "expo-router";
import { useCallback, useMemo } from "react";
-import { TouchableOpacity, View, ViewProps } from "react-native";
+import {
+ Alert,
+ Linking,
+ TouchableOpacity,
+ View,
+ ViewProps,
+} from "react-native";
interface Props extends ViewProps {
- item: BaseItemDto;
+ item: BaseItemDto | MovieDetails | TvDetails;
}
export const ItemActions = ({ item, ...props }: Props) => {
- const router = useRouter();
+ const trailerLink = useMemo(() => {
+ if ("RemoteTrailers" in item && item.RemoteTrailers?.[0]?.Url) {
+ return item.RemoteTrailers[0].Url;
+ }
- const trailerLink = useMemo(() => item.RemoteTrailers?.[0]?.Url, [item]);
+ if ("relatedVideos" in item) {
+ return item.relatedVideos?.find((v) => v.type === "Trailer")?.url;
+ }
+
+ return undefined;
+ }, [item]);
const openTrailer = useCallback(async () => {
- if (!trailerLink) return;
+ if (!trailerLink) {
+ Alert.alert("No trailer available");
+ return;
+ }
- const encodedTrailerLink = encodeURIComponent(trailerLink);
- router.push(`/trailer/page?url=${encodedTrailerLink}`);
- }, [router, trailerLink]);
+ try {
+ await Linking.openURL(trailerLink);
+ } catch (err) {
+ console.error("Failed to open trailer link:", err);
+ }
+ }, [trailerLink]);
return (
diff --git a/components/settings/OtherSettings.tsx b/components/settings/OtherSettings.tsx
index d280a167..cbd8fc18 100644
--- a/components/settings/OtherSettings.tsx
+++ b/components/settings/OtherSettings.tsx
@@ -6,6 +6,7 @@ import {
} from "@/utils/background-tasks";
import { Ionicons } from "@expo/vector-icons";
import * as BackgroundFetch from "expo-background-fetch";
+import { useRouter } from "expo-router";
import * as ScreenOrientation from "expo-screen-orientation";
import * as TaskManager from "expo-task-manager";
import React, { useEffect } from "react";
@@ -19,6 +20,7 @@ import { ListItem } from "../list/ListItem";
interface Props extends ViewProps {}
export const OtherSettings: React.FC = () => {
+ const router = useRouter();
const [settings, updateSettings] = useSettings();
/********************
@@ -54,7 +56,7 @@ export const OtherSettings: React.FC = () => {
if (!settings) return null;
return (
-
+
{
}
/>
+ router.push("/settings/hide-libraries/page")}
+ title="Hide Libraries"
+ showArrow
+ />
+
+
+ updateSettings({ disableHapticFeedback: value })
+ }
+ />
+
);
};
diff --git a/components/settings/QuickConnect.tsx b/components/settings/QuickConnect.tsx
index 9efbec43..85a8259f 100644
--- a/components/settings/QuickConnect.tsx
+++ b/components/settings/QuickConnect.tsx
@@ -7,7 +7,7 @@ import {
BottomSheetView,
} from "@gorhom/bottom-sheet";
import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api";
-import * as Haptics from "expo-haptics";
+import { useHaptic } from "@/hooks/useHaptic";
import { useAtom } from "jotai";
import React, { useCallback, useRef, useState } from "react";
import { Alert, View, ViewProps } from "react-native";
@@ -23,6 +23,8 @@ export const QuickConnect: React.FC = ({ ...props }) => {
const [user] = useAtom(userAtom);
const [quickConnectCode, setQuickConnectCode] = useState();
const bottomSheetModalRef = useRef(null);
+ const successHapticFeedback = useHaptic("success");
+ const errorHapticFeedback = useHaptic("error");
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
@@ -43,16 +45,16 @@ export const QuickConnect: React.FC = ({ ...props }) => {
userId: user?.Id,
});
if (res.status === 200) {
- Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
+ successHapticFeedback();
Alert.alert("Success", "Quick connect authorized");
setQuickConnectCode(undefined);
bottomSheetModalRef?.current?.close();
} else {
- Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
+ errorHapticFeedback();
Alert.alert("Error", "Invalid code");
}
} catch (e) {
- Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
+ errorHapticFeedback();
Alert.alert("Error", "Invalid code");
}
}
diff --git a/components/settings/StorageSettings.tsx b/components/settings/StorageSettings.tsx
index 5b693acd..9064bc14 100644
--- a/components/settings/StorageSettings.tsx
+++ b/components/settings/StorageSettings.tsx
@@ -4,7 +4,7 @@ import { useDownload } from "@/providers/DownloadProvider";
import { clearLogs } from "@/utils/log";
import { useQuery } from "@tanstack/react-query";
import * as FileSystem from "expo-file-system";
-import * as Haptics from "expo-haptics";
+import { useHaptic } from "@/hooks/useHaptic";
import { View } from "react-native";
import * as Progress from "react-native-progress";
import { toast } from "sonner-native";
@@ -13,6 +13,8 @@ import { ListItem } from "../list/ListItem";
export const StorageSettings = () => {
const { deleteAllFiles, appSizeUsage } = useDownload();
+ const successHapticFeedback = useHaptic("success");
+ const errorHapticFeedback = useHaptic("error");
const { data: size, isLoading: appSizeLoading } = useQuery({
queryKey: ["appSize", appSizeUsage],
@@ -29,9 +31,9 @@ export const StorageSettings = () => {
const onDeleteClicked = async () => {
try {
await deleteAllFiles();
- Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
+ successHapticFeedback();
} catch (e) {
- Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
+ errorHapticFeedback();
toast.error("Error deleting files");
}
};
diff --git a/components/video-player/controls/Controls.tsx b/components/video-player/controls/Controls.tsx
index 2fd1cba3..620e112e 100644
--- a/components/video-player/controls/Controls.tsx
+++ b/components/video-player/controls/Controls.tsx
@@ -29,7 +29,7 @@ import {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client";
-import * as Haptics from "expo-haptics";
+import { useHaptic } from "@/hooks/useHaptic";
import { Image } from "expo-image";
import { useLocalSearchParams, useRouter } from "expo-router";
import { useAtom } from "jotai";
@@ -157,10 +157,12 @@ export const Controls: React.FC = ({
isVlc
);
+ const lightHapticFeedback = useHaptic("light");
+
const goToPreviousItem = useCallback(() => {
if (!previousItem || !settings) return;
- Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
+ lightHapticFeedback();
const previousIndexes: previousIndexes = {
subtitleIndex: subtitleIndex ? parseInt(subtitleIndex) : undefined,
@@ -198,7 +200,7 @@ export const Controls: React.FC = ({
const goToNextItem = useCallback(() => {
if (!nextItem || !settings) return;
- Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
+ lightHapticFeedback();
const previousIndexes: previousIndexes = {
subtitleIndex: subtitleIndex ? parseInt(subtitleIndex) : undefined,
@@ -326,7 +328,7 @@ export const Controls: React.FC = ({
const handleSkipBackward = useCallback(async () => {
if (!settings?.rewindSkipTime) return;
wasPlayingRef.current = isPlaying;
- Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
+ lightHapticFeedback();
try {
const curr = progress.value;
if (curr !== undefined) {
@@ -344,7 +346,7 @@ export const Controls: React.FC = ({
const handleSkipForward = useCallback(async () => {
if (!settings?.forwardSkipTime) return;
wasPlayingRef.current = isPlaying;
- Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
+ lightHapticFeedback();
try {
const curr = progress.value;
if (curr !== undefined) {
@@ -361,7 +363,7 @@ export const Controls: React.FC = ({
const toggleIgnoreSafeAreas = useCallback(() => {
setIgnoreSafeAreas((prev) => !prev);
- Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
+ lightHapticFeedback();
}, []);
const memoizedRenderBubble = useCallback(() => {
@@ -440,7 +442,7 @@ export const Controls: React.FC = ({
const gotoItem = await getItemById(api, itemId);
if (!settings || !gotoItem) return;
- Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
+ lightHapticFeedback();
const previousIndexes: previousIndexes = {
subtitleIndex: subtitleIndex ? parseInt(subtitleIndex) : undefined,
@@ -584,7 +586,7 @@ export const Controls: React.FC = ({
)}
{
- Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
+ lightHapticFeedback();
router.back();
}}
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
diff --git a/hooks/useCreditSkipper.ts b/hooks/useCreditSkipper.ts
index 1430e7c9..14a77161 100644
--- a/hooks/useCreditSkipper.ts
+++ b/hooks/useCreditSkipper.ts
@@ -5,7 +5,7 @@ import { apiAtom } from "@/providers/JellyfinProvider";
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
import { writeToLog } from "@/utils/log";
import { msToSeconds, secondsToMs } from "@/utils/time";
-import * as Haptics from "expo-haptics";
+import { useHaptic } from "./useHaptic";
interface CreditTimestamps {
Introduction: {
@@ -29,6 +29,7 @@ export const useCreditSkipper = (
) => {
const [api] = useAtom(apiAtom);
const [showSkipCreditButton, setShowSkipCreditButton] = useState(false);
+ const lightHapticFeedback = useHaptic("light");
if (isVlc) {
currentTime = msToSeconds(currentTime);
@@ -79,7 +80,7 @@ export const useCreditSkipper = (
if (!creditTimestamps) return;
console.log(`Skipping credits to ${creditTimestamps.Credits.End}`);
try {
- Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
+ lightHapticFeedback();
wrappedSeek(creditTimestamps.Credits.End);
setTimeout(() => {
play();
diff --git a/hooks/useDefaultPlaySettings.ts b/hooks/useDefaultPlaySettings.ts
index 5c2d9cc6..39009305 100644
--- a/hooks/useDefaultPlaySettings.ts
+++ b/hooks/useDefaultPlaySettings.ts
@@ -6,7 +6,7 @@ import {
} from "@jellyfin/sdk/lib/generated-client";
import { useMemo } from "react";
-// Used only for intial play settings.
+// Used only for initial play settings.
const useDefaultPlaySettings = (
item: BaseItemDto,
settings: Settings | null
diff --git a/hooks/useHaptic.ts b/hooks/useHaptic.ts
new file mode 100644
index 00000000..c992def1
--- /dev/null
+++ b/hooks/useHaptic.ts
@@ -0,0 +1,54 @@
+import { useCallback, useMemo } from "react";
+import { Platform } from "react-native";
+import * as Haptics from "expo-haptics";
+import { useSettings } from "@/utils/atoms/settings";
+
+export type HapticFeedbackType =
+ | "light"
+ | "medium"
+ | "heavy"
+ | "selection"
+ | "success"
+ | "warning"
+ | "error";
+
+export const useHaptic = (feedbackType: HapticFeedbackType = "selection") => {
+ const [settings] = useSettings();
+
+ const createHapticHandler = useCallback(
+ (type: Haptics.ImpactFeedbackStyle) => {
+ return Platform.OS === "web" ? () => {} : () => Haptics.impactAsync(type);
+ },
+ []
+ );
+ const createNotificationFeedback = useCallback(
+ (type: Haptics.NotificationFeedbackType) => {
+ return Platform.OS === "web"
+ ? () => {}
+ : () => Haptics.notificationAsync(type);
+ },
+ []
+ );
+
+ const hapticHandlers = useMemo(
+ () => ({
+ light: createHapticHandler(Haptics.ImpactFeedbackStyle.Light),
+ medium: createHapticHandler(Haptics.ImpactFeedbackStyle.Medium),
+ heavy: createHapticHandler(Haptics.ImpactFeedbackStyle.Heavy),
+ selection: Platform.OS === "web" ? () => {} : Haptics.selectionAsync,
+ success: createNotificationFeedback(
+ Haptics.NotificationFeedbackType.Success
+ ),
+ warning: createNotificationFeedback(
+ Haptics.NotificationFeedbackType.Warning
+ ),
+ error: createNotificationFeedback(Haptics.NotificationFeedbackType.Error),
+ }),
+ [createHapticHandler, createNotificationFeedback]
+ );
+
+ if (settings?.disableHapticFeedback) {
+ return () => {};
+ }
+ return hapticHandlers[feedbackType];
+};
diff --git a/hooks/useIntroSkipper.ts b/hooks/useIntroSkipper.ts
index 15aaff05..b41872dc 100644
--- a/hooks/useIntroSkipper.ts
+++ b/hooks/useIntroSkipper.ts
@@ -5,7 +5,7 @@ import { apiAtom } from "@/providers/JellyfinProvider";
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
import { writeToLog } from "@/utils/log";
import { msToSeconds, secondsToMs } from "@/utils/time";
-import * as Haptics from "expo-haptics";
+import { useHaptic } from "./useHaptic";
interface IntroTimestamps {
EpisodeId: string;
@@ -33,6 +33,7 @@ export const useIntroSkipper = (
if (isVlc) {
currentTime = msToSeconds(currentTime);
}
+ const lightHapticFeedback = useHaptic("light");
const wrappedSeek = (seconds: number) => {
if (isVlc) {
@@ -78,7 +79,7 @@ export const useIntroSkipper = (
const skipIntro = useCallback(() => {
if (!introTimestamps) return;
try {
- Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
+ lightHapticFeedback();
wrappedSeek(introTimestamps.IntroEnd);
setTimeout(() => {
play();
diff --git a/hooks/useJellyseerr.ts b/hooks/useJellyseerr.ts
index 8393798d..815510fa 100644
--- a/hooks/useJellyseerr.ts
+++ b/hooks/useJellyseerr.ts
@@ -28,6 +28,11 @@ import Issue from "@/utils/jellyseerr/server/entity/Issue";
import { RTRating } from "@/utils/jellyseerr/server/api/rating/rottentomatoes";
import { writeErrorLog } from "@/utils/log";
import DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider";
+import {
+ CombinedCredit,
+ PersonDetails,
+} from "@/utils/jellyseerr/server/models/Person";
+import { useQueryClient } from "@tanstack/react-query";
interface SearchParams {
query: string;
@@ -55,6 +60,8 @@ export enum Endpoints {
API_V1 = "/api/v1",
SEARCH = "/search",
REQUEST = "/request",
+ PERSON = "/person",
+ COMBINED_CREDITS = "/combined_credits",
MOVIE = "/movie",
RATINGS = "/ratings",
ISSUE = "/issue",
@@ -204,6 +211,27 @@ export class JellyseerrApi {
});
}
+ async personDetails(id: number | string): Promise {
+ return this.axios
+ ?.get(Endpoints.API_V1 + Endpoints.PERSON + `/${id}`)
+ .then((response) => {
+ return response?.data;
+ });
+ }
+
+ async personCombinedCredits(id: number | string): Promise {
+ return this.axios
+ ?.get(
+ Endpoints.API_V1 +
+ Endpoints.PERSON +
+ `/${id}` +
+ Endpoints.COMBINED_CREDITS
+ )
+ .then((response) => {
+ return response?.data;
+ });
+ }
+
async movieRatings(id: number) {
return this.axios
?.get(
@@ -238,14 +266,20 @@ export class JellyseerrApi {
});
}
- tvStillImageProxy(path: string, width: number = 1920, quality: number = 75) {
- return (
- this.axios.defaults.baseURL +
- `/_next/image?` +
- new URLSearchParams(
- `url=https://image.tmdb.org/t/p/original/${path}&w=${width}&q=${quality}`
- ).toString()
- );
+ imageProxy(
+ path?: string,
+ tmdbPath: string = "original",
+ width: number = 1920,
+ quality: number = 75
+ ) {
+ return path
+ ? this.axios.defaults.baseURL +
+ `/_next/image?` +
+ new URLSearchParams(
+ `url=https://image.tmdb.org/t/p/${tmdbPath}/${path}&w=${width}&q=${quality}`
+ ).toString()
+ : this.axios?.defaults.baseURL +
+ `/images/overseerr_poster_not_found_logo_top.png`;
}
async submitIssue(mediaId: number, issueType: IssueType, message: string) {
@@ -321,6 +355,7 @@ const jellyseerrUserAtom = atom(storage.get(JELLYSEERR_USER));
export const useJellyseerr = () => {
const [jellyseerrUser, setJellyseerrUser] = useAtom(jellyseerrUserAtom);
const [settings, updateSettings] = useSettings();
+ const queryClient = useQueryClient();
const jellyseerrApi = useMemo(() => {
const cookies = storage.get(JELLYSEERR_COOKIES);
@@ -338,12 +373,16 @@ export const useJellyseerr = () => {
const requestMedia = useCallback(
(title: string, request: MediaRequestBody, onSuccess?: () => void) => {
- jellyseerrApi?.request?.(request)?.then((mediaRequest) => {
+ jellyseerrApi?.request?.(request)?.then(async (mediaRequest) => {
+ await queryClient.invalidateQueries({
+ queryKey: ["search", "jellyseerr"],
+ });
+
switch (mediaRequest.status) {
case MediaRequestStatus.PENDING:
case MediaRequestStatus.APPROVED:
toast.success(`Requested ${title}!`);
- onSuccess?.()
+ onSuccess?.();
break;
case MediaRequestStatus.DECLINED:
toast.error(`You don't have permission to request!`);
diff --git a/hooks/useMarkAsPlayed.ts b/hooks/useMarkAsPlayed.ts
index ff039cc8..fb30bd14 100644
--- a/hooks/useMarkAsPlayed.ts
+++ b/hooks/useMarkAsPlayed.ts
@@ -3,13 +3,14 @@ import { markAsNotPlayed } from "@/utils/jellyfin/playstate/markAsNotPlayed";
import { markAsPlayed } from "@/utils/jellyfin/playstate/markAsPlayed";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useQueryClient } from "@tanstack/react-query";
-import * as Haptics from "expo-haptics";
+import { useHaptic } from "./useHaptic";
import { useAtom } from "jotai";
export const useMarkAsPlayed = (item: BaseItemDto) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const queryClient = useQueryClient();
+ const lightHapticFeedback = useHaptic("light");
const invalidateQueries = () => {
const queriesToInvalidate = [
@@ -29,7 +30,7 @@ export const useMarkAsPlayed = (item: BaseItemDto) => {
};
const markAsPlayedStatus = async (played: boolean) => {
- Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
+ lightHapticFeedback();
// Optimistic update
queryClient.setQueryData(
diff --git a/package.json b/package.json
index 6bbe1d00..e5ebb82a 100644
--- a/package.json
+++ b/package.json
@@ -75,6 +75,7 @@
"react-native-bottom-tabs": "0.7.1",
"react-native-circular-progress": "^1.4.1",
"react-native-compressor": "^1.9.0",
+ "react-native-country-flag": "^2.0.2",
"react-native-device-info": "^14.0.1",
"react-native-edge-to-edge": "^1.1.3",
"react-native-gesture-handler": "~2.16.1",
@@ -82,7 +83,7 @@
"react-native-google-cast": "^4.8.3",
"react-native-image-colors": "^2.4.0",
"react-native-ios-context-menu": "^2.5.2",
- "react-native-ios-utilities": "^4.5.1",
+ "react-native-ios-utilities": "4.5.3",
"react-native-mmkv": "^2.12.2",
"react-native-pager-view": "6.3.0",
"react-native-progress": "^5.0.1",
@@ -99,7 +100,6 @@
"react-native-volume-manager": "^1.10.0",
"react-native-web": "~0.19.13",
"react-native-webview": "13.8.6",
- "react-native-youtube-iframe": "^2.3.0",
"sonner-native": "^0.14.2",
"tailwindcss": "3.3.2",
"use-debounce": "^10.0.4",
diff --git a/plugins/withGoogleCastActivity.js b/plugins/withGoogleCastActivity.js
new file mode 100644
index 00000000..1a8c0a30
--- /dev/null
+++ b/plugins/withGoogleCastActivity.js
@@ -0,0 +1,34 @@
+const { withAndroidManifest } = require("@expo/config-plugins");
+
+const withGoogleCastActivity = (config) =>
+ withAndroidManifest(config, async (config) => {
+ const mainApplication = config.modResults.manifest.application[0];
+
+ // Initialize activity array if it doesn't exist
+ if (!mainApplication.activity) {
+ mainApplication.activity = [];
+ }
+
+ // Check if the activity already exists
+ const activityExists = mainApplication.activity.some(
+ (activity) =>
+ activity.$?.["android:name"] ===
+ "com.reactnative.googlecast.RNGCExpandedControllerActivity"
+ );
+
+ // Only add the activity if it doesn't already exist
+ if (!activityExists) {
+ mainApplication.activity.push({
+ $: {
+ "android:name":
+ "com.reactnative.googlecast.RNGCExpandedControllerActivity",
+ "android:theme": "@style/Theme.MaterialComponents.NoActionBar",
+ "android:launchMode": "singleTask",
+ },
+ });
+ }
+
+ return config;
+ });
+
+module.exports = withGoogleCastActivity;
diff --git a/providers/DownloadProvider.tsx b/providers/DownloadProvider.tsx
index 78fbbe6f..fb8b137f 100644
--- a/providers/DownloadProvider.tsx
+++ b/providers/DownloadProvider.tsx
@@ -48,7 +48,7 @@ import useImageStorage from "@/hooks/useImageStorage";
import { storage } from "@/utils/mmkv";
import useDownloadHelper from "@/utils/download";
import { FileInfo } from "expo-file-system";
-import * as Haptics from "expo-haptics";
+import { useHaptic } from "@/hooks/useHaptic";
import * as Application from "expo-application";
export type DownloadedItem = {
@@ -78,6 +78,8 @@ function useDownloadProvider() {
const [processes, setProcesses] = useAtom(processesAtom);
+ const successHapticFeedback = useHaptic("success");
+
const authHeader = useMemo(() => {
return api?.accessToken;
}, [api]);
@@ -532,9 +534,7 @@ function useDownloadProvider() {
if (i.Id) return deleteFile(i.Id);
return;
})
- ).then(() =>
- Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success)
- );
+ ).then(() => successHapticFeedback());
};
const cleanCacheDirectory = async () => {
diff --git a/scripts/automerge.sh b/scripts/automerge.sh
new file mode 100755
index 00000000..d66a0941
--- /dev/null
+++ b/scripts/automerge.sh
@@ -0,0 +1,12 @@
+#!/bin/bash
+[[ -z $(git status --porcelain) ]] &&
+git checkout master &&
+git pull --ff-only &&
+git checkout develop &&
+git merge master &&
+git push --follow-tags &&
+git checkout master &&
+git merge develop --ff-only &&
+git push &&
+git checkout develop ||
+(echo "Error: Failed to merge" && exit 1)
\ No newline at end of file
diff --git a/scripts/reset-project.js b/scripts/reset-project.js
deleted file mode 100755
index 4512e162..00000000
--- a/scripts/reset-project.js
+++ /dev/null
@@ -1,73 +0,0 @@
-#!/usr/bin/env node
-
-/**
- * This script is used to reset the project to a blank state.
- * It moves the /app directory to /app-example and creates a new /app directory with an index.tsx and _layout.tsx file.
- * You can remove the `reset-project` script from package.json and safely delete this file after running it.
- */
-
-const fs = require('fs');
-const path = require('path');
-
-const root = process.cwd();
-const oldDirPath = path.join(root, 'app');
-const newDirPath = path.join(root, 'app-example');
-const newAppDirPath = path.join(root, 'app');
-
-const indexContent = `import { Text, View } from "react-native";
-
-export default function Index() {
- return (
-
- Edit app/index.tsx to edit this screen.
-
- );
-}
-`;
-
-const layoutContent = `import { Stack } from "expo-router";
-
-export default function RootLayout() {
- return (
-
-
-
- );
-}
-`;
-
-fs.rename(oldDirPath, newDirPath, (error) => {
- if (error) {
- return console.error(`Error renaming directory: ${error}`);
- }
- console.log('/app moved to /app-example.');
-
- fs.mkdir(newAppDirPath, { recursive: true }, (error) => {
- if (error) {
- return console.error(`Error creating new app directory: ${error}`);
- }
- console.log('New /app directory created.');
-
- const indexPath = path.join(newAppDirPath, 'index.tsx');
- fs.writeFile(indexPath, indexContent, (error) => {
- if (error) {
- return console.error(`Error creating index.tsx: ${error}`);
- }
- console.log('app/index.tsx created.');
-
- const layoutPath = path.join(newAppDirPath, '_layout.tsx');
- fs.writeFile(layoutPath, layoutContent, (error) => {
- if (error) {
- return console.error(`Error creating _layout.tsx: ${error}`);
- }
- console.log('app/_layout.tsx created.');
- });
- });
- });
-});
diff --git a/svenska_kyrkan.sql b/svenska_kyrkan.sql
deleted file mode 100644
index e69de29b..00000000
diff --git a/utils/_jellyseerr/useJellyseerrCanRequest.ts b/utils/_jellyseerr/useJellyseerrCanRequest.ts
new file mode 100644
index 00000000..ba692df3
--- /dev/null
+++ b/utils/_jellyseerr/useJellyseerrCanRequest.ts
@@ -0,0 +1,52 @@
+import { useJellyseerr } from "@/hooks/useJellyseerr";
+import {
+ MediaRequestStatus,
+ MediaStatus,
+} from "@/utils/jellyseerr/server/constants/media";
+import {
+ hasPermission,
+ Permission,
+} from "@/utils/jellyseerr/server/lib/permissions";
+import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search";
+import { useMemo } from "react";
+import MediaRequest from "../jellyseerr/server/entity/MediaRequest";
+import { MovieDetails } from "../jellyseerr/server/models/Movie";
+import { TvDetails } from "../jellyseerr/server/models/Tv";
+
+export const useJellyseerrCanRequest = (
+ item?: MovieResult | TvResult | MovieDetails | TvDetails
+) => {
+ const { jellyseerrUser } = useJellyseerr();
+
+ const canRequest = useMemo(() => {
+ if (!jellyseerrUser || !item) return false;
+
+ const canNotRequest =
+ item?.mediaInfo?.requests?.some(
+ (r: MediaRequest) =>
+ r.status == MediaRequestStatus.PENDING ||
+ r.status == MediaRequestStatus.APPROVED
+ ) ||
+ item.mediaInfo?.status === MediaStatus.AVAILABLE ||
+ item.mediaInfo?.status === MediaStatus.BLACKLISTED ||
+ item.mediaInfo?.status === MediaStatus.PENDING ||
+ item.mediaInfo?.status === MediaStatus.PROCESSING;
+
+ if (canNotRequest) return false;
+
+ const userHasPermission = hasPermission(
+ [
+ Permission.REQUEST,
+ item?.mediaInfo?.mediaType
+ ? Permission.REQUEST_MOVIE
+ : Permission.REQUEST_TV,
+ ],
+ jellyseerrUser.permissions,
+ { type: "or" }
+ );
+
+ return userHasPermission && !canNotRequest;
+ }, [item, jellyseerrUser]);
+
+ return canRequest;
+};
diff --git a/utils/atoms/settings.ts b/utils/atoms/settings.ts
index c37dd4eb..b473198d 100644
--- a/utils/atoms/settings.ts
+++ b/utils/atoms/settings.ts
@@ -84,10 +84,12 @@ export type Settings = {
downloadMethod: "optimized" | "remux";
autoDownload: boolean;
showCustomMenuLinks: boolean;
+ disableHapticFeedback: boolean;
subtitleSize: number;
remuxConcurrentLimit: 1 | 2 | 3 | 4;
safeAreaInControlsEnabled: boolean;
jellyseerrServerUrl?: string;
+ hiddenLibraries?: string[];
};
const loadSettings = (): Settings => {
@@ -122,10 +124,12 @@ const loadSettings = (): Settings => {
downloadMethod: "remux",
autoDownload: false,
showCustomMenuLinks: false,
+ disableHapticFeedback: false,
subtitleSize: Platform.OS === "ios" ? 60 : 100,
remuxConcurrentLimit: 1,
safeAreaInControlsEnabled: true,
jellyseerrServerUrl: undefined,
+ hiddenLibraries: [],
};
try {
diff --git a/utils/useReactNavigationQuery.ts b/utils/useReactNavigationQuery.ts
new file mode 100644
index 00000000..a0c5b307
--- /dev/null
+++ b/utils/useReactNavigationQuery.ts
@@ -0,0 +1,32 @@
+import { useFocusEffect } from "@react-navigation/core";
+import {
+ QueryKey,
+ useQuery,
+ UseQueryOptions,
+ UseQueryResult,
+} from "@tanstack/react-query";
+import { useCallback } from "react";
+
+export function useReactNavigationQuery<
+ TQueryFnData = unknown,
+ TError = unknown,
+ TData = TQueryFnData,
+ TQueryKey extends QueryKey = QueryKey
+>(
+ options: UseQueryOptions
+): UseQueryResult {
+ const useQueryReturn = useQuery(options);
+
+ useFocusEffect(
+ useCallback(() => {
+ if (
+ ((options.refetchOnWindowFocus && useQueryReturn.isStale) ||
+ options.refetchOnWindowFocus === "always") &&
+ options.enabled !== false
+ )
+ useQueryReturn.refetch();
+ }, [options.enabled, options.refetchOnWindowFocus])
+ );
+
+ return useQueryReturn;
+}