Merge branch 'develop' into view-password

This commit is contained in:
Gauvain
2025-09-04 00:19:17 +02:00
committed by GitHub
66 changed files with 448 additions and 356 deletions

View File

@@ -2,7 +2,7 @@
"expo": { "expo": {
"name": "Streamyfin", "name": "Streamyfin",
"slug": "streamyfin", "slug": "streamyfin",
"version": "0.35.0", "version": "0.35.1",
"orientation": "default", "orientation": "default",
"icon": "./assets/images/icon.png", "icon": "./assets/images/icon.png",
"scheme": "streamyfin", "scheme": "streamyfin",
@@ -37,7 +37,7 @@
}, },
"android": { "android": {
"jsEngine": "hermes", "jsEngine": "hermes",
"versionCode": 66, "versionCode": 67,
"adaptiveIcon": { "adaptiveIcon": {
"foregroundImage": "./assets/images/icon-android-plain.png", "foregroundImage": "./assets/images/icon-android-plain.png",
"monochromeImage": "./assets/images/icon-android-themed.png", "monochromeImage": "./assets/images/icon-android-themed.png",

View File

@@ -12,7 +12,7 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
export default function page() { export default function page() {
const [settings, updateSettings, pluginSettings] = useSettings(null); const { settings, updateSettings, pluginSettings } = useSettings();
const user = useAtomValue(userAtom); const user = useAtomValue(userAtom);
const api = useAtomValue(apiAtom); const api = useAtomValue(apiAtom);

View File

@@ -3,7 +3,7 @@ import { JellyseerrSettings } from "@/components/settings/Jellyseerr";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
export default function page() { export default function page() {
const [_settings, _updateSettings, pluginSettings] = useSettings(null); const { pluginSettings } = useSettings();
return ( return (
<DisabledSetting <DisabledSetting

View File

@@ -21,7 +21,7 @@ export default function page() {
const { t } = useTranslation(); const { t } = useTranslation();
const [settings, updateSettings, pluginSettings] = useSettings(null); const { settings, updateSettings, pluginSettings } = useSettings();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [value, setValue] = useState<string>(settings?.marlinServerUrl || ""); const [value, setValue] = useState<string>(settings?.marlinServerUrl || "");

View File

@@ -15,7 +15,7 @@ import { COMPANY_LOGO_IMAGE_FILTER } from "@/utils/jellyseerr/src/components/Dis
export default function page() { export default function page() {
const local = useLocalSearchParams(); const local = useLocalSearchParams();
const { jellyseerrApi } = useJellyseerr(); const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr();
const { companyId, image, type } = local as unknown as { const { companyId, image, type } = local as unknown as {
companyId: string; companyId: string;
@@ -53,7 +53,10 @@ export default function page() {
uniqBy( uniqBy(
data?.pages data?.pages
?.filter((p) => p?.results.length) ?.filter((p) => p?.results.length)
.flatMap((p) => p?.results ?? []), .flatMap(
(p) =>
p?.results.filter((r) => isJellyseerrMovieOrTvResult(r)) ?? [],
),
"id", "id",
) ?? [], ) ?? [],
[data], [data],
@@ -98,9 +101,7 @@ export default function page() {
}} }}
/> />
} }
renderItem={(item, _index) => ( renderItem={(item, _index) => <JellyseerrPoster item={item} />}
<JellyseerrPoster item={item as MovieResult | TvResult} />
)}
/> />
); );
} }

View File

@@ -8,14 +8,10 @@ import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow";
import JellyseerrPoster from "@/components/posters/JellyseerrPoster"; import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
import { Endpoints, useJellyseerr } from "@/hooks/useJellyseerr"; import { Endpoints, useJellyseerr } from "@/hooks/useJellyseerr";
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover"; import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
import {
type MovieResult,
type TvResult,
} from "@/utils/jellyseerr/server/models/Search";
export default function page() { export default function page() {
const local = useLocalSearchParams(); const local = useLocalSearchParams();
const { jellyseerrApi } = useJellyseerr(); const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr();
const { genreId, name, type } = local as unknown as { const { genreId, name, type } = local as unknown as {
genreId: string; genreId: string;
@@ -51,7 +47,10 @@ export default function page() {
uniqBy( uniqBy(
data?.pages data?.pages
?.filter((p) => p?.results.length) ?.filter((p) => p?.results.length)
.flatMap((p) => p?.results ?? []), .flatMap(
(p) =>
p?.results.filter((r) => isJellyseerrMovieOrTvResult(r)) ?? [],
),
"id", "id",
) ?? [], ) ?? [],
[data], [data],
@@ -62,7 +61,7 @@ export default function page() {
jellyseerrApi jellyseerrApi
? flatData.map((r) => ? flatData.map((r) =>
jellyseerrApi.imageProxy( jellyseerrApi.imageProxy(
(r as TvResult | MovieResult).backdropPath, r.backdropPath,
"w1920_and_h800_multi_faces", "w1920_and_h800_multi_faces",
), ),
) )
@@ -92,9 +91,7 @@ export default function page() {
{name} {name}
</Text> </Text>
} }
renderItem={(item, _index) => ( renderItem={(item, _index) => <JellyseerrPoster item={item} />}
<JellyseerrPoster item={item as MovieResult | TvResult} />
)}
/> />
); );
} }

View File

@@ -10,10 +10,6 @@ import { OverviewText } from "@/components/OverviewText";
import JellyseerrPoster from "@/components/posters/JellyseerrPoster"; import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
import { useJellyseerr } from "@/hooks/useJellyseerr"; import { useJellyseerr } from "@/hooks/useJellyseerr";
import type { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person"; import type { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person";
import type {
MovieResult,
TvResult,
} from "@/utils/jellyseerr/server/models/Search";
export default function page() { export default function page() {
const local = useLocalSearchParams(); const local = useLocalSearchParams();
@@ -106,9 +102,7 @@ export default function page() {
MainContent={() => ( MainContent={() => (
<OverviewText text={data?.details?.biography} className='mt-4' /> <OverviewText text={data?.details?.biography} className='mt-4' />
)} )}
renderItem={(item, _index) => ( renderItem={(item, _index) => <JellyseerrPoster item={item} />}
<JellyseerrPoster item={item as MovieResult | TvResult} />
)}
/> />
); );
} }

View File

@@ -7,7 +7,7 @@ import { useAtom } from "jotai";
import { useCallback, useMemo } from "react"; import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { View } from "react-native"; import { View } from "react-native";
import { InfiniteHorizontalScroll } from "@/components/common/InfiniteHorrizontalScroll"; import { InfiniteHorizontalScroll } from "@/components/common/InfiniteHorizontalScroll";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter"; import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import { ItemCardText } from "@/components/ItemCardText"; import { ItemCardText } from "@/components/ItemCardText";
@@ -22,21 +22,21 @@ import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
const page: React.FC = () => { const page: React.FC = () => {
const local = useLocalSearchParams(); const local = useLocalSearchParams();
const { actorId } = local as { actorId: string }; const { personId } = local as { personId: string };
const { t } = useTranslation(); const { t } = useTranslation();
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const { data: item, isLoading: l1 } = useQuery({ const { data: item, isLoading: l1 } = useQuery({
queryKey: ["item", actorId], queryKey: ["item", personId],
queryFn: async () => queryFn: async () =>
await getUserItemData({ await getUserItemData({
api, api,
userId: user?.Id, userId: user?.Id,
itemId: actorId, itemId: personId,
}), }),
enabled: !!actorId && !!api, enabled: !!personId && !!api,
staleTime: 60, staleTime: 60,
}); });
@@ -50,7 +50,7 @@ const page: React.FC = () => {
const response = await getItemsApi(api).getItems({ const response = await getItemsApi(api).getItems({
userId: user.Id, userId: user.Id,
personIds: [actorId], personIds: [personId],
startIndex: pageParam, startIndex: pageParam,
limit: 16, limit: 16,
sortOrder: ["Descending", "Descending", "Ascending"], sortOrder: ["Descending", "Descending", "Ascending"],
@@ -68,7 +68,7 @@ const page: React.FC = () => {
return response.data; return response.data;
}, },
[api, user?.Id, actorId], [api, user?.Id, personId],
); );
const backdropUrl = useMemo( const backdropUrl = useMemo(
@@ -131,7 +131,7 @@ const page: React.FC = () => {
</TouchableItemRouter> </TouchableItemRouter>
)} )}
queryFn={fetchItems} queryFn={fetchItems}
queryKey={["actor", "movies", actorId]} queryKey={["actor", "movies", personId]}
/> />
<View className='h-12' /> <View className='h-12' />
</View> </View>

View File

@@ -9,7 +9,7 @@ const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
export default function IndexLayout() { export default function IndexLayout() {
const [settings, updateSettings, pluginSettings] = useSettings(null); const { settings, updateSettings, pluginSettings } = useSettings();
const { t } = useTranslation(); const { t } = useTranslation();

View File

@@ -19,7 +19,7 @@ export default function index() {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [settings] = useSettings(null); const { settings } = useSettings();
const { t } = useTranslation(); const { t } = useTranslation();

View File

@@ -71,7 +71,7 @@ export default function search() {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [settings] = useSettings(null); const { settings } = useSettings();
const { jellyseerrApi } = useJellyseerr(); const { jellyseerrApi } = useJellyseerr();
const [jellyseerrOrderBy, setJellyseerrOrderBy] = const [jellyseerrOrderBy, setJellyseerrOrderBy] =
useState<JellyseerrSearchSort>( useState<JellyseerrSearchSort>(

View File

@@ -27,7 +27,7 @@ export const NativeTabs = withLayoutContext<
>(Navigator); >(Navigator);
export default function TabLayout() { export default function TabLayout() {
const [settings] = useSettings(null); const { settings } = useSettings();
const { t } = useTranslation(); const { t } = useTranslation();
const router = useRouter(); const router = useRouter();

View File

@@ -97,7 +97,7 @@ export default function page() {
/** Playback position in ticks. */ /** Playback position in ticks. */
playbackPosition?: string; playbackPosition?: string;
}>(); }>();
const [_settings] = useSettings(null); useSettings();
const offline = offlineStr === "true"; const offline = offlineStr === "true";
const playbackManager = usePlaybackManager(); const playbackManager = usePlaybackManager();
@@ -748,7 +748,6 @@ export default function page() {
setAspectRatio={setAspectRatio} setAspectRatio={setAspectRatio}
setScaleFactor={setScaleFactor} setScaleFactor={setScaleFactor}
isVlc isVlc
api={api}
downloadedFiles={downloadedFiles} downloadedFiles={downloadedFiles}
/> />
)} )}

View File

@@ -230,7 +230,7 @@ const queryClient = new QueryClient({
}); });
function Layout() { function Layout() {
const [settings] = useSettings(null); const { settings } = useSettings();
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const appState = useRef(AppState.currentState); const appState = useRef(AppState.currentState);
@@ -269,6 +269,15 @@ function Layout() {
await Notifications?.setNotificationChannelAsync("default", { await Notifications?.setNotificationChannelAsync("default", {
name: "default", name: "default",
}); });
// Create dedicated channel for download notifications
console.log("Setting android notification channel 'downloads'");
await Notifications?.setNotificationChannelAsync("downloads", {
name: "Downloads",
importance: Notifications.AndroidImportance.DEFAULT,
vibrationPattern: [0, 250, 250, 250],
lightColor: "#FF231F7C",
});
} }
const granted = await checkAndRequestPermissions(); const granted = await checkAndRequestPermissions();

View File

@@ -61,7 +61,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const [queue, _setQueue] = useAtom(queueAtom); const [queue, _setQueue] = useAtom(queueAtom);
const [settings] = useSettings(null); const { settings } = useSettings();
const [downloadUnwatchedOnly, setDownloadUnwatchedOnly] = useState(false); const [downloadUnwatchedOnly, setDownloadUnwatchedOnly] = useState(false);
const { processes, startBackgroundDownload, getDownloadedItems } = const { processes, startBackgroundDownload, getDownloadedItems } =

View File

@@ -54,7 +54,7 @@ interface ItemContentProps {
export const ItemContent: React.FC<ItemContentProps> = React.memo( export const ItemContent: React.FC<ItemContentProps> = React.memo(
({ item, isOffline }) => { ({ item, isOffline }) => {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [settings] = useSettings(null); const { settings } = useSettings();
const { orientation } = useOrientation(); const { orientation } = useOrientation();
const navigation = useNavigation(); const navigation = useNavigation();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();

View File

@@ -5,7 +5,7 @@ import { useAtom } from "jotai";
import type React from "react"; import type React from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { View, type ViewProps } from "react-native"; import { View, type ViewProps } from "react-native";
import { HorizontalScroll } from "@/components/common/HorrizontalScroll"; import { HorizontalScroll } from "@/components/common/HorizontalScroll";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter"; import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import { ItemCardText } from "@/components/ItemCardText"; import { ItemCardText } from "@/components/ItemCardText";

View File

@@ -67,7 +67,7 @@ export const PlayButton: React.FC<Props> = ({
const startColor = useSharedValue(colorAtom); const startColor = useSharedValue(colorAtom);
const widthProgress = useSharedValue(0); const widthProgress = useSharedValue(0);
const colorChangeProgress = useSharedValue(0); const colorChangeProgress = useSharedValue(0);
const [settings, updateSettings] = useSettings(null); const { settings, updateSettings } = useSettings();
const lightHapticFeedback = useHaptic("light"); const lightHapticFeedback = useHaptic("light");
const goToPlayer = useCallback( const goToPlayer = useCallback(

View File

@@ -44,7 +44,7 @@ export const PlayButton: React.FC<Props> = ({
const startColor = useSharedValue(colorAtom); const startColor = useSharedValue(colorAtom);
const widthProgress = useSharedValue(0); const widthProgress = useSharedValue(0);
const colorChangeProgress = useSharedValue(0); const colorChangeProgress = useSharedValue(0);
const [settings] = useSettings(null); const { settings } = useSettings();
const lightHapticFeedback = useHaptic("light"); const lightHapticFeedback = useHaptic("light");
const goToPlayer = useCallback( const goToPlayer = useCallback(

View File

@@ -7,7 +7,7 @@ import { useTranslation } from "react-i18next";
import { View, type ViewProps } from "react-native"; import { View, type ViewProps } from "react-native";
import MoviePoster from "@/components/posters/MoviePoster"; import MoviePoster from "@/components/posters/MoviePoster";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { HorizontalScroll } from "./common/HorrizontalScroll"; import { HorizontalScroll } from "./common/HorizontalScroll";
import { Text } from "./common/Text"; import { Text } from "./common/Text";
import { TouchableItemRouter } from "./common/TouchableItemRouter"; import { TouchableItemRouter } from "./common/TouchableItemRouter";
import { ItemCardText } from "./ItemCardText"; import { ItemCardText } from "./ItemCardText";

View File

@@ -10,6 +10,7 @@ import {
Permission, Permission,
} from "@/utils/jellyseerr/server/lib/permissions"; } from "@/utils/jellyseerr/server/lib/permissions";
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie"; import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
import { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person";
import type { import type {
MovieResult, MovieResult,
TvResult, TvResult,
@@ -17,7 +18,7 @@ import type {
import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv"; import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
interface Props extends TouchableOpacityProps { interface Props extends TouchableOpacityProps {
result?: MovieResult | TvResult | MovieDetails | TvDetails; result?: MovieResult | TvResult | MovieDetails | TvDetails | PersonCreditCast;
mediaTitle: string; mediaTitle: string;
releaseYear: number; releaseYear: number;
canRequest: boolean; canRequest: boolean;

View File

@@ -1,8 +1,5 @@
import { useActionSheet } from "@expo/react-native-action-sheet"; import { useActionSheet } from "@expo/react-native-action-sheet";
import type { import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
BaseItemDto,
BaseItemPerson,
} from "@jellyfin/sdk/lib/generated-client/models";
import { useRouter, useSegments } from "expo-router"; import { useRouter, useSegments } from "expo-router";
import { type PropsWithChildren, useCallback } from "react"; import { type PropsWithChildren, useCallback } from "react";
import { TouchableOpacity, type TouchableOpacityProps } from "react-native"; import { TouchableOpacity, type TouchableOpacityProps } from "react-native";
@@ -14,10 +11,7 @@ interface Props extends TouchableOpacityProps {
isOffline?: boolean; isOffline?: boolean;
} }
export const itemRouter = ( export const itemRouter = (item: BaseItemDto, from: string) => {
item: BaseItemDto | BaseItemPerson,
from: string,
) => {
if ("CollectionType" in item && item.CollectionType === "livetv") { if ("CollectionType" in item && item.CollectionType === "livetv") {
return `/(auth)/(tabs)/${from}/livetv`; return `/(auth)/(tabs)/${from}/livetv`;
} }
@@ -26,8 +20,8 @@ export const itemRouter = (
return `/(auth)/(tabs)/${from}/series/${item.Id}`; return `/(auth)/(tabs)/${from}/series/${item.Id}`;
} }
if (item.Type === "Person" || item.Type === "Actor") { if (item.Type === "Person") {
return `/(auth)/(tabs)/${from}/actors/${item.Id}`; return `/(auth)/(tabs)/${from}/persons/${item.Id}`;
} }
if (item.Type === "BoxSet") { if (item.Type === "BoxSet") {

View File

@@ -1,27 +1,9 @@
import { Ionicons } from "@expo/vector-icons";
import { useQueryClient } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useRouter } from "expo-router";
import { t } from "i18next"; import { t } from "i18next";
import { useMemo } from "react"; import { View, type ViewProps } from "react-native";
import {
ActivityIndicator,
TouchableOpacity,
type TouchableOpacityProps,
View,
type ViewProps,
} from "react-native";
import { toast } from "sonner-native";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { useDownload } from "@/providers/DownloadProvider"; import { useDownload } from "@/providers/DownloadProvider";
import { JobStatus } from "@/providers/Downloads/types"; import { JobStatus } from "@/providers/Downloads/types";
import { storage } from "@/utils/mmkv"; import { DownloadCard } from "./DownloadCard";
import { formatTimeString } from "@/utils/time";
import { Button } from "../Button";
const bytesToMB = (bytes: number) => {
return bytes / 1024 / 1024;
};
interface ActiveDownloadsProps extends ViewProps {} interface ActiveDownloadsProps extends ViewProps {}
@@ -52,163 +34,3 @@ export default function ActiveDownloads({ ...props }: ActiveDownloadsProps) {
</View> </View>
); );
} }
interface DownloadCardProps extends TouchableOpacityProps {
process: JobStatus;
}
const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
const { startDownload, pauseDownload, resumeDownload, removeProcess } =
useDownload();
const router = useRouter();
const queryClient = useQueryClient();
const handlePause = async (id: string) => {
try {
await pauseDownload(id);
toast.success(t("home.downloads.toasts.download_paused"));
} catch (error) {
console.error("Error pausing download:", error);
toast.error(t("home.downloads.toasts.could_not_pause_download"));
}
};
const handleResume = async (id: string) => {
try {
await resumeDownload(id);
toast.success(t("home.downloads.toasts.download_resumed"));
} catch (error) {
console.error("Error resuming download:", error);
toast.error(t("home.downloads.toasts.could_not_resume_download"));
}
};
const handleDelete = async (id: string) => {
try {
await removeProcess(id);
toast.success(t("home.downloads.toasts.download_deleted"));
queryClient.invalidateQueries({ queryKey: ["downloads"] });
} catch (error) {
console.error("Error deleting download:", error);
toast.error(t("home.downloads.toasts.could_not_delete_download"));
}
};
const eta = (p: JobStatus) => {
if (!p.speed || p.speed <= 0 || !p.estimatedTotalSizeBytes) return null;
const bytesRemaining = p.estimatedTotalSizeBytes - (p.bytesDownloaded || 0);
if (bytesRemaining <= 0) return null;
const secondsRemaining = bytesRemaining / p.speed;
return formatTimeString(secondsRemaining, "s");
};
const base64Image = useMemo(() => {
return storage.getString(process.item.Id!);
}, []);
return (
<TouchableOpacity
onPress={() => router.push(`/(auth)/items/page?id=${process.item.Id}`)}
className='relative bg-neutral-900 border border-neutral-800 rounded-2xl overflow-hidden'
{...props}
>
{process.status === "downloading" && (
<View
className={`
bg-purple-600 h-1 absolute bottom-0 left-0
`}
style={{
width: process.progress
? `${Math.max(5, process.progress)}%`
: "5%",
}}
/>
)}
<View className='px-3 py-1.5 flex flex-col w-full'>
<View className='flex flex-row items-center w-full'>
{base64Image && (
<View className='w-14 aspect-[10/15] rounded-lg overflow-hidden mr-4'>
<Image
source={{
uri: `data:image/jpeg;base64,${base64Image}`,
}}
style={{
width: "100%",
height: "100%",
}}
contentFit='cover'
/>
</View>
)}
<View className='shrink mb-1'>
<Text className='text-xs opacity-50'>{process.item.Type}</Text>
<Text className='font-semibold shrink'>{process.item.Name}</Text>
<Text className='text-xs opacity-50'>
{process.item.ProductionYear}
</Text>
<View className='flex flex-row items-center space-x-2 mt-1 text-purple-600'>
{process.progress === 0 ? (
<ActivityIndicator size={"small"} color={"white"} />
) : (
<Text className='text-xs'>{process.progress.toFixed(0)}%</Text>
)}
{process.speed && process.speed > 0 && (
<Text className='text-xs'>
{bytesToMB(process.speed).toFixed(2)} MB/s
</Text>
)}
{eta(process) && (
<Text className='text-xs'>
{t("home.downloads.eta", { eta: eta(process) })}
</Text>
)}
</View>
<View className='flex flex-row items-center space-x-2 mt-1 text-purple-600'>
<Text className='text-xs capitalize'>{process.status}</Text>
</View>
</View>
<View className='ml-auto flex flex-row items-center space-x-2'>
{process.status === "downloading" && (
<TouchableOpacity
onPress={() => handlePause(process.id)}
className='p-2 rounded-full bg-yellow-600'
>
<Ionicons name='pause' size={20} color='white' />
</TouchableOpacity>
)}
{process.status === "paused" && (
<TouchableOpacity
onPress={() => handleResume(process.id)}
className='p-2 rounded-full bg-green-600'
>
<Ionicons name='play' size={20} color='white' />
</TouchableOpacity>
)}
<TouchableOpacity
onPress={() => handleDelete(process.id)}
className='p-2 rounded-full bg-red-600'
>
<Ionicons name='close' size={20} color='white' />
</TouchableOpacity>
</View>
</View>
{process.status === "completed" && (
<View className='flex flex-row mt-4 space-x-4'>
<Button
onPress={() => {
startDownload(process);
}}
className='w-full'
>
Download now
</Button>
</View>
)}
</View>
</TouchableOpacity>
);
};

View File

@@ -0,0 +1,198 @@
import { Ionicons } from "@expo/vector-icons";
import { useQueryClient } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useRouter } from "expo-router";
import { t } from "i18next";
import { useMemo } from "react";
import {
ActivityIndicator,
TouchableOpacity,
type TouchableOpacityProps,
View,
} from "react-native";
import { toast } from "sonner-native";
import { Text } from "@/components/common/Text";
import { useDownload } from "@/providers/DownloadProvider";
import { JobStatus } from "@/providers/Downloads/types";
import { storage } from "@/utils/mmkv";
import { formatTimeString } from "@/utils/time";
import { Button } from "../Button";
const bytesToMB = (bytes: number) => {
return bytes / 1024 / 1024;
};
interface DownloadCardProps extends TouchableOpacityProps {
process: JobStatus;
}
export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
const { startDownload, pauseDownload, resumeDownload, removeProcess } =
useDownload();
const router = useRouter();
const queryClient = useQueryClient();
const handlePause = async (id: string) => {
try {
await pauseDownload(id);
toast.success(t("home.downloads.toasts.download_paused"));
} catch (error) {
console.error("Error pausing download:", error);
toast.error(t("home.downloads.toasts.could_not_pause_download"));
}
};
const handleResume = async (id: string) => {
try {
await resumeDownload(id);
toast.success(t("home.downloads.toasts.download_resumed"));
} catch (error) {
console.error("Error resuming download:", error);
toast.error(t("home.downloads.toasts.could_not_resume_download"));
}
};
const handleDelete = async (id: string) => {
try {
await removeProcess(id);
toast.success(t("home.downloads.toasts.download_deleted"));
queryClient.invalidateQueries({ queryKey: ["downloads"] });
} catch (error) {
console.error("Error deleting download:", error);
toast.error(t("home.downloads.toasts.could_not_delete_download"));
}
};
const eta = (p: JobStatus) => {
if (!p.speed || p.speed <= 0 || !p.estimatedTotalSizeBytes) return null;
const bytesRemaining = p.estimatedTotalSizeBytes - (p.bytesDownloaded || 0);
if (bytesRemaining <= 0) return null;
const secondsRemaining = bytesRemaining / p.speed;
return formatTimeString(secondsRemaining, "s");
};
const base64Image = useMemo(() => {
return storage.getString(process.item.Id!);
}, []);
// Sanitize progress to ensure it's within valid bounds
const sanitizedProgress = useMemo(() => {
if (
typeof process.progress !== "number" ||
Number.isNaN(process.progress)
) {
return 0;
}
return Math.max(0, Math.min(100, process.progress));
}, [process.progress]);
return (
<TouchableOpacity
onPress={() => router.push(`/(auth)/items/page?id=${process.item.Id}`)}
className='relative bg-neutral-900 border border-neutral-800 rounded-2xl overflow-hidden'
{...props}
>
{process.status === "downloading" && (
<View
className={`
bg-purple-600 h-1 absolute bottom-0 left-0
`}
style={{
width:
sanitizedProgress > 0
? `${Math.max(5, sanitizedProgress)}%`
: "5%",
}}
/>
)}
{/* Action buttons in top right corner */}
<View className='absolute top-2 right-2 flex flex-row items-center space-x-2 z-10'>
{process.status === "downloading" && (
<TouchableOpacity
onPress={() => handlePause(process.id)}
className='p-1'
>
<Ionicons name='pause' size={20} color='white' />
</TouchableOpacity>
)}
{process.status === "paused" && (
<TouchableOpacity
onPress={() => handleResume(process.id)}
className='p-1'
>
<Ionicons name='play' size={20} color='white' />
</TouchableOpacity>
)}
<TouchableOpacity
onPress={() => handleDelete(process.id)}
className='p-1'
>
<Ionicons name='close' size={20} color='red' />
</TouchableOpacity>
</View>
<View className='px-3 py-1.5 flex flex-col w-full'>
<View className='flex flex-row items-center w-full'>
{base64Image && (
<View className='w-14 aspect-[10/15] rounded-lg overflow-hidden mr-4'>
<Image
source={{
uri: `data:image/jpeg;base64,${base64Image}`,
}}
style={{
width: "100%",
height: "100%",
}}
contentFit='cover'
/>
</View>
)}
<View className='shrink mb-1 flex-1'>
<Text className='text-xs opacity-50'>{process.item.Type}</Text>
<Text className='font-semibold shrink'>{process.item.Name}</Text>
<Text className='text-xs opacity-50'>
{process.item.ProductionYear}
</Text>
<View className='flex flex-row items-center space-x-2 mt-1 text-purple-600'>
{sanitizedProgress === 0 ? (
<ActivityIndicator size={"small"} color={"white"} />
) : (
<Text className='text-xs'>{sanitizedProgress.toFixed(0)}%</Text>
)}
{process.speed && process.speed > 0 && (
<Text className='text-xs'>
{bytesToMB(process.speed).toFixed(2)} MB/s
</Text>
)}
{eta(process) && (
<Text className='text-xs'>
{t("home.downloads.eta", { eta: eta(process) })}
</Text>
)}
</View>
<View className='flex flex-row items-center space-x-2 mt-1 text-purple-600'>
<Text className='text-xs capitalize'>{process.status}</Text>
</View>
</View>
</View>
{process.status === "completed" && (
<View className='flex flex-row mt-4 space-x-4'>
<Button
onPress={() => {
startDownload(process);
}}
className='w-full'
>
Download now
</Button>
</View>
)}
</View>
</TouchableOpacity>
);
};

View File

@@ -26,7 +26,7 @@ import { itemRouter } from "../common/TouchableItemRouter";
interface Props extends ViewProps {} interface Props extends ViewProps {}
export const LargeMovieCarousel: React.FC<Props> = ({ ...props }) => { export const LargeMovieCarousel: React.FC<Props> = ({ ...props }) => {
const [settings] = useSettings(null); const { settings } = useSettings();
const ref = React.useRef<ICarouselInstance>(null); const ref = React.useRef<ICarouselInstance>(null);
const progress = useSharedValue<number>(0); const progress = useSharedValue<number>(0);

View File

@@ -11,16 +11,12 @@ import {
useJellyseerr, useJellyseerr,
} from "@/hooks/useJellyseerr"; } from "@/hooks/useJellyseerr";
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover"; import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
import type {
MovieResult,
TvResult,
} from "@/utils/jellyseerr/server/models/Search";
const MovieTvSlide: React.FC<SlideProps & ViewProps> = ({ const MovieTvSlide: React.FC<SlideProps & ViewProps> = ({
slide, slide,
...props ...props
}) => { }) => {
const { jellyseerrApi } = useJellyseerr(); const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr();
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({ const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
queryKey: ["jellyseerr", "discover", slide.id], queryKey: ["jellyseerr", "discover", slide.id],
@@ -69,7 +65,9 @@ const MovieTvSlide: React.FC<SlideProps & ViewProps> = ({
uniqBy( uniqBy(
data?.pages data?.pages
?.filter((p) => p?.results.length) ?.filter((p) => p?.results.length)
.flatMap((p) => p?.results), .flatMap((p) =>
p?.results.filter((r) => isJellyseerrMovieOrTvResult(r)),
),
"id", "id",
), ),
[data], [data],
@@ -86,12 +84,7 @@ const MovieTvSlide: React.FC<SlideProps & ViewProps> = ({
onEndReached={() => { onEndReached={() => {
if (hasNextPage) fetchNextPage(); if (hasNextPage) fetchNextPage();
}} }}
renderItem={(item) => ( renderItem={(item) => <JellyseerrPoster item={item} key={item?.id} />}
<JellyseerrPoster
item={item as MovieResult | TvResult}
key={item?.id}
/>
)}
/> />
) )
); );

View File

@@ -41,7 +41,7 @@ const icons: Record<CollectionType, IconName> = {
export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => { export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const [settings] = useSettings(null); const { settings } = useSettings();
const { t } = useTranslation(); const { t } = useTranslation();

View File

@@ -12,7 +12,7 @@ import { useAtom } from "jotai";
import { useCallback } from "react"; import { useCallback } from "react";
import { View, type ViewProps } from "react-native"; import { View, type ViewProps } from "react-native";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { InfiniteHorizontalScroll } from "../common/InfiniteHorrizontalScroll"; import { InfiniteHorizontalScroll } from "../common/InfiniteHorizontalScroll";
import { Text } from "../common/Text"; import { Text } from "../common/Text";
import { TouchableItemRouter } from "../common/TouchableItemRouter"; import { TouchableItemRouter } from "../common/TouchableItemRouter";
import { ItemCardText } from "../ItemCardText"; import { ItemCardText } from "../ItemCardText";

View File

@@ -20,6 +20,7 @@ import { MediaStatus } from "@/utils/jellyseerr/server/constants/media";
import type MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest"; import type MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest";
import type { DownloadingItem } from "@/utils/jellyseerr/server/lib/downloadtracker"; import type { DownloadingItem } from "@/utils/jellyseerr/server/lib/downloadtracker";
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie"; import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
import { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person";
import type { import type {
MovieResult, MovieResult,
TvResult, TvResult,
@@ -27,7 +28,7 @@ import type {
import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv"; import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
interface Props extends ViewProps { interface Props extends ViewProps {
item?: MovieResult | TvResult | MovieDetails | TvDetails; item?: MovieResult | TvResult | MovieDetails | TvDetails | PersonCreditCast;
horizontal?: boolean; horizontal?: boolean;
showDownloadInfo?: boolean; showDownloadInfo?: boolean;
mediaRequest?: MediaRequest; mediaRequest?: MediaRequest;

View File

@@ -10,7 +10,7 @@ import { useTranslation } from "react-i18next";
import { TouchableOpacity, View, type ViewProps } from "react-native"; import { TouchableOpacity, View, type ViewProps } from "react-native";
import { apiAtom } from "@/providers/JellyfinProvider"; import { apiAtom } from "@/providers/JellyfinProvider";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { HorizontalScroll } from "../common/HorrizontalScroll"; import { HorizontalScroll } from "../common/HorizontalScroll";
import { Text } from "../common/Text"; import { Text } from "../common/Text";
import { itemRouter } from "../common/TouchableItemRouter"; import { itemRouter } from "../common/TouchableItemRouter";
import Poster from "../posters/Poster"; import Poster from "../posters/Poster";
@@ -27,16 +27,18 @@ export const CastAndCrew: React.FC<Props> = ({ item, loading, ...props }) => {
const from = segments[2]; const from = segments[2];
const destinctPeople = useMemo(() => { const destinctPeople = useMemo(() => {
const people: BaseItemPerson[] = []; const people: Record<string, BaseItemPerson> = {};
item?.People?.forEach((person) => { item?.People?.forEach((person) => {
const existingPerson = people.find((p) => p.Id === person.Id); if (!person.Id) return;
const existingPerson = people[person.Id];
if (existingPerson) { if (existingPerson) {
existingPerson.Role = `${existingPerson.Role}, ${person.Role}`; existingPerson.Role = `${existingPerson.Role}, ${person.Role}`;
} else { } else {
people.push(person); people[person.Id] = person;
} }
}); });
return people; return Object.values(people);
}, [item?.People]); }, [item?.People]);
if (!from) return null; if (!from) return null;
@@ -54,7 +56,13 @@ export const CastAndCrew: React.FC<Props> = ({ item, loading, ...props }) => {
renderItem={(i) => ( renderItem={(i) => (
<TouchableOpacity <TouchableOpacity
onPress={() => { onPress={() => {
const url = itemRouter(i, from); const url = itemRouter(
{
Id: i.Id,
Type: "Person",
},
from,
);
// @ts-expect-error // @ts-expect-error
router.push(url); router.push(url);
}} }}

View File

@@ -6,7 +6,7 @@ import { useTranslation } from "react-i18next";
import { TouchableOpacity, View, type ViewProps } from "react-native"; import { TouchableOpacity, View, type ViewProps } from "react-native";
import { apiAtom } from "@/providers/JellyfinProvider"; import { apiAtom } from "@/providers/JellyfinProvider";
import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById"; import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById";
import { HorizontalScroll } from "../common/HorrizontalScroll"; import { HorizontalScroll } from "../common/HorizontalScroll";
import { Text } from "../common/Text"; import { Text } from "../common/Text";
import Poster from "../posters/Poster"; import Poster from "../posters/Poster";

View File

@@ -11,7 +11,7 @@ import { orderBy } from "lodash";
import type React from "react"; import type React from "react";
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { Alert, TouchableOpacity, View } from "react-native"; import { Alert, TouchableOpacity, View } from "react-native";
import { HorizontalScroll } from "@/components/common/HorrizontalScroll"; import { HorizontalScroll } from "@/components/common/HorizontalScroll";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { Tags } from "@/components/GenreTags"; import { Tags } from "@/components/GenreTags";
import { dateOpts } from "@/components/jellyseerr/DetailFacts"; import { dateOpts } from "@/components/jellyseerr/DetailFacts";

View File

@@ -12,7 +12,7 @@ import ContinueWatchingPoster from "../ContinueWatchingPoster";
import { import {
HorizontalScroll, HorizontalScroll,
type HorizontalScrollRef, type HorizontalScrollRef,
} from "../common/HorrizontalScroll"; } from "../common/HorizontalScroll";
import { ItemCardText } from "../ItemCardText"; import { ItemCardText } from "../ItemCardText";
interface Props extends ViewProps { interface Props extends ViewProps {
@@ -42,11 +42,7 @@ export const SeasonEpisodesCarousel: React.FC<Props> = ({
return item?.SeasonId; return item?.SeasonId;
}, [item]); }, [item]);
const { const { data: episodes, isPending } = useQuery({
data: episodes,
isLoading,
isFetching,
} = useQuery({
queryKey: ["episodes", seasonId, isOffline], queryKey: ["episodes", seasonId, isOffline],
queryFn: async () => { queryFn: async () => {
if (isOffline) { if (isOffline) {
@@ -132,7 +128,7 @@ export const SeasonEpisodesCarousel: React.FC<Props> = ({
ref={scrollRef} ref={scrollRef}
data={episodes} data={episodes}
extraData={item} extraData={item}
loading={loading || isLoading || isFetching} loading={loading || isPending}
renderItem={(_item, _idx) => ( renderItem={(_item, _idx) => (
<TouchableOpacity <TouchableOpacity
key={_item.Id} key={_item.Id}

View File

@@ -74,7 +74,7 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
return season.Id!; return season.Id!;
}, [seasons, seasonIndex]); }, [seasons, seasonIndex]);
const { data: episodes, isFetching } = useQuery({ const { data: episodes, isPending } = useQuery({
queryKey: ["episodes", item.Id, selectedSeasonId], queryKey: ["episodes", item.Id, selectedSeasonId],
queryFn: async () => { queryFn: async () => {
if (!api || !user?.Id || !item.Id || !selectedSeasonId) { if (!api || !user?.Id || !item.Id || !selectedSeasonId) {
@@ -165,7 +165,7 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
) : null} ) : null}
</View> </View>
<View className='px-4 flex flex-col mt-4'> <View className='px-4 flex flex-col mt-4'>
{isFetching ? ( {isPending ? (
<View <View
style={{ style={{
minHeight: 144 * nrOfEpisodes, minHeight: 144 * nrOfEpisodes,

View File

@@ -12,7 +12,7 @@ interface Props extends ViewProps {}
export const AppLanguageSelector: React.FC<Props> = () => { export const AppLanguageSelector: React.FC<Props> = () => {
const isTv = Platform.isTV; const isTv = Platform.isTV;
const [settings, updateSettings] = useSettings(null); const { settings, updateSettings } = useSettings();
const { t } = useTranslation(); const { t } = useTranslation();
if (isTv) return null; if (isTv) return null;

View File

@@ -17,7 +17,7 @@ export const AudioToggles: React.FC<Props> = ({ ...props }) => {
const isTv = Platform.isTV; const isTv = Platform.isTV;
const media = useMedia(); const media = useMedia();
const [_, __, pluginSettings] = useSettings(null); const { pluginSettings } = useSettings();
const { settings, updateSettings } = media; const { settings, updateSettings } = media;
const cultures = media.cultures; const cultures = media.cultures;
const { t } = useTranslation(); const { t } = useTranslation();

View File

@@ -4,7 +4,7 @@ import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem"; import { ListItem } from "../list/ListItem";
export const ChromecastSettings: React.FC = ({ ...props }) => { export const ChromecastSettings: React.FC = ({ ...props }) => {
const [settings, updateSettings] = useSettings(null); const { settings, updateSettings } = useSettings();
return ( return (
<View {...props}> <View {...props}>
<ListGroup title={"Chromecast"}> <ListGroup title={"Chromecast"}>

View File

@@ -7,7 +7,7 @@ import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem"; import { ListItem } from "../list/ListItem";
export const Dashboard = () => { export const Dashboard = () => {
const [settings, _updateSettings] = useSettings(null); const { settings } = useSettings();
const { sessions = [] } = useSessions({} as useSessionsProps); const { sessions = [] } = useSessions({} as useSessionsProps);
const router = useRouter(); const router = useRouter();

View File

@@ -7,7 +7,7 @@ import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem"; import { ListItem } from "../list/ListItem";
export default function DownloadSettings({ ...props }) { export default function DownloadSettings({ ...props }) {
const [settings, updateSettings, pluginSettings] = useSettings(null); const { settings, updateSettings, pluginSettings } = useSettings();
const { t } = useTranslation(); const { t } = useTranslation();
const allDisabled = useMemo( const allDisabled = useMemo(

View File

@@ -13,7 +13,7 @@ interface Props extends ViewProps {}
export const GestureControls: React.FC<Props> = ({ ...props }) => { export const GestureControls: React.FC<Props> = ({ ...props }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [settings, updateSettings, pluginSettings] = useSettings(null); const { settings, updateSettings, pluginSettings } = useSettings();
const disabled = useMemo( const disabled = useMemo(
() => () =>

View File

@@ -64,13 +64,7 @@ export const HomeIndex = () => {
const user = useAtomValue(userAtom); const user = useAtomValue(userAtom);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [ const { settings, refreshStreamyfinPluginSettings } = useSettings();
settings,
_updateSettings,
_pluginSettings,
_setPluginSettings,
refreshStreamyfinPluginSettings,
] = useSettings(null);
const navigation = useNavigation(); const navigation = useNavigation();

View File

@@ -21,7 +21,7 @@ export const JellyseerrSettings = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const [settings, updateSettings, _pluginSettings] = useSettings(null); const { settings, updateSettings } = useSettings();
const [jellyseerrPassword, setJellyseerrPassword] = useState< const [jellyseerrPassword, setJellyseerrPassword] = useState<
string | undefined string | undefined

View File

@@ -28,7 +28,7 @@ export const useMedia = () => {
}; };
export const MediaProvider = ({ children }: { children: ReactNode }) => { export const MediaProvider = ({ children }: { children: ReactNode }) => {
const [settings, updateSettings] = useSettings(null); const { settings, updateSettings } = useSettings();
const api = useAtomValue(apiAtom); const api = useAtomValue(apiAtom);
const queryClient = useQueryClient(); const queryClient = useQueryClient();

View File

@@ -13,7 +13,7 @@ interface Props extends ViewProps {}
export const MediaToggles: React.FC<Props> = ({ ...props }) => { export const MediaToggles: React.FC<Props> = ({ ...props }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [settings, updateSettings, pluginSettings] = useSettings(null); const { settings, updateSettings, pluginSettings } = useSettings();
const disabled = useMemo( const disabled = useMemo(
() => () =>

View File

@@ -23,7 +23,7 @@ import { ListItem } from "../list/ListItem";
export const OtherSettings: React.FC = () => { export const OtherSettings: React.FC = () => {
const router = useRouter(); const router = useRouter();
const [settings, updateSettings, pluginSettings] = useSettings(null); const { settings, updateSettings, pluginSettings } = useSettings();
const { t } = useTranslation(); const { t } = useTranslation();

View File

@@ -6,7 +6,7 @@ import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem"; import { ListItem } from "../list/ListItem";
export const PluginSettings = () => { export const PluginSettings = () => {
const [settings, _updateSettings] = useSettings(null); const { settings } = useSettings();
const router = useRouter(); const router = useRouter();

View File

@@ -20,7 +20,7 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
const isTv = Platform.isTV; const isTv = Platform.isTV;
const media = useMedia(); const media = useMedia();
const [_, __, pluginSettings] = useSettings(null); const { pluginSettings } = useSettings();
const { settings, updateSettings } = media; const { settings, updateSettings } = media;
const cultures = media.cultures; const cultures = media.cultures;
const { t } = useTranslation(); const { t } = useTranslation();

View File

@@ -17,7 +17,7 @@ export const commonScreenOptions: ICommonScreenOptions = {
headerLeft: () => <HeaderBackButton />, headerLeft: () => <HeaderBackButton />,
}; };
const routes = ["actors/[actorId]", "items/page", "series/[id]"]; const routes = ["persons/[personId]", "items/page", "series/[id]"];
export const nestedTabPageScreenOptions: Record<string, ICommonScreenOptions> = export const nestedTabPageScreenOptions: Record<string, ICommonScreenOptions> =
Object.fromEntries(routes.map((route) => [route, commonScreenOptions])); Object.fromEntries(routes.map((route) => [route, commonScreenOptions]));

View File

@@ -88,7 +88,7 @@ export const BottomControls: FC<BottomControlsProps> = ({
trickplayInfo, trickplayInfo,
time, time,
}) => { }) => {
const [settings] = useSettings(null); const { settings } = useSettings();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
return ( return (

View File

@@ -30,7 +30,7 @@ export const CenterControls: FC<CenterControlsProps> = ({
handleSkipBackward, handleSkipBackward,
handleSkipForward, handleSkipForward,
}) => { }) => {
const [settings] = useSettings(null); const { settings } = useSettings();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
return ( return (

View File

@@ -16,7 +16,7 @@ export interface ContinueWatchingOverlayProps {
const ContinueWatchingOverlay: React.FC<ContinueWatchingOverlayProps> = ({ const ContinueWatchingOverlay: React.FC<ContinueWatchingOverlayProps> = ({
goToNextItem, goToNextItem,
}) => { }) => {
const [settings] = useSettings(null); const { settings } = useSettings();
const router = useRouter(); const router = useRouter();
return settings.autoPlayEpisodeCount >= return settings.autoPlayEpisodeCount >=

View File

@@ -116,7 +116,7 @@ export const Controls: FC<Props> = ({
api = null, api = null,
downloadedFiles = undefined, downloadedFiles = undefined,
}) => { }) => {
const [settings, updateSettings] = useSettings(api); const { settings, updateSettings } = useSettings();
const router = useRouter(); const router = useRouter();
const lightHapticFeedback = useHaptic("light"); const lightHapticFeedback = useHaptic("light");

View File

@@ -11,7 +11,7 @@ import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
import { import {
HorizontalScroll, HorizontalScroll,
type HorizontalScrollRef, type HorizontalScrollRef,
} from "@/components/common/HorrizontalScroll"; } from "@/components/common/HorizontalScroll";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader"; import { Loader } from "@/components/Loader";
import { import {

View File

@@ -31,7 +31,7 @@ export const GestureOverlay = ({
onSkipForward, onSkipForward,
onSkipBackward, onSkipBackward,
}: Props) => { }: Props) => {
const [settings] = useSettings(null); const { settings } = useSettings();
const lightHaptic = useHaptic("light"); const lightHaptic = useHaptic("light");
const [feedback, setFeedback] = useState<FeedbackState>({ const [feedback, setFeedback] = useState<FeedbackState>({

View File

@@ -70,7 +70,7 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
setVideoAspectRatio, setVideoAspectRatio,
setVideoScaleFactor, setVideoScaleFactor,
}) => { }) => {
const [settings] = useSettings(null); const { settings } = useSettings();
const router = useRouter(); const router = useRouter();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { width: screenWidth } = useWindowDimensions(); const { width: screenWidth } = useWindowDimensions();

View File

@@ -20,7 +20,7 @@ export function useVideoNavigation({
seek, seek,
play, play,
}: UseVideoNavigationProps) { }: UseVideoNavigationProps) {
const [settings] = useSettings(null); const { settings } = useSettings();
const lightHapticFeedback = useHaptic("light"); const lightHapticFeedback = useHaptic("light");
const wasPlayingRef = useRef(false); const wasPlayingRef = useRef(false);

View File

@@ -26,13 +26,6 @@
"EXPO_PUBLIC_WRITE_DEBUG": "1" "EXPO_PUBLIC_WRITE_DEBUG": "1"
} }
}, },
"preview": {
"environment": "development",
"distribution": "internal",
"env": {
"EXPO_PUBLIC_WRITE_DEBUG": "1"
}
},
"development-simulator": { "development-simulator": {
"environment": "development", "environment": "development",
"developmentClient": true, "developmentClient": true,
@@ -44,16 +37,22 @@
"EXPO_PUBLIC_WRITE_DEBUG": "1" "EXPO_PUBLIC_WRITE_DEBUG": "1"
} }
}, },
"preview": {
"distribution": "internal",
"env": {
"EXPO_PUBLIC_WRITE_DEBUG": "1"
}
},
"production": { "production": {
"environment": "production", "environment": "production",
"channel": "0.35.0", "channel": "0.35.1",
"android": { "android": {
"image": "latest" "image": "latest"
} }
}, },
"production-apk": { "production-apk": {
"environment": "production", "environment": "production",
"channel": "0.35.0", "channel": "0.35.1",
"android": { "android": {
"buildType": "apk", "buildType": "apk",
"image": "latest" "image": "latest"
@@ -61,7 +60,7 @@
}, },
"production-apk-tv": { "production-apk-tv": {
"environment": "production", "environment": "production",
"channel": "0.35.0", "channel": "0.35.1",
"android": { "android": {
"buildType": "apk", "buildType": "apk",
"image": "latest" "image": "latest"

View File

@@ -14,7 +14,7 @@ export type HapticFeedbackType =
| "error"; | "error";
export const useHaptic = (feedbackType: HapticFeedbackType = "selection") => { export const useHaptic = (feedbackType: HapticFeedbackType = "selection") => {
const [settings] = useSettings(null); const { settings } = useSettings();
const isTv = Platform.isTV; const isTv = Platform.isTV;
const isDisabled = const isDisabled =
isTv || isTv ||

View File

@@ -14,7 +14,7 @@ import { useQueryClient } from "@tanstack/react-query";
import { t } from "i18next"; import { t } from "i18next";
import { useCallback, useMemo } from "react"; import { useCallback, useMemo } from "react";
import { toast } from "sonner-native"; import { toast } from "sonner-native";
import { defaultValues, Settings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import type { RTRating } from "@/utils/jellyseerr/server/api/rating/rottentomatoes"; import type { RTRating } from "@/utils/jellyseerr/server/api/rating/rottentomatoes";
import { import {
IssueStatus, IssueStatus,
@@ -40,6 +40,7 @@ import type { UserResultsResponse } from "@/utils/jellyseerr/server/interfaces/a
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie"; import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
import type { import type {
CombinedCredit, CombinedCredit,
PersonCreditCast,
PersonDetails, PersonDetails,
} from "@/utils/jellyseerr/server/models/Person"; } from "@/utils/jellyseerr/server/models/Person";
import type { import type {
@@ -416,10 +417,8 @@ export class JellyseerrApi {
const jellyseerrUserAtom = atom(storage.get<JellyseerrUser>(JELLYSEERR_USER)); const jellyseerrUserAtom = atom(storage.get<JellyseerrUser>(JELLYSEERR_USER));
export const useJellyseerr = ( export const useJellyseerr = () => {
settings: Settings = defaultValues, const { settings, updateSettings } = useSettings();
updateSettings: (update: Partial<Settings>) => void = () => {},
) => {
const [jellyseerrUser, setJellyseerrUser] = useAtom(jellyseerrUserAtom); const [jellyseerrUser, setJellyseerrUser] = useAtom(jellyseerrUserAtom);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -468,49 +467,47 @@ export const useJellyseerr = (
[jellyseerrApi], [jellyseerrApi],
); );
const isJellyseerrResult = ( const isJellyseerrMovieOrTvResult = (
items: any | null | undefined, items: any | null | undefined,
): items is Results => { ): items is MovieResult | TvResult => {
return ( return (
items && items &&
Object.hasOwn(items, "mediaType") && Object.hasOwn(items, "mediaType") &&
Object.values(MediaType).includes(items.mediaType as MediaType) (items.mediaType === MediaType.MOVIE || items.mediaType === MediaType.TV)
); );
}; };
const getTitle = ( const getTitle = (
item?: TvResult | TvDetails | MovieResult | MovieDetails, item?: TvResult | TvDetails | MovieResult | MovieDetails | PersonCreditCast,
) => { ) => {
return isJellyseerrResult(item) return isJellyseerrMovieOrTvResult(item)
? item.mediaType === MediaType.MOVIE ? item.mediaType === MediaType.MOVIE
? item?.title ? item?.title
: item?.name : item?.name
: item?.mediaInfo.mediaType === MediaType.MOVIE : item?.mediaInfo?.mediaType === MediaType.MOVIE
? (item as MovieDetails)?.title ? (item as MovieDetails)?.title
: (item as TvDetails)?.name; : (item as TvDetails)?.name;
}; };
const getYear = ( const getYear = (
item?: TvResult | TvDetails | MovieResult | MovieDetails, item?: TvResult | TvDetails | MovieResult | MovieDetails | PersonCreditCast,
) => { ) => {
return new Date( return new Date(
(isJellyseerrResult(item) (isJellyseerrMovieOrTvResult(item)
? item.mediaType === MediaType.MOVIE ? item.mediaType === MediaType.MOVIE
? item?.releaseDate ? item?.releaseDate
: item?.firstAirDate : item?.firstAirDate
: item?.mediaInfo.mediaType === MediaType.MOVIE : item?.mediaInfo?.mediaType === MediaType.MOVIE
? (item as MovieDetails)?.releaseDate ? (item as MovieDetails)?.releaseDate
: (item as TvDetails)?.firstAirDate) || "", : (item as TvDetails)?.firstAirDate) || "",
)?.getFullYear?.(); )?.getFullYear?.();
}; };
const getMediaType = ( const getMediaType = (
item?: TvResult | TvDetails | MovieResult | MovieDetails, item?: TvResult | TvDetails | MovieResult | MovieDetails | PersonCreditCast,
): MediaType => { ): MediaType => {
return isJellyseerrResult(item) return isJellyseerrMovieOrTvResult(item)
? item.mediaType === "movie" ? (item.mediaType as MediaType)
? MediaType.MOVIE
: MediaType.TV
: item?.mediaInfo?.mediaType; : item?.mediaInfo?.mediaType;
}; };
@@ -528,7 +525,7 @@ export const useJellyseerr = (
jellyseerrUser, jellyseerrUser,
setJellyseerrUser, setJellyseerrUser,
clearAllJellyseerData, clearAllJellyseerData,
isJellyseerrResult, isJellyseerrMovieOrTvResult,
getTitle, getTitle,
getYear, getYear,
getMediaType, getMediaType,

View File

@@ -4,6 +4,7 @@ import type {
} from "@jellyfin/sdk/lib/generated-client/models"; } from "@jellyfin/sdk/lib/generated-client/models";
import * as Application from "expo-application"; import * as Application from "expo-application";
import * as FileSystem from "expo-file-system"; import * as FileSystem from "expo-file-system";
import * as Notifications from "expo-notifications";
import { router } from "expo-router"; import { router } from "expo-router";
import { atom, useAtom } from "jotai"; import { atom, useAtom } from "jotai";
import { throttle } from "lodash"; import { throttle } from "lodash";
@@ -87,9 +88,72 @@ function useDownloadProvider() {
const { saveSeriesPrimaryImage } = useDownloadHelper(); const { saveSeriesPrimaryImage } = useDownloadHelper();
const { saveImage } = useImageStorage(); const { saveImage } = useImageStorage();
const [processes, setProcesses] = useAtom<JobStatus[]>(processesAtom); const [processes, setProcesses] = useAtom<JobStatus[]>(processesAtom);
const [settings] = useSettings(null); const { settings } = useSettings();
const successHapticFeedback = useHaptic("success"); const successHapticFeedback = useHaptic("success");
// Generate notification content based on item type
const getNotificationContent = useCallback(
(item: BaseItemDto, isSuccess: boolean) => {
if (item.Type === "Episode") {
const season = item.ParentIndexNumber
? String(item.ParentIndexNumber).padStart(2, "0")
: "??";
const episode = item.IndexNumber
? String(item.IndexNumber).padStart(2, "0")
: "??";
const subtitle = `${item.Name} - [S${season}E${episode}] (${item.SeriesName})`;
return {
title: isSuccess ? "Download complete" : "Download failed",
body: subtitle,
};
} else if (item.Type === "Movie") {
const year = item.ProductionYear ? ` (${item.ProductionYear})` : "";
const subtitle = `${item.Name}${year}`;
return {
title: isSuccess ? "Download complete" : "Download failed",
body: subtitle,
};
} else {
// Fallback for other types
return {
title: isSuccess
? t("home.downloads.toasts.download_completed_for_item", {
item: item.Name,
})
: t("home.downloads.toasts.download_failed_for_item", {
item: item.Name,
}),
body: item.Name || "Unknown item",
};
}
},
[t],
);
// Send local notification for download events
const sendDownloadNotification = useCallback(
async (title: string, body: string, data?: Record<string, any>) => {
if (Platform.isTV) return;
try {
await Notifications.scheduleNotificationAsync({
content: {
title,
body,
data,
...(Platform.OS === "android" && { channelId: "downloads" }),
},
trigger: null, // Show immediately
});
} catch (error) {
console.error("Failed to send notification:", error);
}
},
[],
);
/// Cant use the background downloader callback. As its not triggered if size is unknown. /// Cant use the background downloader callback. As its not triggered if size is unknown.
const updateProgress = async () => { const updateProgress = async () => {
const tasks = await BackGroundDownloader.checkForExistingDownloads(); const tasks = await BackGroundDownloader.checkForExistingDownloads();
@@ -418,6 +482,21 @@ function useDownloadProvider() {
} }
await saveDownloadsDatabase(db); await saveDownloadsDatabase(db);
// Send native notification for successful download
const successNotification = getNotificationContent(
process.item,
true,
);
await sendDownloadNotification(
successNotification.title,
successNotification.body,
{
itemId: process.item.Id,
itemName: process.item.Name,
type: "download_completed",
},
);
toast.success( toast.success(
t("home.downloads.toasts.download_completed_for_item", { t("home.downloads.toasts.download_completed_for_item", {
item: process.item.Name, item: process.item.Name,
@@ -425,8 +504,25 @@ function useDownloadProvider() {
); );
removeProcess(process.id); removeProcess(process.id);
}) })
.error((error: any) => { .error(async (error: any) => {
console.error("Download error:", error); console.error("Download error:", error);
// Send native notification for failed download
const failureNotification = getNotificationContent(
process.item,
false,
);
await sendDownloadNotification(
failureNotification.title,
failureNotification.body,
{
itemId: process.item.Id,
itemName: process.item.Name,
type: "download_failed",
error: error?.message || "Unknown error",
},
);
toast.error( toast.error(
t("home.downloads.toasts.download_failed_for_item", { t("home.downloads.toasts.download_failed_for_item", {
item: process.item.Name, item: process.item.Name,
@@ -435,7 +531,7 @@ function useDownloadProvider() {
removeProcess(process.id); removeProcess(process.id);
}); });
}, },
[authHeader], [authHeader, sendDownloadNotification, getNotificationContent],
); );
const manageDownloadQueue = useCallback(() => { const manageDownloadQueue = useCallback(() => {

View File

@@ -64,7 +64,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
setJellyfin( setJellyfin(
() => () =>
new Jellyfin({ new Jellyfin({
clientInfo: { name: "Streamyfin", version: "0.35.0" }, clientInfo: { name: "Streamyfin", version: "0.35.1" },
deviceInfo: { deviceInfo: {
name: deviceName, name: deviceName,
id, id,
@@ -79,24 +79,15 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
const [user, setUser] = useAtom(userAtom); const [user, setUser] = useAtom(userAtom);
const [isPolling, setIsPolling] = useState<boolean>(false); const [isPolling, setIsPolling] = useState<boolean>(false);
const [secret, setSecret] = useState<string | null>(null); const [secret, setSecret] = useState<string | null>(null);
const [ const { setPluginSettings, refreshStreamyfinPluginSettings } = useSettings();
_settings, const { clearAllJellyseerData, setJellyseerrUser } = useJellyseerr();
_updateSettings,
_pluginSettings,
setPluginSettings,
refreshStreamyfinPluginSettings,
] = useSettings(api);
const { clearAllJellyseerData, setJellyseerrUser } = useJellyseerr(
_settings || {},
_updateSettings,
);
const headers = useMemo(() => { const headers = useMemo(() => {
if (!deviceId) return {}; if (!deviceId) return {};
return { return {
authorization: `MediaBrowser Client="Streamyfin", Device=${ authorization: `MediaBrowser Client="Streamyfin", Device=${
Platform.OS === "android" ? "Android" : "iOS" Platform.OS === "android" ? "Android" : "iOS"
}, DeviceId="${deviceId}", Version="0.35.0"`, }, DeviceId="${deviceId}", Version="0.35.1"`,
}; };
}, [deviceId]); }, [deviceId]);

View File

@@ -9,6 +9,7 @@ import {
hasPermission, hasPermission,
Permission, Permission,
} from "@/utils/jellyseerr/server/lib/permissions"; } from "@/utils/jellyseerr/server/lib/permissions";
import { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person";
import type { import type {
MovieResult, MovieResult,
TvResult, TvResult,
@@ -18,7 +19,7 @@ import type { MovieDetails } from "../jellyseerr/server/models/Movie";
import type { TvDetails } from "../jellyseerr/server/models/Tv"; import type { TvDetails } from "../jellyseerr/server/models/Tv";
export const useJellyseerrCanRequest = ( export const useJellyseerrCanRequest = (
item?: MovieResult | TvResult | MovieDetails | TvDetails, item?: MovieResult | TvResult | MovieDetails | TvDetails | PersonCreditCast,
) => { ) => {
const { jellyseerrUser } = useJellyseerr(); const { jellyseerrUser } = useJellyseerr();

View File

@@ -56,7 +56,7 @@ export const useJobProcessor = () => {
const [queue, setQueue] = useAtom(queueAtom); const [queue, setQueue] = useAtom(queueAtom);
const [running, setRunning] = useAtom(runningAtom); const [running, setRunning] = useAtom(runningAtom);
const [processes] = useAtom<JobStatus[]>(processesAtom); const [processes] = useAtom<JobStatus[]>(processesAtom);
const [settings] = useSettings(null); const { settings } = useSettings();
useEffect(() => { useEffect(() => {
if ( if (

View File

@@ -1,4 +1,3 @@
import type { Api } from "@jellyfin/sdk";
import { import {
type BaseItemKind, type BaseItemKind,
type CultureDto, type CultureDto,
@@ -7,11 +6,12 @@ import {
type SortOrder, type SortOrder,
SubtitlePlaybackMode, SubtitlePlaybackMode,
} from "@jellyfin/sdk/lib/generated-client"; } from "@jellyfin/sdk/lib/generated-client";
import { atom, useAtom } from "jotai"; import { atom, useAtom, useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo } from "react"; import { useCallback, useEffect, useMemo } from "react";
import { Platform } from "react-native"; import { Platform } from "react-native";
import { BITRATES, type Bitrate } from "@/components/BitrateSelector"; import { BITRATES, type Bitrate } from "@/components/BitrateSelector";
import * as ScreenOrientation from "@/packages/expo-screen-orientation"; import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { apiAtom } from "@/providers/JellyfinProvider";
import { writeInfoLog } from "@/utils/log"; import { writeInfoLog } from "@/utils/log";
import { storage } from "../mmkv"; import { storage } from "../mmkv";
@@ -278,7 +278,8 @@ export const pluginSettingsAtom = atom<PluginLockableSettings | undefined>(
loadPluginSettings(), loadPluginSettings(),
); );
export const useSettings = (api: Api | null) => { export const useSettings = () => {
const api = useAtomValue(apiAtom);
const [_settings, setSettings] = useAtom(settingsAtom); const [_settings, setSettings] = useAtom(settingsAtom);
const [pluginSettings, _setPluginSettings] = useAtom(pluginSettingsAtom); const [pluginSettings, _setPluginSettings] = useAtom(pluginSettingsAtom);
@@ -302,11 +303,11 @@ export const useSettings = (api: Api | null) => {
return; return;
} }
const settings = await api.getStreamyfinPluginConfig().then( const settings = await api.getStreamyfinPluginConfig().then(
({ data }: any) => { ({ data }) => {
writeInfoLog("Got plugin settings", data?.settings); writeInfoLog("Got plugin settings", data?.settings);
return data?.settings; return data?.settings;
}, },
(_err: any) => undefined, (_err) => undefined,
); );
setPluginSettings(settings); setPluginSettings(settings);
return settings; return settings;
@@ -367,11 +368,11 @@ export const useSettings = (api: Api | null) => {
}; };
}, [_settings, pluginSettings]); }, [_settings, pluginSettings]);
return [ return {
settings, settings,
updateSettings, updateSettings,
pluginSettings, pluginSettings,
setPluginSettings, setPluginSettings,
refreshStreamyfinPluginSettings, refreshStreamyfinPluginSettings,
] as const; };
}; };