chore: Apply linting rules and add git hok (#611)

Co-authored-by: Fredrik Burmester <fredrik.burmester@gmail.com>
This commit is contained in:
lostb1t
2025-03-16 18:01:12 +01:00
committed by GitHub
parent 2688e1b981
commit 92513e234f
268 changed files with 9197 additions and 8394 deletions

View File

@@ -1,13 +1,13 @@
import {Stack} from "expo-router";
import { Platform } from "react-native";
import { Stack } from "expo-router";
import { useTranslation } from "react-i18next";
import { Platform } from "react-native";
export default function CustomMenuLayout() {
const { t } = useTranslation();
return (
<Stack>
<Stack.Screen
name="index"
name='index'
options={{
headerShown: true,
headerLargeTitle: true,

View File

@@ -1,13 +1,13 @@
import { Text } from "@/components/common/Text";
import { ListItem } from "@/components/list/ListItem";
import { apiAtom } from "@/providers/JellyfinProvider";
import Ionicons from "@expo/vector-icons/Ionicons";
import { useAtom } from "jotai/index";
import React, { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Platform } from "react-native";
import { FlatList, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import React, { useCallback, useEffect, useState } from "react";
import { useAtom } from "jotai/index";
import { apiAtom } from "@/providers/JellyfinProvider";
import { ListItem } from "@/components/list/ListItem";
import Ionicons from "@expo/vector-icons/Ionicons";
import { Text } from "@/components/common/Text";
import { useTranslation } from "react-i18next";
const WebBrowser = !Platform.isTV ? require("expo-web-browser") : null;
@@ -26,7 +26,7 @@ export default function menuLinks() {
const getMenuLinks = useCallback(async () => {
try {
const response = await api?.axiosInstance.get(
api?.basePath + "/web/config.json"
api?.basePath + "/web/config.json",
);
const config = response?.data;
@@ -46,7 +46,7 @@ export default function menuLinks() {
}, []);
return (
<FlatList
contentInsetAdjustmentBehavior="automatic"
contentInsetAdjustmentBehavior='automatic'
contentContainerStyle={{
paddingTop: 10,
paddingLeft: insets.left,
@@ -63,7 +63,7 @@ export default function menuLinks() {
>
<ListItem
title={item.name}
iconAfter={<Ionicons name="link" size={24} color="white" />}
iconAfter={<Ionicons name='link' size={24} color='white' />}
/>
</TouchableOpacity>
)}
@@ -76,8 +76,10 @@ export default function menuLinks() {
/>
)}
ListEmptyComponent={
<View className="flex flex-col items-center justify-center h-full">
<Text className="font-bold text-xl text-neutral-500">{t("custom_links.no_links")}</Text>
<View className='flex flex-col items-center justify-center h-full'>
<Text className='font-bold text-xl text-neutral-500'>
{t("custom_links.no_links")}
</Text>
</View>
}
/>

View File

@@ -1,14 +1,14 @@
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
import { Stack } from "expo-router";
import { Platform } from "react-native";
import { useTranslation } from "react-i18next";
import { Platform } from "react-native";
export default function SearchLayout() {
const { t } = useTranslation();
return (
<Stack>
<Stack.Screen
name="index"
name='index'
options={{
headerShown: true,
headerLargeTitle: true,

View File

@@ -18,7 +18,7 @@ export default function favorites() {
return (
<ScrollView
nestedScrollEnabled
contentInsetAdjustmentBehavior="automatic"
contentInsetAdjustmentBehavior='automatic'
refreshControl={
<RefreshControl refreshing={loading} onRefresh={refetch} />
}
@@ -28,7 +28,7 @@ export default function favorites() {
paddingBottom: 16,
}}
>
<View className="my-4">
<View className='my-4'>
<Favorites />
</View>
</ScrollView>

View File

@@ -1,12 +1,12 @@
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
import { Ionicons, Feather } from "@expo/vector-icons";
import { Feather, Ionicons } from "@expo/vector-icons";
import { Stack, useRouter } from "expo-router";
import { Platform, TouchableOpacity, View } from "react-native";
import { useTranslation } from "react-i18next";
import { Platform, TouchableOpacity, View } from "react-native";
const Chromecast = Platform.isTV ? null : require("@/components/Chromecast");
import { useAtom } from "jotai";
import { useSessions, type useSessionsProps } from "@/hooks/useSessions";
import { userAtom } from "@/providers/JellyfinProvider";
import { useSessions, useSessionsProps } from "@/hooks/useSessions";
import { useAtom } from "jotai";
export default function IndexLayout() {
const router = useRouter();
@@ -16,7 +16,7 @@ export default function IndexLayout() {
return (
<Stack>
<Stack.Screen
name="index"
name='index'
options={{
headerShown: true,
headerLargeTitle: true,
@@ -28,13 +28,11 @@ export default function IndexLayout() {
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerRight: () => (
<View className="flex flex-row items-center space-x-2">
<View className='flex flex-row items-center space-x-2'>
{!Platform.isTV && (
<>
<Chromecast.Chromecast />
{user && user.Policy?.IsAdministrator && (
<SessionsButton />
)}
{user && user.Policy?.IsAdministrator && <SessionsButton />}
<SettingsButton />
</>
)}
@@ -43,61 +41,61 @@ export default function IndexLayout() {
}}
/>
<Stack.Screen
name="downloads/index"
name='downloads/index'
options={{
title: t("home.downloads.downloads_title"),
}}
/>
<Stack.Screen
name="downloads/[seriesId]"
name='downloads/[seriesId]'
options={{
title: t("home.downloads.tvseries"),
}}
/>
<Stack.Screen
name="sessions/index"
name='sessions/index'
options={{
title: t("home.sessions.title"),
}}
/>
<Stack.Screen
name="settings"
name='settings'
options={{
title: t("home.settings.settings_title"),
}}
/>
<Stack.Screen
name="settings/optimized-server/page"
name='settings/optimized-server/page'
options={{
title: "",
}}
/>
<Stack.Screen
name="settings/marlin-search/page"
name='settings/marlin-search/page'
options={{
title: "",
}}
/>
<Stack.Screen
name="settings/jellyseerr/page"
name='settings/jellyseerr/page'
options={{
title: "",
}}
/>
<Stack.Screen
name="settings/hide-libraries/page"
name='settings/hide-libraries/page'
options={{
title: "",
}}
/>
<Stack.Screen
name="settings/logs/page"
name='settings/logs/page'
options={{
title: "",
}}
/>
<Stack.Screen
name="intro/page"
name='intro/page'
options={{
headerShown: false,
title: "",
@@ -108,7 +106,7 @@ export default function IndexLayout() {
<Stack.Screen key={name} name={name} options={options} />
))}
<Stack.Screen
name="collections/[collectionId]"
name='collections/[collectionId]'
options={{
title: "",
headerShown: true,
@@ -130,7 +128,7 @@ const SettingsButton = () => {
router.push("/(auth)/settings");
}}
>
<Feather name="settings" color={"white"} size={22} />
<Feather name='settings' color={"white"} size={22} />
</TouchableOpacity>
);
};
@@ -145,9 +143,9 @@ const SessionsButton = () => {
router.push("/(auth)/sessions");
}}
>
<View className="mr-4">
<View className='mr-4'>
<Ionicons
name="play-circle"
name='play-circle'
color={sessions.length === 0 ? "white" : "#9333ea"}
size={25}
/>

View File

@@ -1,16 +1,16 @@
import { Text } from "@/components/common/Text";
import { useDownload } from "@/providers/DownloadProvider";
import { router, useLocalSearchParams, useNavigation } from "expo-router";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { ScrollView, TouchableOpacity, View, Alert } from "react-native";
import { EpisodeCard } from "@/components/downloads/EpisodeCard";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import {
SeasonDropdown,
SeasonIndexState,
type SeasonIndexState,
} from "@/components/series/SeasonDropdown";
import { useDownload } from "@/providers/DownloadProvider";
import { storage } from "@/utils/mmkv";
import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { router, useLocalSearchParams, useNavigation } from "expo-router";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Alert, ScrollView, TouchableOpacity, View } from "react-native";
export default function page() {
const navigation = useNavigation();
@@ -21,7 +21,7 @@ export default function page() {
};
const [seasonIndexState, setSeasonIndexState] = useState<SeasonIndexState>(
{}
{},
);
const { downloadedFiles, deleteItems } = useDownload();
@@ -31,7 +31,7 @@ export default function page() {
downloadedFiles
?.filter((f) => f.item.SeriesId == seriesId)
?.sort(
(a, b) => a?.item.ParentIndexNumber! - b.item.ParentIndexNumber!
(a, b) => a?.item.ParentIndexNumber! - b.item.ParentIndexNumber!,
) || []
);
} catch {
@@ -64,7 +64,7 @@ export default function page() {
() =>
Object.values(groupBySeason)?.[0]?.ParentIndexNumber ??
series?.[0]?.item?.ParentIndexNumber,
[groupBySeason]
[groupBySeason],
);
useEffect(() => {
@@ -92,14 +92,14 @@ export default function page() {
onPress: () => deleteItems(groupBySeason),
style: "destructive",
},
]
],
);
}, [groupBySeason]);
return (
<View className="flex-1">
<View className='flex-1'>
{series.length > 0 && (
<View className="flex flex-row items-center justify-start my-2 px-4">
<View className='flex flex-row items-center justify-start my-2 px-4'>
<SeasonDropdown
item={series[0].item}
seasons={series.map((s) => s.item)}
@@ -112,17 +112,17 @@ export default function page() {
}));
}}
/>
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center ml-2">
<Text className="text-xs font-bold">{groupBySeason.length}</Text>
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center ml-2'>
<Text className='text-xs font-bold'>{groupBySeason.length}</Text>
</View>
<View className="bg-neutral-800/80 rounded-full h-9 w-9 flex items-center justify-center ml-auto">
<View className='bg-neutral-800/80 rounded-full h-9 w-9 flex items-center justify-center ml-auto'>
<TouchableOpacity onPress={deleteSeries}>
<Ionicons name="trash" size={20} color="white" />
<Ionicons name='trash' size={20} color='white' />
</TouchableOpacity>
</View>
</View>
)}
<ScrollView key={seasonIndex} className="px-4">
<ScrollView key={seasonIndex} className='px-4'>
{groupBySeason.map((episode, index) => (
<EpisodeCard key={index} item={episode} />
))}

View File

@@ -1,28 +1,28 @@
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { ActiveDownloads } from "@/components/downloads/ActiveDownloads";
import { DownloadSize } from "@/components/downloads/DownloadSize";
import { MovieCard } from "@/components/downloads/MovieCard";
import { SeriesCard } from "@/components/downloads/SeriesCard";
import { DownloadedItem, useDownload } from "@/providers/DownloadProvider";
import { type DownloadedItem, useDownload } from "@/providers/DownloadProvider";
import { queueAtom } from "@/utils/atoms/queue";
import {DownloadMethod, useSettings} from "@/utils/atoms/settings";
import { DownloadMethod, useSettings } from "@/utils/atoms/settings";
import { writeToLog } from "@/utils/log";
import { Ionicons } from "@expo/vector-icons";
import { useNavigation, useRouter } from "expo-router";
import { useAtom } from "jotai";
import React, { useEffect, useMemo, useRef } from "react";
import { Alert, ScrollView, TouchableOpacity, View } from "react-native";
import { Button } from "@/components/Button";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useTranslation } from "react-i18next";
import { t } from 'i18next';
import { DownloadSize } from "@/components/downloads/DownloadSize";
import {
BottomSheetBackdrop,
BottomSheetBackdropProps,
type BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetView,
} from "@gorhom/bottom-sheet";
import { useNavigation, useRouter } from "expo-router";
import { t } from "i18next";
import { useAtom } from "jotai";
import React, { useEffect, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import { Alert, ScrollView, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { toast } from "sonner-native";
import { writeToLog } from "@/utils/log";
export default function page() {
const navigation = useNavigation();
@@ -45,7 +45,7 @@ export default function page() {
const groupedBySeries = useMemo(() => {
try {
const episodes = downloadedFiles?.filter(
(f) => f.item.Type === "Episode"
(f) => f.item.Type === "Episode",
);
const series: { [key: string]: DownloadedItem[] } = {};
episodes?.forEach((e) => {
@@ -73,14 +73,22 @@ export default function page() {
const deleteMovies = () =>
deleteFileByType("Movie")
.then(() => toast.success(t("home.downloads.toasts.deleted_all_movies_successfully")))
.then(() =>
toast.success(
t("home.downloads.toasts.deleted_all_movies_successfully"),
),
)
.catch((reason) => {
writeToLog("ERROR", reason);
toast.error(t("home.downloads.toasts.failed_to_delete_all_movies"));
});
const deleteShows = () =>
deleteFileByType("Episode")
.then(() => toast.success(t("home.downloads.toasts.deleted_all_tvseries_successfully")))
.then(() =>
toast.success(
t("home.downloads.toasts.deleted_all_tvseries_successfully"),
),
)
.catch((reason) => {
writeToLog("ERROR", reason);
toast.error(t("home.downloads.toasts.failed_to_delete_all_tvseries"));
@@ -97,26 +105,28 @@ export default function page() {
paddingBottom: 100,
}}
>
<View className="py-4">
<View className="mb-4 flex flex-col space-y-4 px-4">
<View className='py-4'>
<View className='mb-4 flex flex-col space-y-4 px-4'>
{settings?.downloadMethod === DownloadMethod.Remux && (
<View className="bg-neutral-900 p-4 rounded-2xl">
<Text className="text-lg font-bold">{t("home.downloads.queue")}</Text>
<Text className="text-xs opacity-70 text-red-600">
<View className='bg-neutral-900 p-4 rounded-2xl'>
<Text className='text-lg font-bold'>
{t("home.downloads.queue")}
</Text>
<Text className='text-xs opacity-70 text-red-600'>
{t("home.downloads.queue_hint")}
</Text>
<View className="flex flex-col space-y-2 mt-2">
<View className='flex flex-col space-y-2 mt-2'>
{queue.map((q, index) => (
<TouchableOpacity
onPress={() =>
router.push(`/(auth)/items/page?id=${q.item.Id}`)
}
className="relative bg-neutral-900 border border-neutral-800 p-4 rounded-2xl overflow-hidden flex flex-row items-center justify-between"
className='relative bg-neutral-900 border border-neutral-800 p-4 rounded-2xl overflow-hidden flex flex-row items-center justify-between'
key={index}
>
<View>
<Text className="font-semibold">{q.item.Name}</Text>
<Text className="text-xs opacity-50">
<Text className='font-semibold'>{q.item.Name}</Text>
<Text className='text-xs opacity-50'>
{q.item.Type}
</Text>
</View>
@@ -129,14 +139,16 @@ export default function page() {
});
}}
>
<Ionicons name="close" size={24} color="red" />
<Ionicons name='close' size={24} color='red' />
</TouchableOpacity>
</TouchableOpacity>
))}
</View>
{queue.length === 0 && (
<Text className="opacity-50">{t("home.downloads.no_items_in_queue")}</Text>
<Text className='opacity-50'>
{t("home.downloads.no_items_in_queue")}
</Text>
)}
</View>
)}
@@ -145,17 +157,19 @@ export default function page() {
</View>
{movies.length > 0 && (
<View className="mb-4">
<View className="flex flex-row items-center justify-between mb-2 px-4">
<Text className="text-lg font-bold">{t("home.downloads.movies")}</Text>
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center">
<Text className="text-xs font-bold">{movies?.length}</Text>
<View className='mb-4'>
<View className='flex flex-row items-center justify-between mb-2 px-4'>
<Text className='text-lg font-bold'>
{t("home.downloads.movies")}
</Text>
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
<Text className='text-xs font-bold'>{movies?.length}</Text>
</View>
</View>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View className="px-4 flex flex-row">
<View className='px-4 flex flex-row'>
{movies?.map((item) => (
<View className="mb-2 last:mb-0" key={item.item.Id}>
<View className='mb-2 last:mb-0' key={item.item.Id}>
<MovieCard item={item.item} />
</View>
))}
@@ -164,20 +178,22 @@ export default function page() {
</View>
)}
{groupedBySeries.length > 0 && (
<View className="mb-4">
<View className="flex flex-row items-center justify-between mb-2 px-4">
<Text className="text-lg font-bold">{t("home.downloads.tvseries")}</Text>
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center">
<Text className="text-xs font-bold">
<View className='mb-4'>
<View className='flex flex-row items-center justify-between mb-2 px-4'>
<Text className='text-lg font-bold'>
{t("home.downloads.tvseries")}
</Text>
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
<Text className='text-xs font-bold'>
{groupedBySeries?.length}
</Text>
</View>
</View>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View className="px-4 flex flex-row">
<View className='px-4 flex flex-row'>
{groupedBySeries?.map((items) => (
<View
className="mb-2 last:mb-0"
className='mb-2 last:mb-0'
key={items[0].item.SeriesId}
>
<SeriesCard
@@ -191,8 +207,10 @@ export default function page() {
</View>
)}
{downloadedFiles?.length === 0 && (
<View className="flex px-4">
<Text className="opacity-50">{t("home.downloads.no_downloaded_items")}</Text>
<View className='flex px-4'>
<Text className='opacity-50'>
{t("home.downloads.no_downloaded_items")}
</Text>
</View>
)}
</View>
@@ -215,14 +233,14 @@ export default function page() {
)}
>
<BottomSheetView>
<View className="p-4 space-y-4 mb-4">
<Button color="purple" onPress={deleteMovies}>
<View className='p-4 space-y-4 mb-4'>
<Button color='purple' onPress={deleteMovies}>
{t("home.downloads.delete_all_movies_button")}
</Button>
<Button color="purple" onPress={deleteShows}>
<Button color='purple' onPress={deleteShows}>
{t("home.downloads.delete_all_tvseries_button")}
</Button>
<Button color="red" onPress={deleteAllMedia}>
<Button color='red' onPress={deleteAllMedia}>
{t("home.downloads.delete_all_button")}
</Button>
</View>
@@ -248,6 +266,6 @@ function migration_20241124() {
style: "destructive",
onPress: async () => await deleteAllFiles(),
},
]
],
);
}

View File

@@ -15,26 +15,26 @@ export default function page() {
useFocusEffect(
useCallback(() => {
storage.set("hasShownIntro", true);
}, [])
}, []),
);
return (
<View className="bg-neutral-900 h-full py-16 px-4 space-y-8">
<View className='bg-neutral-900 h-full py-16 px-4 space-y-8'>
<View>
<Text className="text-3xl font-bold text-center mb-2">
<Text className='text-3xl font-bold text-center mb-2'>
{t("home.intro.welcome_to_streamyfin")}
</Text>
<Text className="text-center">
<Text className='text-center'>
{t("home.intro.a_free_and_open_source_client_for_jellyfin")}
</Text>
</View>
<View>
<Text className="text-lg font-bold">
<Text className='text-lg font-bold'>
{t("home.intro.features_title")}
</Text>
<Text className="text-xs">{t("home.intro.features_description")}</Text>
<View className="flex flex-row items-center mt-4">
<Text className='text-xs'>{t("home.intro.features_description")}</Text>
<View className='flex flex-row items-center mt-4'>
<Image
source={require("@/assets/icons/jellyseerr-logo.svg")}
style={{
@@ -42,70 +42,70 @@ export default function page() {
height: 50,
}}
/>
<View className="shrink ml-2">
<Text className="font-bold mb-1">Jellyseerr</Text>
<Text className="shrink text-xs">
<View className='shrink ml-2'>
<Text className='font-bold mb-1'>Jellyseerr</Text>
<Text className='shrink text-xs'>
{t("home.intro.jellyseerr_feature_description")}
</Text>
</View>
</View>
<View className="flex flex-row items-center mt-4">
<View className='flex flex-row items-center mt-4'>
<View
style={{
width: 50,
height: 50,
}}
className="flex items-center justify-center"
className='flex items-center justify-center'
>
<Ionicons name="cloud-download-outline" size={32} color="white" />
<Ionicons name='cloud-download-outline' size={32} color='white' />
</View>
<View className="shrink ml-2">
<Text className="font-bold mb-1">
<View className='shrink ml-2'>
<Text className='font-bold mb-1'>
{t("home.intro.downloads_feature_title")}
</Text>
<Text className="shrink text-xs">
<Text className='shrink text-xs'>
{t("home.intro.downloads_feature_description")}
</Text>
</View>
</View>
<View className="flex flex-row items-center mt-4">
<View className='flex flex-row items-center mt-4'>
<View
style={{
width: 50,
height: 50,
}}
className="flex items-center justify-center"
className='flex items-center justify-center'
>
<Feather name="cast" size={28} color={"white"} />
<Feather name='cast' size={28} color={"white"} />
</View>
<View className="shrink ml-2">
<Text className="font-bold mb-1">Chromecast</Text>
<Text className="shrink text-xs">
<View className='shrink ml-2'>
<Text className='font-bold mb-1'>Chromecast</Text>
<Text className='shrink text-xs'>
{t("home.intro.chromecast_feature_description")}
</Text>
</View>
</View>
<View className="flex flex-row items-center mt-4">
<View className='flex flex-row items-center mt-4'>
<View
style={{
width: 50,
height: 50,
}}
className="flex items-center justify-center"
className='flex items-center justify-center'
>
<Feather name="settings" size={28} color={"white"} />
<Feather name='settings' size={28} color={"white"} />
</View>
<View className="shrink ml-2">
<Text className="font-bold mb-1">
<View className='shrink ml-2'>
<Text className='font-bold mb-1'>
{t("home.intro.centralised_settings_plugin_title")}
</Text>
<Text className="shrink text-xs">
<Text className='shrink text-xs'>
{t("home.intro.centralised_settings_plugin_description")}{" "}
<Text
className="text-purple-600"
className='text-purple-600'
onPress={() => {
Linking.openURL(
"https://github.com/streamyfin/jellyfin-plugin-streamyfin"
"https://github.com/streamyfin/jellyfin-plugin-streamyfin",
);
}}
>
@@ -120,7 +120,7 @@ export default function page() {
onPress={() => {
router.back();
}}
className="mt-4"
className='mt-4'
>
{t("home.intro.done_button")}
</Button>
@@ -129,9 +129,9 @@ export default function page() {
router.back();
router.push("/settings");
}}
className="mt-4"
className='mt-4'
>
<Text className="text-purple-600 text-center">
<Text className='text-purple-600 text-center'>
{t("home.intro.go_to_settings_button")}
</Text>
</TouchableOpacity>

View File

@@ -1,21 +1,29 @@
import { Badge } from "@/components/Badge";
import { Loader } from "@/components/Loader";
import { Text } from "@/components/common/Text";
import { useSessions, useSessionsProps } from "@/hooks/useSessions";
import Poster from "@/components/posters/Poster";
import { useInterval } from "@/hooks/useInterval";
import { useSessions, type useSessionsProps } from "@/hooks/useSessions";
import { apiAtom } from "@/providers/JellyfinProvider";
import { formatBitrate } from "@/utils/bitrate";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { formatTimeString } from "@/utils/time";
import {
AntDesign,
Entypo,
Ionicons,
MaterialCommunityIcons,
} from "@expo/vector-icons";
import {
HardwareAccelerationType,
type SessionInfoDto,
} from "@jellyfin/sdk/lib/generated-client";
import { FlashList } from "@shopify/flash-list";
import { useQuery } from "@tanstack/react-query";
import { useAtomValue } from "jotai";
import React, { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { Loader } from "@/components/Loader";
import { HardwareAccelerationType, SessionInfoDto } from "@jellyfin/sdk/lib/generated-client";
import { useAtomValue } from "jotai";
import { apiAtom } from "@/providers/JellyfinProvider";
import Poster from "@/components/posters/Poster";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { useInterval } from "@/hooks/useInterval";
import React, { useEffect, useMemo, useState } from "react";
import { formatTimeString } from "@/utils/time";
import { formatBitrate } from "@/utils/bitrate";
import { Ionicons, Entypo, AntDesign, MaterialCommunityIcons } from "@expo/vector-icons";
import { Badge } from "@/components/Badge";
import { useQuery } from "@tanstack/react-query";
export default function page() {
const { sessions, isLoading } = useSessions({} as useSessionsProps);
@@ -23,21 +31,23 @@ export default function page() {
if (isLoading)
return (
<View className="justify-center items-center h-full">
<View className='justify-center items-center h-full'>
<Loader />
</View>
);
if (!sessions || sessions.length == 0)
return (
<View className="h-full w-full flex justify-center items-center">
<Text className="text-lg text-neutral-500">{t("home.sessions.no_active_sessions")}</Text>
<View className='h-full w-full flex justify-center items-center'>
<Text className='text-lg text-neutral-500'>
{t("home.sessions.no_active_sessions")}
</Text>
</View>
);
return (
<FlashList
contentInsetAdjustmentBehavior="automatic"
contentInsetAdjustmentBehavior='automatic'
contentContainerStyle={{
paddingTop: 17,
paddingHorizontal: 17,
@@ -70,14 +80,20 @@ const SessionCard = ({ session }: SessionCardProps) => {
}
return Math.round(
(100 / session.NowPlayingItem?.RunTimeTicks) * (session.NowPlayingItem?.RunTimeTicks - remainingTicks)
(100 / session.NowPlayingItem?.RunTimeTicks) *
(session.NowPlayingItem?.RunTimeTicks - remainingTicks),
);
};
useEffect(() => {
const currentTime = session.PlayState?.PositionTicks;
const duration = session.NowPlayingItem?.RunTimeTicks;
if (duration !== null && duration !== undefined && currentTime !== null && currentTime !== undefined) {
if (
duration !== null &&
duration !== undefined &&
currentTime !== null &&
currentTime !== undefined
) {
const remainingTimeTicks = duration - currentTime;
setRemainingTicks(remainingTimeTicks);
}
@@ -85,9 +101,11 @@ const SessionCard = ({ session }: SessionCardProps) => {
const { data: ipInfo } = useQuery({
queryKey: ["ipinfo", session.RemoteEndPoint],
cacheTime: Infinity,
cacheTime: Number.POSITIVE_INFINITY,
queryFn: async () => {
const resp = await api.axiosInstance.get(`https://freeipapi.com/api/json/${session.RemoteEndPoint}`);
const resp = await api.axiosInstance.get(
`https://freeipapi.com/api/json/${session.RemoteEndPoint}`,
);
return resp.data;
},
});
@@ -95,18 +113,23 @@ const SessionCard = ({ session }: SessionCardProps) => {
useInterval(tick, 1000);
return (
<View className="flex flex-col shadow-md bg-neutral-900 rounded-2xl mb-4">
<View className="flex flex-row p-4">
<View className="w-20 pr-4">
<Poster id={session.NowPlayingItem?.Id} url={getPrimaryImageUrl({ api, item: session.NowPlayingItem })} />
<View className='flex flex-col shadow-md bg-neutral-900 rounded-2xl mb-4'>
<View className='flex flex-row p-4'>
<View className='w-20 pr-4'>
<Poster
id={session.NowPlayingItem?.Id}
url={getPrimaryImageUrl({ api, item: session.NowPlayingItem })}
/>
</View>
<View className="w-full flex-1">
<View className="flex flex-row justify-between">
<View className="flex-1 pr-4">
<View className='w-full flex-1'>
<View className='flex flex-row justify-between'>
<View className='flex-1 pr-4'>
{session.NowPlayingItem?.Type === "Episode" ? (
<>
<Text className="font-bold">{session.NowPlayingItem?.Name}</Text>
<Text numberOfLines={1} className="text-xs opacity-50">
<Text className='font-bold'>
{session.NowPlayingItem?.Name}
</Text>
<Text numberOfLines={1} className='text-xs opacity-50'>
{`S${session.NowPlayingItem.ParentIndexNumber?.toString()}:E${session.NowPlayingItem.IndexNumber?.toString()}`}
{" - "}
{session.NowPlayingItem.SeriesName}
@@ -114,13 +137,19 @@ const SessionCard = ({ session }: SessionCardProps) => {
</>
) : (
<>
<Text className="font-bold">{session.NowPlayingItem?.Name}</Text>
<Text className="text-xs opacity-50">{session.NowPlayingItem?.ProductionYear}</Text>
<Text className="text-xs opacity-50">{session.NowPlayingItem?.SeriesName}</Text>
<Text className='font-bold'>
{session.NowPlayingItem?.Name}
</Text>
<Text className='text-xs opacity-50'>
{session.NowPlayingItem?.ProductionYear}
</Text>
<Text className='text-xs opacity-50'>
{session.NowPlayingItem?.SeriesName}
</Text>
</>
)}
</View>
<Text className="text-xs opacity-50 align-right text-right">
<Text className='text-xs opacity-50 align-right text-right'>
{session.UserName}
{"\n"}
{session.Client}
@@ -130,21 +159,21 @@ const SessionCard = ({ session }: SessionCardProps) => {
{ipInfo?.cityName} {ipInfo?.countryCode}
</Text>
</View>
<View className="flex-1" />
<View className="flex flex-col align-bottom">
<View className="flex flex-row justify-between align-bottom mb-1">
<Text className="-ml-0.5 text-xs opacity-50 align-left text-left">
<View className='flex-1' />
<View className='flex flex-col align-bottom'>
<View className='flex flex-row justify-between align-bottom mb-1'>
<Text className='-ml-0.5 text-xs opacity-50 align-left text-left'>
{!session.PlayState?.IsPaused ? (
<Ionicons name="play" size={14} color="white" />
<Ionicons name='play' size={14} color='white' />
) : (
<Ionicons name="pause" size={14} color="white" />
<Ionicons name='pause' size={14} color='white' />
)}
</Text>
<Text className="text-xs opacity-50 align-right text-right">
<Text className='text-xs opacity-50 align-right text-right'>
{formatTimeString(remainingTicks, "tick")} left
</Text>
</View>
<View className="align-bottom bg-gray-800 h-1">
<View className='align-bottom bg-gray-800 h-1'>
<View
className={`bg-purple-600 h-full`}
style={{
@@ -166,17 +195,23 @@ interface TranscodingBadgesProps {
const TranscodingBadges = ({ properties }: TranscodingBadgesProps) => {
const iconMap = {
bitrate: <Ionicons name="speedometer-outline" size={12} color="white" />,
codec: <Ionicons name="layers-outline" size={12} color="white" />,
videoRange: <Ionicons name="color-palette-outline" size={12} color="white" />,
resolution: <Ionicons name="film-outline" size={12} color="white" />,
language: <Ionicons name="language-outline" size={12} color="white" />,
audioChannels: <Ionicons name="mic-outline" size={12} color="white" />,
hwType: <Ionicons name="hardware-chip-outline" size={12} color="white" />,
bitrate: <Ionicons name='speedometer-outline' size={12} color='white' />,
codec: <Ionicons name='layers-outline' size={12} color='white' />,
videoRange: (
<Ionicons name='color-palette-outline' size={12} color='white' />
),
resolution: <Ionicons name='film-outline' size={12} color='white' />,
language: <Ionicons name='language-outline' size={12} color='white' />,
audioChannels: <Ionicons name='mic-outline' size={12} color='white' />,
hwType: <Ionicons name='hardware-chip-outline' size={12} color='white' />,
} as const;
const icon = (val: string) => {
return iconMap[val as keyof typeof iconMap] ?? <Ionicons name="layers-outline" size={12} color="white" />;
return (
iconMap[val as keyof typeof iconMap] ?? (
<Ionicons name='layers-outline' size={12} color='white' />
)
);
};
const formatVal = (key: string, val: any) => {
@@ -195,8 +230,8 @@ const TranscodingBadges = ({ properties }: TranscodingBadgesProps) => {
.map(([key]) => (
<Badge
key={key}
variant="gray"
className="m-0 p-0 pt-0.5 mr-1"
variant='gray'
className='m-0 p-0 pt-0.5 mr-1'
text={formatVal(key, properties[key as keyof StreamProps])}
iconLeft={icon(key)}
/>
@@ -216,7 +251,7 @@ interface StreamProps {
interface TranscodingStreamViewProps {
title: string | undefined;
value?: string;
isTranscoding: Boolean;
isTranscoding: boolean;
transcodeValue?: string | undefined | null;
properties: StreamProps;
transcodeProperties?: StreamProps;
@@ -231,20 +266,26 @@ const TranscodingStreamView = ({
transcodeValue,
}: TranscodingStreamViewProps) => {
return (
<View className="flex flex-col pt-2 first:pt-0">
<View className="flex flex-row">
<Text className="text-xs opacity-50 w-20 font-bold text-right pr-4">{title}</Text>
<Text className="flex-1">
<View className='flex flex-col pt-2 first:pt-0'>
<View className='flex flex-row'>
<Text className='text-xs opacity-50 w-20 font-bold text-right pr-4'>
{title}
</Text>
<Text className='flex-1'>
<TranscodingBadges properties={properties} />
</Text>
</View>
{isTranscoding && transcodeProperties ? (
<>
<View className="flex flex-row">
<Text className="-mt-0 text-xs opacity-50 w-20 font-bold text-right pr-4">
<MaterialCommunityIcons name="arrow-right-bottom" size={14} color="white" />
<View className='flex flex-row'>
<Text className='-mt-0 text-xs opacity-50 w-20 font-bold text-right pr-4'>
<MaterialCommunityIcons
name='arrow-right-bottom'
size={14}
color='white'
/>
</Text>
<Text className="flex-1 text-sm mt-1">
<Text className='flex-1 text-sm mt-1'>
<TranscodingBadges properties={transcodeProperties} />
</Text>
</View>
@@ -256,21 +297,29 @@ const TranscodingStreamView = ({
const TranscodingView = ({ session }: SessionCardProps) => {
const videoStream = useMemo(() => {
return session.NowPlayingItem?.MediaStreams?.filter((s) => s.Type == "Video")[0];
return session.NowPlayingItem?.MediaStreams?.filter(
(s) => s.Type == "Video",
)[0];
}, [session]);
const audioStream = useMemo(() => {
const index = session.PlayState?.AudioStreamIndex;
return index !== null && index !== undefined ? session.NowPlayingItem?.MediaStreams?.[index] : undefined;
return index !== null && index !== undefined
? session.NowPlayingItem?.MediaStreams?.[index]
: undefined;
}, [session.PlayState?.AudioStreamIndex]);
const subtitleStream = useMemo(() => {
const index = session.PlayState?.SubtitleStreamIndex;
return index !== null && index !== undefined ? session.NowPlayingItem?.MediaStreams?.[index] : undefined;
return index !== null && index !== undefined
? session.NowPlayingItem?.MediaStreams?.[index]
: undefined;
}, [session.PlayState?.SubtitleStreamIndex]);
const isTranscoding = useMemo(() => {
return session.PlayState?.PlayMethod == "Transcode" && session.TranscodingInfo;
return (
session.PlayState?.PlayMethod == "Transcode" && session.TranscodingInfo
);
}, [session.PlayState?.PlayMethod, session.TranscodingInfo]);
const videoStreamTitle = () => {
@@ -278,9 +327,9 @@ const TranscodingView = ({ session }: SessionCardProps) => {
};
return (
<View className="flex flex-col bg-neutral-800 rounded-b-2xl p-4 pt-2">
<View className='flex flex-col bg-neutral-800 rounded-b-2xl p-4 pt-2'>
<TranscodingStreamView
title="Video"
title='Video'
properties={{
resolution: videoStreamTitle(),
bitrate: videoStream?.BitRate,
@@ -291,11 +340,15 @@ const TranscodingView = ({ session }: SessionCardProps) => {
bitrate: session.TranscodingInfo?.Bitrate,
codec: session.TranscodingInfo?.VideoCodec,
}}
isTranscoding={isTranscoding && !session.TranscodingInfo?.IsVideoDirect ? true : false}
isTranscoding={
isTranscoding && !session.TranscodingInfo?.IsVideoDirect
? true
: false
}
/>
<TranscodingStreamView
title="Audio"
title='Audio'
properties={{
language: audioStream?.Language,
bitrate: audioStream?.BitRate,
@@ -306,13 +359,17 @@ const TranscodingView = ({ session }: SessionCardProps) => {
codec: session.TranscodingInfo?.AudioCodec,
audioChannels: session.TranscodingInfo?.AudioChannels?.toString(),
}}
isTranscoding={isTranscoding && !session.TranscodingInfo?.IsVideoDirect ? true : false}
isTranscoding={
isTranscoding && !session.TranscodingInfo?.IsVideoDirect
? true
: false
}
/>
{subtitleStream && (
<>
<TranscodingStreamView
title="Subtitle"
title='Subtitle'
isTranscoding={false}
properties={{
language: subtitleStream?.Language,

View File

@@ -3,6 +3,7 @@ import { ListGroup } from "@/components/list/ListGroup";
import { ListItem } from "@/components/list/ListItem";
import { AppLanguageSelector } from "@/components/settings/AppLanguageSelector";
import { AudioToggles } from "@/components/settings/AudioToggles";
import { ChromecastSettings } from "@/components/settings/ChromecastSettings";
import DownloadSettings from "@/components/settings/DownloadSettings";
import { MediaProvider } from "@/components/settings/MediaContext";
import { MediaToggles } from "@/components/settings/MediaToggles";
@@ -14,16 +15,15 @@ import { SubtitleToggles } from "@/components/settings/SubtitleToggles";
import { UserInfo } from "@/components/settings/UserInfo";
import { useHaptic } from "@/hooks/useHaptic";
import { useJellyfin } from "@/providers/JellyfinProvider";
import { userAtom } from "@/providers/JellyfinProvider";
import { clearLogs } from "@/utils/log";
import { storage } from "@/utils/mmkv";
import { useNavigation, useRouter } from "expo-router";
import { t } from "i18next";
import { useAtom } from "jotai";
import React, { useEffect } from "react";
import { ScrollView, Switch, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useAtom } from "jotai";
import { userAtom } from "@/providers/JellyfinProvider";
import { ChromecastSettings } from "@/components/settings/ChromecastSettings";
export default function settings() {
const router = useRouter();
@@ -46,7 +46,7 @@ export default function settings() {
logout();
}}
>
<Text className="text-red-600">
<Text className='text-red-600'>
{t("home.settings.log_out_button")}
</Text>
</TouchableOpacity>
@@ -61,15 +61,15 @@ export default function settings() {
paddingRight: insets.right,
}}
>
<View className="p-4 flex flex-col gap-y-4">
<View className='p-4 flex flex-col gap-y-4'>
<UserInfo />
<QuickConnect className="mb-4" />
<QuickConnect className='mb-4' />
<MediaProvider>
<MediaToggles className="mb-4" />
<AudioToggles className="mb-4" />
<SubtitleToggles className="mb-4" />
<MediaToggles className='mb-4' />
<AudioToggles className='mb-4' />
<SubtitleToggles className='mb-4' />
</MediaProvider>
<OtherSettings />
@@ -90,7 +90,7 @@ export default function settings() {
title={t("home.settings.intro.show_intro")}
/>
<ListItem
textColor="red"
textColor='red'
onPress={() => {
storage.set("hasShownIntro", false);
}}
@@ -98,7 +98,7 @@ export default function settings() {
/>
</ListGroup>
<View className="mb-4">
<View className='mb-4'>
<ListGroup title={t("home.settings.logs.logs_title")}>
<ListItem
onPress={() => router.push("/settings/logs/page")}
@@ -106,7 +106,7 @@ export default function settings() {
title={t("home.settings.logs.logs_title")}
/>
<ListItem
textColor="red"
textColor='red'
onPress={onClearLogsClicked}
title={t("home.settings.logs.delete_all_logs")}
/>

View File

@@ -1,15 +1,15 @@
import { Loader } from "@/components/Loader";
import { Text } from "@/components/common/Text";
import { ListGroup } from "@/components/list/ListGroup";
import { ListItem } from "@/components/list/ListItem";
import { Loader } from "@/components/Loader";
import DisabledSetting from "@/components/settings/DisabledSetting";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getUserViewsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { useAtomValue } from "jotai";
import { useTranslation } from "react-i18next";
import { Switch, View } from "react-native";
import { useTranslation } from "react-i18next";
import DisabledSetting from "@/components/settings/DisabledSetting";
export default function page() {
const [settings, updateSettings, pluginSettings] = useSettings();
@@ -18,7 +18,7 @@ export default function page() {
const { t } = useTranslation();
const { data, isLoading: isLoading } = useQuery({
const { data, isLoading } = useQuery({
queryKey: ["user-views", user?.Id],
queryFn: async () => {
const response = await getUserViewsApi(api!).getUserViews({
@@ -33,7 +33,7 @@ export default function page() {
if (isLoading)
return (
<View className="mt-4">
<View className='mt-4'>
<Loader />
</View>
);
@@ -41,7 +41,7 @@ export default function page() {
return (
<DisabledSetting
disabled={pluginSettings?.hiddenLibraries?.locked === true}
className="px-4"
className='px-4'
>
<ListGroup>
{data?.map((view) => (
@@ -59,8 +59,8 @@ export default function page() {
</ListItem>
))}
</ListGroup>
<Text className="px-4 text-xs text-neutral-500 mt-1">
{t("home.settings.other.select_liraries_you_want_to_hide")}
<Text className='px-4 text-xs text-neutral-500 mt-1'>
{t("home.settings.other.select_liraries_you_want_to_hide")}
</Text>
</DisabledSetting>
);

View File

@@ -1,6 +1,6 @@
import DisabledSetting from "@/components/settings/DisabledSetting";
import { JellyseerrSettings } from "@/components/settings/Jellyseerr";
import { useSettings } from "@/utils/atoms/settings";
import DisabledSetting from "@/components/settings/DisabledSetting";
export default function page() {
const [settings, updateSettings, pluginSettings] = useSettings();
@@ -8,7 +8,7 @@ export default function page() {
return (
<DisabledSetting
disabled={pluginSettings?.jellyseerrServerUrl?.locked === true}
className="p-4"
className='p-4'
>
<JellyseerrSettings />
</DisabledSetting>

View File

@@ -1,17 +1,17 @@
import { Text } from "@/components/common/Text";
import { useLog } from "@/utils/log";
import { ScrollView, View } from "react-native";
import { useTranslation } from "react-i18next";
import { ScrollView, View } from "react-native";
export default function page() {
const { logs } = useLog();
const { t } = useTranslation();
return (
<ScrollView className="p-4">
<View className="flex flex-col space-y-2">
<ScrollView className='p-4'>
<View className='flex flex-col space-y-2'>
{logs?.map((log, index) => (
<View key={index} className="bg-neutral-900 rounded-xl p-3">
<View key={index} className='bg-neutral-900 rounded-xl p-3'>
<Text
className={`
mb-1
@@ -21,13 +21,15 @@ export default function page() {
>
{log.level}
</Text>
<Text uiTextView selectable className="text-xs">
<Text uiTextView selectable className='text-xs'>
{log.message}
</Text>
</View>
))}
{logs?.length === 0 && (
<Text className="opacity-50">{t("home.settings.logs.no_logs_available")}</Text>
<Text className='opacity-50'>
{t("home.settings.logs.no_logs_available")}
</Text>
)}
</View>
</ScrollView>

View File

@@ -6,7 +6,8 @@ import { useQueryClient } from "@tanstack/react-query";
import { useNavigation } from "expo-router";
import { useTranslation } from "react-i18next";
import React, {useEffect, useMemo, useState} from "react";
import DisabledSetting from "@/components/settings/DisabledSetting";
import React, { useEffect, useMemo, useState } from "react";
import {
Linking,
Switch,
@@ -15,7 +16,6 @@ import {
View,
} from "react-native";
import { toast } from "sonner-native";
import DisabledSetting from "@/components/settings/DisabledSetting";
export default function page() {
const navigation = useNavigation();
@@ -39,7 +39,10 @@ export default function page() {
};
const disabled = useMemo(() => {
return pluginSettings?.searchEngine?.locked === true && pluginSettings?.marlinServerUrl?.locked === true
return (
pluginSettings?.searchEngine?.locked === true &&
pluginSettings?.marlinServerUrl?.locked === true
);
}, [pluginSettings]);
useEffect(() => {
@@ -47,7 +50,9 @@ export default function page() {
navigation.setOptions({
headerRight: () => (
<TouchableOpacity onPress={() => onSave(value)}>
<Text className="text-blue-500">{t("home.settings.plugins.marlin_search.save_button")}</Text>
<Text className='text-blue-500'>
{t("home.settings.plugins.marlin_search.save_button")}
</Text>
</TouchableOpacity>
),
});
@@ -57,17 +62,16 @@ export default function page() {
if (!settings) return null;
return (
<DisabledSetting
disabled={disabled}
className="px-4"
>
<DisabledSetting disabled={disabled} className='px-4'>
<ListGroup>
<DisabledSetting
disabled={pluginSettings?.searchEngine?.locked === true}
showText={!pluginSettings?.marlinServerUrl?.locked}
>
<ListItem
title={t("home.settings.plugins.marlin_search.enable_marlin_search")}
title={t(
"home.settings.plugins.marlin_search.enable_marlin_search",
)}
onPress={() => {
updateSettings({ searchEngine: "Jellyfin" });
queryClient.invalidateQueries({ queryKey: ["search"] });
@@ -87,28 +91,30 @@ export default function page() {
<DisabledSetting
disabled={pluginSettings?.marlinServerUrl?.locked === true}
showText={!pluginSettings?.searchEngine?.locked}
className="mt-2 flex flex-col rounded-xl overflow-hidden pl-4 bg-neutral-900 px-4"
className='mt-2 flex flex-col rounded-xl overflow-hidden pl-4 bg-neutral-900 px-4'
>
<View
className={`flex flex-row items-center bg-neutral-900 h-11 pr-4`}
>
<Text className="mr-4">{t("home.settings.plugins.marlin_search.url")}</Text>
<View className={`flex flex-row items-center bg-neutral-900 h-11 pr-4`}>
<Text className='mr-4'>
{t("home.settings.plugins.marlin_search.url")}
</Text>
<TextInput
editable={settings.searchEngine === "Marlin"}
className="text-white"
placeholder={t("home.settings.plugins.marlin_search.server_url_placeholder")}
className='text-white'
placeholder={t(
"home.settings.plugins.marlin_search.server_url_placeholder",
)}
value={value}
keyboardType="url"
returnKeyType="done"
autoCapitalize="none"
textContentType="URL"
keyboardType='url'
returnKeyType='done'
autoCapitalize='none'
textContentType='URL'
onChangeText={(text) => setValue(text)}
/>
</View>
</DisabledSetting>
<Text className="px-4 text-xs text-neutral-500 mt-1">
<Text className='px-4 text-xs text-neutral-500 mt-1'>
{t("home.settings.plugins.marlin_search.marlin_search_hint")}{" "}
<Text className="text-blue-500" onPress={handleOpenLink}>
<Text className='text-blue-500' onPress={handleOpenLink}>
{t("home.settings.plugins.marlin_search.read_more_about_marlin")}
</Text>
</Text>

View File

@@ -1,4 +1,5 @@
import { Text } from "@/components/common/Text";
import DisabledSetting from "@/components/settings/DisabledSetting";
import { OptimizedServerForm } from "@/components/settings/OptimizedServerForm";
import { apiAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
@@ -8,10 +9,9 @@ import { useMutation } from "@tanstack/react-query";
import { useNavigation } from "expo-router";
import { useAtom } from "jotai";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
import { toast } from "sonner-native";
import { useTranslation } from "react-i18next";
import DisabledSetting from "@/components/settings/DisabledSetting";
export default function page() {
const navigation = useNavigation();
@@ -67,8 +67,12 @@ export default function page() {
saveMutation.isPending ? (
<ActivityIndicator size={"small"} color={"white"} />
) : (
<TouchableOpacity onPress={() => onSave(optimizedVersionsServerUrl)}>
<Text className="text-blue-500">{t("home.settings.downloads.save_button")}</Text>
<TouchableOpacity
onPress={() => onSave(optimizedVersionsServerUrl)}
>
<Text className='text-blue-500'>
{t("home.settings.downloads.save_button")}
</Text>
</TouchableOpacity>
),
});
@@ -78,7 +82,7 @@ export default function page() {
return (
<DisabledSetting
disabled={pluginSettings?.optimizedVersionsServerUrl?.locked === true}
className="p-4"
className='p-4'
>
<OptimizedServerForm
value={optimizedVersionsServerUrl}

View File

@@ -10,15 +10,15 @@ import MoviePoster from "@/components/posters/MoviePoster";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import { BaseItemDtoQueryResult } from "@jellyfin/sdk/lib/generated-client/models";
import type { BaseItemDtoQueryResult } from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useLocalSearchParams } from "expo-router";
import { useAtom } from "jotai";
import { useCallback, useMemo } from "react";
import { View } from "react-native";
import { useTranslation } from "react-i18next";
import { View } from "react-native";
const page: React.FC = () => {
const local = useLocalSearchParams();
@@ -68,7 +68,7 @@ const page: React.FC = () => {
return response.data;
},
[api, user?.Id, actorId]
[api, user?.Id, actorId],
);
const backdropUrl = useMemo(
@@ -79,12 +79,12 @@ const page: React.FC = () => {
quality: 90,
width: 1000,
}),
[item]
[item],
);
if (l1)
return (
<View className="justify-center items-center h-full">
<View className='justify-center items-center h-full'>
<Loader />
</View>
);
@@ -105,13 +105,13 @@ const page: React.FC = () => {
/>
}
>
<View className="flex flex-col space-y-4 my-4">
<View className="px-4 mb-4">
<MoviesTitleHeader item={item} className="mb-4" />
<View className='flex flex-col space-y-4 my-4'>
<View className='px-4 mb-4'>
<MoviesTitleHeader item={item} className='mb-4' />
<OverviewText text={item.Overview} />
</View>
<Text className="px-4 text-2xl font-bold mb-2 text-neutral-100">
<Text className='px-4 text-2xl font-bold mb-2 text-neutral-100'>
{t("item_card.appeared_in")}
</Text>
<InfiniteHorizontalScroll
@@ -133,7 +133,7 @@ const page: React.FC = () => {
queryFn={fetchItems}
queryKey={["actor", "movies", actorId]}
/>
<View className="h-12"></View>
<View className='h-12'></View>
</View>
</ParallaxScrollView>
);

View File

@@ -1,22 +1,23 @@
import { ItemCardText } from "@/components/ItemCardText";
import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import { FilterButton } from "@/components/filters/FilterButton";
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
import { ItemCardText } from "@/components/ItemCardText";
import { ItemPoster } from "@/components/posters/ItemPoster";
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import {
SortByOption,
SortOrderOption,
genreFilterAtom,
sortByAtom,
SortByOption,
sortOptions,
sortOrderAtom,
SortOrderOption,
sortOrderOptions,
tagsFilterAtom,
yearFilterAtom,
} from "@/utils/atoms/filters";
import {
import type {
BaseItemDto,
BaseItemDtoQueryResult,
ItemSortBy,
@@ -29,11 +30,11 @@ import {
import { FlashList } from "@shopify/flash-list";
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { useLocalSearchParams, useNavigation } from "expo-router";
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { useAtom } from "jotai";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { FlatList, View } from "react-native";
import type React from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { FlatList, View } from "react-native";
const page: React.FC = () => {
const searchParams = useLocalSearchParams();
@@ -43,7 +44,7 @@ const page: React.FC = () => {
const [user] = useAtom(userAtom);
const navigation = useNavigation();
const [orientation, setOrientation] = useState(
ScreenOrientation.Orientation.PORTRAIT_UP
ScreenOrientation.Orientation.PORTRAIT_UP,
);
const { t } = useTranslation();
@@ -111,7 +112,7 @@ const page: React.FC = () => {
recursive: true,
genres: selectedGenres,
tags: selectedTags,
years: selectedYears.map((year) => parseInt(year)),
years: selectedYears.map((year) => Number.parseInt(year)),
includeItemTypes: ["Movie", "Series"],
});
@@ -126,7 +127,7 @@ const page: React.FC = () => {
selectedTags,
sortBy,
sortOrder,
]
],
);
const { data, isFetching, fetchNextPage, hasNextPage } = useInfiniteQuery({
@@ -151,7 +152,7 @@ const page: React.FC = () => {
const totalItems = lastPage.TotalRecordCount;
const accumulatedItems = pages.reduce(
(acc, curr) => acc + (curr?.Items?.length || 0),
0
0,
);
if (accumulatedItems < totalItems) {
@@ -188,8 +189,8 @@ const page: React.FC = () => {
index % 3 === 0
? "flex-end"
: (index + 1) % 3 === 0
? "flex-start"
: "center",
? "flex-start"
: "center",
width: "89%",
}}
>
@@ -199,14 +200,14 @@ const page: React.FC = () => {
</View>
</TouchableItemRouter>
),
[orientation]
[orientation],
);
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
const ListHeaderComponent = useCallback(
() => (
<View className="">
<View className=''>
<FlatList
horizontal
showsHorizontalScrollIndicator={false}
@@ -232,13 +233,13 @@ const page: React.FC = () => {
key: "genre",
component: (
<FilterButton
className="mr-1"
className='mr-1'
collectionId={collectionId}
queryKey="genreFilter"
queryKey='genreFilter'
queryFn={async () => {
if (!api) return null;
const response = await getFilterApi(
api
api,
).getQueryFiltersLegacy({
userId: user?.Id,
parentId: collectionId,
@@ -259,13 +260,13 @@ const page: React.FC = () => {
key: "year",
component: (
<FilterButton
className="mr-1"
className='mr-1'
collectionId={collectionId}
queryKey="yearFilter"
queryKey='yearFilter'
queryFn={async () => {
if (!api) return null;
const response = await getFilterApi(
api
api,
).getQueryFiltersLegacy({
userId: user?.Id,
parentId: collectionId,
@@ -284,13 +285,13 @@ const page: React.FC = () => {
key: "tags",
component: (
<FilterButton
className="mr-1"
className='mr-1'
collectionId={collectionId}
queryKey="tagsFilter"
queryKey='tagsFilter'
queryFn={async () => {
if (!api) return null;
const response = await getFilterApi(
api
api,
).getQueryFiltersLegacy({
userId: user?.Id,
parentId: collectionId,
@@ -311,9 +312,9 @@ const page: React.FC = () => {
key: "sortBy",
component: (
<FilterButton
className="mr-1"
className='mr-1'
collectionId={collectionId}
queryKey="sortBy"
queryKey='sortBy'
queryFn={async () => sortOptions.map((s) => s.key)}
set={setSortBy}
values={sortBy}
@@ -331,9 +332,9 @@ const page: React.FC = () => {
key: "sortOrder",
component: (
<FilterButton
className="mr-1"
className='mr-1'
collectionId={collectionId}
queryKey="sortOrder"
queryKey='sortOrder'
queryFn={async () => sortOrderOptions.map((s) => s.key)}
set={setSortOrder}
values={sortOrder}
@@ -368,7 +369,7 @@ const page: React.FC = () => {
sortOrder,
setSortOrder,
isFetching,
]
],
);
if (!collection) return null;
@@ -376,8 +377,10 @@ const page: React.FC = () => {
return (
<FlashList
ListEmptyComponent={
<View className="flex flex-col items-center justify-center h-full">
<Text className="font-bold text-xl text-neutral-500">{t("search.no_results")}</Text>
<View className='flex flex-col items-center justify-center h-full'>
<Text className='font-bold text-xl text-neutral-500'>
{t("search.no_results")}
</Text>
</View>
}
extraData={[
@@ -387,7 +390,7 @@ const page: React.FC = () => {
sortBy,
sortOrder,
]}
contentInsetAdjustmentBehavior="automatic"
contentInsetAdjustmentBehavior='automatic'
data={flatData}
renderItem={renderItem}
keyExtractor={keyExtractor}

View File

@@ -1,11 +1,13 @@
import { Text } from "@/components/common/Text";
import { ItemContent } from "@/components/ItemContent";
import { Text } from "@/components/common/Text";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { useLocalSearchParams } from "expo-router";
import { useAtom } from "jotai";
import React, { useEffect } from "react";
import type React from "react";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import Animated, {
runOnJS,
@@ -13,7 +15,6 @@ import Animated, {
useSharedValue,
withTiming,
} from "react-native-reanimated";
import { useTranslation } from "react-i18next";
const Page: React.FC = () => {
const [api] = useAtom(apiAtom);
@@ -75,36 +76,36 @@ const Page: React.FC = () => {
if (isError)
return (
<View className="flex flex-col items-center justify-center h-screen w-screen">
<View className='flex flex-col items-center justify-center h-screen w-screen'>
<Text>{t("item_card.could_not_load_item")}</Text>
</View>
);
return (
<View className="flex flex-1 relative">
<View className='flex flex-1 relative'>
<Animated.View
pointerEvents={"none"}
style={[animatedStyle]}
className="absolute top-0 left-0 flex flex-col items-start h-screen w-screen px-4 z-50 bg-black"
className='absolute top-0 left-0 flex flex-col items-start h-screen w-screen px-4 z-50 bg-black'
>
<View
style={{
height: item?.Type === "Episode" ? 300 : 450,
}}
className="bg-transparent rounded-lg mb-4 w-full"
className='bg-transparent rounded-lg mb-4 w-full'
></View>
<View className="h-6 bg-neutral-900 rounded mb-4 w-14"></View>
<View className="h-10 bg-neutral-900 rounded-lg mb-2 w-1/2"></View>
<View className="h-3 bg-neutral-900 rounded mb-3 w-8"></View>
<View className="flex flex-row space-x-1 mb-8">
<View className="h-6 bg-neutral-900 rounded mb-3 w-14"></View>
<View className="h-6 bg-neutral-900 rounded mb-3 w-14"></View>
<View className="h-6 bg-neutral-900 rounded mb-3 w-14"></View>
<View className='h-6 bg-neutral-900 rounded mb-4 w-14'></View>
<View className='h-10 bg-neutral-900 rounded-lg mb-2 w-1/2'></View>
<View className='h-3 bg-neutral-900 rounded mb-3 w-8'></View>
<View className='flex flex-row space-x-1 mb-8'>
<View className='h-6 bg-neutral-900 rounded mb-3 w-14'></View>
<View className='h-6 bg-neutral-900 rounded mb-3 w-14'></View>
<View className='h-6 bg-neutral-900 rounded mb-3 w-14'></View>
</View>
<View className="h-3 bg-neutral-900 rounded w-2/3 mb-1"></View>
<View className="h-10 bg-neutral-900 rounded-lg w-full mb-2"></View>
<View className="h-12 bg-neutral-900 rounded-lg w-full mb-2"></View>
<View className="h-24 bg-neutral-900 rounded-lg mb-1 w-full"></View>
<View className='h-3 bg-neutral-900 rounded w-2/3 mb-1'></View>
<View className='h-10 bg-neutral-900 rounded-lg w-full mb-2'></View>
<View className='h-12 bg-neutral-900 rounded-lg w-full mb-2'></View>
<View className='h-24 bg-neutral-900 rounded-lg mb-1 w-full'></View>
</Animated.View>
{item && <ItemContent item={item} />}
</View>

View File

@@ -1,41 +1,43 @@
import {useLocalSearchParams} from "expo-router";
import React, {useMemo,} from "react";
import {useInfiniteQuery} from "@tanstack/react-query";
import {Endpoints, useJellyseerr} from "@/hooks/useJellyseerr";
import {Image} from "expo-image";
import {DiscoverSliderType} from "@/utils/jellyseerr/server/constants/discover";
import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow";
import {MovieResult, Results, TvResult} from "@/utils/jellyseerr/server/models/Search";
import {COMPANY_LOGO_IMAGE_FILTER} from "@/utils/jellyseerr/src/components/Discover/NetworkSlider";
import {uniqBy} from "lodash";
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
import { Endpoints, useJellyseerr } from "@/hooks/useJellyseerr";
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
import {
type MovieResult,
Results,
type TvResult,
} from "@/utils/jellyseerr/server/models/Search";
import { COMPANY_LOGO_IMAGE_FILTER } from "@/utils/jellyseerr/src/components/Discover/NetworkSlider";
import { useInfiniteQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useLocalSearchParams } from "expo-router";
import { uniqBy } from "lodash";
import React, { useMemo } from "react";
export default function page() {
const local = useLocalSearchParams();
const {jellyseerrApi} = useJellyseerr();
const { jellyseerrApi } = useJellyseerr();
const {companyId, name, image, type} = local as unknown as {
companyId: string,
name: string,
image: string,
type: DiscoverSliderType
const { companyId, name, image, type } = local as unknown as {
companyId: string;
name: string;
image: string;
type: DiscoverSliderType;
};
const {data, fetchNextPage, hasNextPage} = useInfiniteQuery({
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
queryKey: ["jellyseerr", "company", type, companyId],
queryFn: async ({pageParam}) => {
let params: any = {
queryFn: async ({ pageParam }) => {
const params: any = {
page: Number(pageParam),
};
return jellyseerrApi?.discover(
(
type == DiscoverSliderType.NETWORKS
? Endpoints.DISCOVER_TV_NETWORK
: Endpoints.DISCOVER_MOVIES_STUDIO
) + `/${companyId}`,
params
)
(type == DiscoverSliderType.NETWORKS
? Endpoints.DISCOVER_TV_NETWORK
: Endpoints.DISCOVER_MOVIES_STUDIO) + `/${companyId}`,
params,
);
},
enabled: !!jellyseerrApi && !!companyId,
initialPageParam: 1,
@@ -46,46 +48,58 @@ export default function page() {
});
const flatData = useMemo(
() => uniqBy(data?.pages?.filter((p) => p?.results.length).flatMap((p) => p?.results ?? []), "id")?? [],
[data]
() =>
uniqBy(
data?.pages
?.filter((p) => p?.results.length)
.flatMap((p) => p?.results ?? []),
"id",
) ?? [],
[data],
);
const backdrops = useMemo(
() => jellyseerrApi
? flatData.map((r) => jellyseerrApi.imageProxy((r as TvResult | MovieResult).backdropPath, "w1920_and_h800_multi_faces"))
: [],
[jellyseerrApi, flatData]
() =>
jellyseerrApi
? flatData.map((r) =>
jellyseerrApi.imageProxy(
(r as TvResult | MovieResult).backdropPath,
"w1920_and_h800_multi_faces",
),
)
: [],
[jellyseerrApi, flatData],
);
return (
<ParallaxSlideShow
data={flatData}
images={backdrops}
listHeader=""
listHeader=''
keyExtractor={(item) => item.id.toString()}
onEndReached={() => {
if (hasNextPage) {
fetchNextPage()
fetchNextPage();
}
}}
logo={
<Image
id={companyId}
key={companyId}
className="bottom-1 w-1/2"
className='bottom-1 w-1/2'
source={{
uri: jellyseerrApi?.imageProxy(image, COMPANY_LOGO_IMAGE_FILTER),
}}
cachePolicy={"memory-disk"}
contentFit="contain"
contentFit='contain'
style={{
aspectRatio: "4/3",
}}
/>
}
renderItem={(item, index) =>
renderItem={(item, index) => (
<JellyseerrPoster item={item as MovieResult | TvResult} />
}
)}
/>
);
}

View File

@@ -1,42 +1,46 @@
import {router, useLocalSearchParams, useSegments,} from "expo-router";
import React, {useMemo,} from "react";
import {TouchableOpacity} from "react-native";
import {useInfiniteQuery} from "@tanstack/react-query";
import {Endpoints, useJellyseerr} from "@/hooks/useJellyseerr";
import {Text} from "@/components/common/Text";
import Poster from "@/components/posters/Poster";
import { Text } from "@/components/common/Text";
import JellyseerrMediaIcon from "@/components/jellyseerr/JellyseerrMediaIcon";
import {DiscoverSliderType} from "@/utils/jellyseerr/server/constants/discover";
import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow";
import {MovieResult, Results, TvResult} from "@/utils/jellyseerr/server/models/Search";
import {uniqBy} from "lodash";
import {textShadowStyle} from "@/components/jellyseerr/discover/GenericSlideCard";
import { textShadowStyle } from "@/components/jellyseerr/discover/GenericSlideCard";
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
import Poster from "@/components/posters/Poster";
import { Endpoints, useJellyseerr } from "@/hooks/useJellyseerr";
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
import {
type MovieResult,
Results,
type TvResult,
} from "@/utils/jellyseerr/server/models/Search";
import { useInfiniteQuery } from "@tanstack/react-query";
import { router, useLocalSearchParams, useSegments } from "expo-router";
import { uniqBy } from "lodash";
import React, { useMemo } from "react";
import { TouchableOpacity } from "react-native";
export default function page() {
const local = useLocalSearchParams();
const {jellyseerrApi} = useJellyseerr();
const { jellyseerrApi } = useJellyseerr();
const {genreId, name, type} = local as unknown as {
genreId: string,
name: string,
type: DiscoverSliderType
const { genreId, name, type } = local as unknown as {
genreId: string;
name: string;
type: DiscoverSliderType;
};
const {data, fetchNextPage, hasNextPage} = useInfiniteQuery({
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
queryKey: ["jellyseerr", "company", type, genreId],
queryFn: async ({pageParam}) => {
let params: any = {
queryFn: async ({ pageParam }) => {
const params: any = {
page: Number(pageParam),
genre: genreId
genre: genreId,
};
return jellyseerrApi?.discover(
type == DiscoverSliderType.MOVIE_GENRES
? Endpoints.DISCOVER_MOVIES
: Endpoints.DISCOVER_TV,
params
)
type == DiscoverSliderType.MOVIE_GENRES
? Endpoints.DISCOVER_MOVIES
: Endpoints.DISCOVER_TV,
params,
);
},
enabled: !!jellyseerrApi && !!genreId,
initialPageParam: 1,
@@ -47,41 +51,54 @@ export default function page() {
});
const flatData = useMemo(
() => uniqBy(data?.pages?.filter((p) => p?.results.length).flatMap((p) => p?.results ?? []), "id")?? [],
[data]
() =>
uniqBy(
data?.pages
?.filter((p) => p?.results.length)
.flatMap((p) => p?.results ?? []),
"id",
) ?? [],
[data],
);
const backdrops = useMemo(
() => jellyseerrApi
? flatData.map((r) => jellyseerrApi.imageProxy((r as TvResult | MovieResult).backdropPath, "w1920_and_h800_multi_faces"))
: [],
[jellyseerrApi, flatData]
() =>
jellyseerrApi
? flatData.map((r) =>
jellyseerrApi.imageProxy(
(r as TvResult | MovieResult).backdropPath,
"w1920_and_h800_multi_faces",
),
)
: [],
[jellyseerrApi, flatData],
);
return (
<ParallaxSlideShow
data={flatData}
images={backdrops}
listHeader=""
listHeader=''
keyExtractor={(item) => item.id.toString()}
onEndReached={() => {
if (hasNextPage) {
fetchNextPage()
fetchNextPage();
}
}}
logo={
<Text
className="text-4xl font-bold text-center bottom-1"
className='text-4xl font-bold text-center bottom-1'
style={{
...textShadowStyle.shadow,
shadowRadius: 10
}}>
shadowRadius: 10,
}}
>
{name}
</Text>
}
renderItem={(item, index) =>
renderItem={(item, index) => (
<JellyseerrPoster item={item as MovieResult | TvResult} />
}
)}
/>
);
}

View File

@@ -1,27 +1,29 @@
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { GenreTags } from "@/components/GenreTags";
import Cast from "@/components/jellyseerr/Cast";
import DetailFacts from "@/components/jellyseerr/DetailFacts";
import { OverviewText } from "@/components/OverviewText";
import { ParallaxScrollView } from "@/components/ParallaxPage";
import { JellyserrRatings } from "@/components/Ratings";
import { Text } from "@/components/common/Text";
import Cast from "@/components/jellyseerr/Cast";
import DetailFacts from "@/components/jellyseerr/DetailFacts";
import JellyseerrSeasons from "@/components/series/JellyseerrSeasons";
import { ItemActions } from "@/components/series/SeriesActions";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { useJellyseerrCanRequest } from "@/utils/_jellyseerr/useJellyseerrCanRequest";
import {
IssueType,
type IssueType,
IssueTypeName,
} from "@/utils/jellyseerr/server/constants/issue";
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search";
import { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
import { useTranslation } from "react-i18next";
import type {
MovieResult,
TvResult,
} from "@/utils/jellyseerr/server/models/Search";
import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
import { Ionicons } from "@expo/vector-icons";
import {
BottomSheetBackdrop,
BottomSheetBackdropProps,
type BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetTextInput,
BottomSheetView,
@@ -29,20 +31,16 @@ import {
import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation } from "expo-router";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import type React from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Platform, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
import RequestModal from "@/components/jellyseerr/RequestModal";
import { ANIME_KEYWORD_ID } from "@/utils/jellyseerr/server/api/themoviedb/constants";
import { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
import {MovieDetails} from "@/utils/jellyseerr/server/models/Movie";
import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
const Page: React.FC = () => {
const insets = useSafeAreaInsets();
@@ -83,8 +81,8 @@ const Page: React.FC = () => {
refetchInterval: 0,
queryFn: async () => {
return mediaType === MediaType.MOVIE
? jellyseerrApi?.movieDetails(result.id!!)
: jellyseerrApi?.tvDetails(result.id!!);
? jellyseerrApi?.movieDetails(result.id!)
: jellyseerrApi?.tvDetails(result.id!);
},
});
@@ -99,7 +97,7 @@ const Page: React.FC = () => {
appearsOnIndex={0}
/>
),
[]
[],
);
const submitIssue = useCallback(() => {
@@ -114,15 +112,18 @@ const Page: React.FC = () => {
}
}, [jellyseerrApi, details, result, issueType, issueMessage]);
const setRequestBody = useCallback((body: MediaRequestBody) => {
_setRequestBody(body)
advancedReqModalRef?.current?.present?.();
}, [requestBody, _setRequestBody, advancedReqModalRef])
const setRequestBody = useCallback(
(body: MediaRequestBody) => {
_setRequestBody(body);
advancedReqModalRef?.current?.present?.();
},
[requestBody, _setRequestBody, advancedReqModalRef],
);
const request = useCallback(async () => {
const body: MediaRequestBody = {
mediaId: Number(result.id!!),
mediaType: mediaType!!,
mediaId: Number(result.id!),
mediaType: mediaType!,
tvdbId: details?.externalIds?.tvdbId,
seasons: (details as TvDetails)?.seasons
?.filter?.((s) => s.seasonNumber !== 0)
@@ -130,7 +131,7 @@ const Page: React.FC = () => {
};
if (hasAdvancedRequestPermission) {
setRequestBody(body)
setRequestBody(body);
return;
}
@@ -141,14 +142,14 @@ const Page: React.FC = () => {
() =>
(details?.keywords.some((k) => k.id === ANIME_KEYWORD_ID) || false) &&
mediaType === MediaType.TV,
[details]
[details],
);
useEffect(() => {
if (details) {
navigation.setOptions({
headerRight: () => (
<TouchableOpacity className="rounded-full p-2 bg-neutral-800/80">
<TouchableOpacity className='rounded-full p-2 bg-neutral-800/80'>
<ItemActions item={details} />
</TouchableOpacity>
),
@@ -158,14 +159,14 @@ const Page: React.FC = () => {
return (
<View
className="flex-1 relative"
className='flex-1 relative'
style={{
paddingLeft: insets.left,
paddingRight: insets.right,
}}
>
<ParallaxScrollView
className="flex-1 opacity-100"
className='flex-1 opacity-100'
headerHeight={300}
headerImage={
<View>
@@ -180,7 +181,7 @@ const Page: React.FC = () => {
source={{
uri: jellyseerrApi?.imageProxy(
result.backdropPath,
"w1920_and_h800_multi_faces"
"w1920_and_h800_multi_faces",
),
}}
/>
@@ -190,12 +191,12 @@ const Page: React.FC = () => {
width: "100%",
height: "100%",
}}
className="flex flex-col items-center justify-center border border-neutral-800 bg-neutral-900"
className='flex flex-col items-center justify-center border border-neutral-800 bg-neutral-900'
>
<Ionicons
name="image-outline"
name='image-outline'
size={24}
color="white"
color='white'
style={{ opacity: 0.4 }}
/>
</View>
@@ -203,23 +204,31 @@ const Page: React.FC = () => {
</View>
}
>
<View className="flex flex-col">
<View className="space-y-4">
<View className="px-4">
<View className="flex flex-row justify-between w-full">
<View className="flex flex-col w-56">
<JellyserrRatings result={result as MovieResult | TvResult | MovieDetails | TvDetails} />
<View className='flex flex-col'>
<View className='space-y-4'>
<View className='px-4'>
<View className='flex flex-row justify-between w-full'>
<View className='flex flex-col w-56'>
<JellyserrRatings
result={
result as
| MovieResult
| TvResult
| MovieDetails
| TvDetails
}
/>
<Text
uiTextView
selectable
className="font-bold text-2xl mb-1"
className='font-bold text-2xl mb-1'
>
{mediaTitle}
</Text>
<Text className="opacity-50">{releaseYear}</Text>
<Text className='opacity-50'>{releaseYear}</Text>
</View>
<Image
className="absolute bottom-1 right-1 rounded-lg w-28 aspect-[10/15] border-2 border-neutral-800/50 drop-shadow-2xl"
className='absolute bottom-1 right-1 rounded-lg w-28 aspect-[10/15] border-2 border-neutral-800/50 drop-shadow-2xl'
cachePolicy={"memory-disk"}
transition={300}
source={{
@@ -227,22 +236,22 @@ const Page: React.FC = () => {
}}
/>
</View>
<View className="mb-4">
<View className='mb-4'>
<GenreTags genres={details?.genres?.map((g) => g.name) || []} />
</View>
{isLoading || isFetching ? (
<Button loading={true} disabled={true} color="purple"></Button>
<Button loading={true} disabled={true} color='purple'></Button>
) : canRequest ? (
<Button color="purple" onPress={request}>
<Button color='purple' onPress={request}>
{t("jellyseerr.request_button")}
</Button>
) : (
<Button
className="bg-yellow-500/50 border-yellow-400 ring-yellow-400 text-yellow-100"
color="transparent"
className='bg-yellow-500/50 border-yellow-400 ring-yellow-400 text-yellow-100'
color='transparent'
onPress={() => bottomSheetModalRef?.current?.present()}
iconLeft={
<Ionicons name="warning-outline" size={24} color="white" />
<Ionicons name='warning-outline' size={24} color='white' />
}
style={{
borderWidth: 1,
@@ -252,7 +261,7 @@ const Page: React.FC = () => {
{t("jellyseerr.report_issue_button")}
</Button>
)}
<OverviewText text={result.overview} className="mt-4" />
<OverviewText text={result.overview} className='mt-4' />
</View>
{mediaType === MediaType.TV && (
@@ -261,13 +270,11 @@ const Page: React.FC = () => {
details={details as TvDetails}
refetch={refetch}
hasAdvancedRequest={hasAdvancedRequestPermission}
onAdvancedRequest={(data) =>
setRequestBody(data)
}
onAdvancedRequest={(data) => setRequestBody(data)}
/>
)}
<DetailFacts
className="p-2 border border-neutral-800 bg-neutral-900 rounded-xl"
className='p-2 border border-neutral-800 bg-neutral-900 rounded-xl'
details={details}
/>
<Cast details={details} />
@@ -278,11 +285,11 @@ const Page: React.FC = () => {
ref={advancedReqModalRef}
requestBody={requestBody}
title={mediaTitle}
id={result.id!!}
id={result.id!}
type={mediaType}
isAnime={isAnime}
onRequested={() => {
_setRequestBody(undefined)
_setRequestBody(undefined);
advancedReqModalRef?.current?.close();
refetch();
}}
@@ -300,22 +307,22 @@ const Page: React.FC = () => {
backdropComponent={renderBackdrop}
>
<BottomSheetView>
<View className="flex flex-col space-y-4 px-4 pb-8 pt-2">
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>
<View>
<Text className="font-bold text-2xl text-neutral-100">
<Text className='font-bold text-2xl text-neutral-100'>
{t("jellyseerr.whats_wrong")}
</Text>
</View>
<View className="flex flex-col space-y-2 items-start">
<View className="flex flex-col">
<View className='flex flex-col space-y-2 items-start'>
<View className='flex flex-col'>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<View className="flex flex-col">
<Text className="opacity-50 mb-1 text-xs">
<View className='flex flex-col'>
<Text className='opacity-50 mb-1 text-xs'>
{t("jellyseerr.issue_type")}
</Text>
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between">
<Text style={{}} className="" numberOfLines={1}>
<TouchableOpacity className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
<Text style={{}} className='' numberOfLines={1}>
{issueType
? IssueTypeName[issueType]
: t("jellyseerr.select_an_issue")}
@@ -325,8 +332,8 @@ const Page: React.FC = () => {
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={false}
side="bottom"
align="center"
side='bottom'
align='center'
alignOffset={0}
avoidCollisions={true}
collisionPadding={0}
@@ -353,14 +360,14 @@ const Page: React.FC = () => {
</DropdownMenu.Root>
</View>
<View className="p-4 border border-neutral-800 rounded-xl bg-neutral-900 w-full">
<View className='p-4 border border-neutral-800 rounded-xl bg-neutral-900 w-full'>
<BottomSheetTextInput
multiline
maxLength={254}
style={{ color: "white" }}
clearButtonMode="always"
clearButtonMode='always'
placeholder={t("jellyseerr.describe_the_issue")}
placeholderTextColor="#9CA3AF"
placeholderTextColor='#9CA3AF'
// Issue with multiline + Textinput inside a portal
// https://github.com/callstack/react-native-paper/issues/1668
defaultValue={issueMessage}
@@ -368,7 +375,7 @@ const Page: React.FC = () => {
/>
</View>
</View>
<Button className="mt-auto" onPress={submitIssue} color="purple">
<Button className='mt-auto' onPress={submitIssue} color='purple'>
{t("jellyseerr.submit_button")}
</Button>
</View>

View File

@@ -1,25 +1,30 @@
import {
useLocalSearchParams,
useSegments,
} from "expo-router";
import React, { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { Text } from "@/components/common/Text";
import { Image } from "expo-image";
import { OverviewText } from "@/components/OverviewText";
import {orderBy, uniqBy} from "lodash";
import { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person";
import { Text } from "@/components/common/Text";
import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow";
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
import {MovieResult, TvResult} from "@/utils/jellyseerr/server/models/Search";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import type { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person";
import type {
MovieResult,
TvResult,
} from "@/utils/jellyseerr/server/models/Search";
import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useLocalSearchParams, useSegments } from "expo-router";
import { orderBy, uniqBy } from "lodash";
import React, { useMemo } from "react";
import { useTranslation } from "react-i18next";
export default function page() {
const local = useLocalSearchParams();
const { t } = useTranslation();
const { jellyseerrApi, jellyseerrUser, jellyseerrRegion: region, jellyseerrLocale: locale } = useJellyseerr();
const {
jellyseerrApi,
jellyseerrUser,
jellyseerrRegion: region,
jellyseerrLocale: locale,
} = useJellyseerr();
const { personId } = local as { personId: string };
@@ -34,18 +39,27 @@ export default function page() {
const castedRoles: PersonCreditCast[] = useMemo(
() =>
uniqBy(orderBy(
data?.combinedCredits?.cast,
["voteCount", "voteAverage"],
"desc"
), 'id'),
[data?.combinedCredits]
uniqBy(
orderBy(
data?.combinedCredits?.cast,
["voteCount", "voteAverage"],
"desc",
),
"id",
),
[data?.combinedCredits],
);
const backdrops = useMemo(
() => jellyseerrApi
? castedRoles.map((c) => jellyseerrApi.imageProxy(c.backdropPath, "w1920_and_h800_multi_faces"))
: [],
[jellyseerrApi, data?.combinedCredits]
() =>
jellyseerrApi
? castedRoles.map((c) =>
jellyseerrApi.imageProxy(
c.backdropPath,
"w1920_and_h800_multi_faces",
),
)
: [],
[jellyseerrApi, data?.combinedCredits],
);
return (
@@ -58,15 +72,15 @@ export default function page() {
<Image
key={data?.details?.id}
id={data?.details?.id.toString()}
className="rounded-full bottom-1"
className='rounded-full bottom-1'
source={{
uri: jellyseerrApi?.imageProxy(
data?.details?.profilePath,
"w600_and_h600_bestv2"
"w600_and_h600_bestv2",
),
}}
cachePolicy={"memory-disk"}
contentFit="cover"
contentFit='cover'
style={{
width: 125,
height: 125,
@@ -75,27 +89,27 @@ export default function page() {
}
HeaderContent={() => (
<>
<Text className="font-bold text-2xl mb-1">
{data?.details?.name}
</Text>
<Text className="opacity-50">
<Text className='font-bold text-2xl mb-1'>{data?.details?.name}</Text>
<Text className='opacity-50'>
{t("jellyseerr.born")}{" "}
{new Date(data?.details?.birthday!!).toLocaleDateString(
{new Date(data?.details?.birthday!).toLocaleDateString(
`${locale}-${region}`,
{
year: "numeric",
month: "long",
day: "numeric",
}
},
)}{" "}
| {data?.details?.placeOfBirth}
</Text>
</>
)}
MainContent={() => (
<OverviewText text={data?.details?.biography} className="mt-4" />
<OverviewText text={data?.details?.biography} className='mt-4' />
)}
renderItem={(item, index) => (
<JellyseerrPoster item={item as MovieResult | TvResult} />
)}
renderItem={(item, index) => <JellyseerrPoster item={item as MovieResult | TvResult} />}
/>
);
}

View File

@@ -3,7 +3,10 @@ import type {
MaterialTopTabNavigationOptions,
} from "@react-navigation/material-top-tabs";
import { createMaterialTopTabNavigator } from "@react-navigation/material-top-tabs";
import { ParamListBase, TabNavigationState } from "@react-navigation/native";
import type {
ParamListBase,
TabNavigationState,
} from "@react-navigation/native";
import { Stack, withLayoutContext } from "expo-router";
import React from "react";
@@ -21,8 +24,8 @@ const Layout = () => {
<>
<Stack.Screen options={{ title: "Live TV" }} />
<Tab
initialRouteName="programs"
keyboardDismissMode="none"
initialRouteName='programs'
keyboardDismissMode='none'
screenOptions={{
tabBarBounces: true,
tabBarLabelStyle: { fontSize: 10 },
@@ -37,10 +40,10 @@ const Layout = () => {
tabBarScrollEnabled: true,
}}
>
<Tab.Screen name="programs" />
<Tab.Screen name="guide" />
<Tab.Screen name="channels" />
<Tab.Screen name="recordings" />
<Tab.Screen name='programs' />
<Tab.Screen name='guide' />
<Tab.Screen name='channels' />
<Tab.Screen name='recordings' />
</Tab>
</>
);

View File

@@ -31,13 +31,13 @@ export default function page() {
});
return (
<View className="flex flex-1">
<View className='flex flex-1'>
<FlashList
data={channels?.Items}
estimatedItemSize={76}
renderItem={({ item }) => (
<View className="flex flex-row items-center px-4 mb-2">
<View className="w-22 mr-4 rounded-lg overflow-hidden">
<View className='flex flex-row items-center px-4 mb-2'>
<View className='w-22 mr-4 rounded-lg overflow-hidden'>
<ItemImage
style={{
aspectRatio: "1/1",
@@ -47,7 +47,7 @@ export default function page() {
item={item}
/>
</View>
<Text className="font-bold">{item.Name}</Text>
<Text className='font-bold'>{item.Name}</Text>
</View>
)}
/>

View File

@@ -9,6 +9,7 @@ import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { useAtom } from "jotai";
import React, { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Button,
Dimensions,
@@ -17,7 +18,6 @@ import {
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useTranslation } from "react-i18next";
const HOUR_HEIGHT = 30;
const ITEMS_PER_PAGE = 20;
@@ -71,7 +71,7 @@ export default function page() {
MaxStartDate: endOfDay.toISOString(),
MinEndDate: isToday ? now.toISOString() : startOfDay.toISOString(),
ChannelIds: channels?.Items?.map((c) => c.Id).filter(
Boolean
Boolean,
) as string[],
ImageTypeLimit: 1,
EnableImages: false,
@@ -100,7 +100,7 @@ export default function page() {
return (
<ScrollView
nestedScrollEnabled
contentInsetAdjustmentBehavior="automatic"
contentInsetAdjustmentBehavior='automatic'
key={"home"}
contentContainerStyle={{
paddingLeft: insets.left,
@@ -117,16 +117,16 @@ export default function page() {
}
/>
<View className="flex flex-row">
<View className="flex flex-col w-[64px]">
<View className='flex flex-row'>
<View className='flex flex-col w-[64px]'>
<View
style={{
height: HOUR_HEIGHT,
}}
className="bg-neutral-800"
className='bg-neutral-800'
></View>
{channels?.Items?.map((c, i) => (
<View className="h-16 w-16 mr-4 rounded-lg overflow-hidden" key={i}>
<View className='h-16 w-16 mr-4 rounded-lg overflow-hidden' key={i}>
<ItemImage
style={{
width: "100%",
@@ -148,7 +148,7 @@ export default function page() {
setScrollX(e.nativeEvent.contentOffset.x);
}}
>
<View className="flex flex-col">
<View className='flex flex-col'>
<HourHeader height={HOUR_HEIGHT} />
{channels?.Items?.map((c, i) => (
<MemoizedLiveTVGuideRow
@@ -180,14 +180,14 @@ const PageButtons: React.FC<PageButtonsProps> = ({
}) => {
const { t } = useTranslation();
return (
<View className="flex flex-row justify-between items-center bg-neutral-800 w-full px-4 py-2">
<View className='flex flex-row justify-between items-center bg-neutral-800 w-full px-4 py-2'>
<TouchableOpacity
onPress={onPrevPage}
disabled={currentPage === 1}
className="flex flex-row items-center"
className='flex flex-row items-center'
>
<Ionicons
name="chevron-back"
name='chevron-back'
size={24}
color={currentPage === 1 ? "gray" : "white"}
/>
@@ -199,11 +199,11 @@ const PageButtons: React.FC<PageButtonsProps> = ({
{t("live_tv.previous")}
</Text>
</TouchableOpacity>
<Text className="text-white">Page {currentPage}</Text>
<Text className='text-white'>Page {currentPage}</Text>
<TouchableOpacity
onPress={onNextPage}
disabled={isNextDisabled}
className="flex flex-row items-center"
className='flex flex-row items-center'
>
<Text
className={`mr-1 ${isNextDisabled ? "text-gray-500" : "text-white"}`}
@@ -211,7 +211,7 @@ const PageButtons: React.FC<PageButtonsProps> = ({
{t("live_tv.next")}
</Text>
<Ionicons
name="chevron-forward"
name='chevron-forward'
size={24}
color={isNextDisabled ? "gray" : "white"}
/>

View File

@@ -1,13 +1,13 @@
import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList";
import { TAB_HEIGHT } from "@/constants/Values";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
import { useAtom } from "jotai";
import React from "react";
import { useTranslation } from "react-i18next";
import { ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useTranslation } from "react-i18next";
export default function page() {
const [api] = useAtom(apiAtom);
@@ -19,7 +19,7 @@ export default function page() {
return (
<ScrollView
nestedScrollEnabled
contentInsetAdjustmentBehavior="automatic"
contentInsetAdjustmentBehavior='automatic'
key={"home"}
contentContainerStyle={{
paddingLeft: insets.left,
@@ -28,7 +28,7 @@ export default function page() {
paddingTop: 8,
}}
>
<View className="flex flex-col space-y-2">
<View className='flex flex-col space-y-2'>
<ScrollingCollectionList
queryKey={["livetv", "recommended"]}
title={t("live_tv.on_now")}
@@ -45,7 +45,7 @@ export default function page() {
});
return res.data.Items || [];
}}
orientation="horizontal"
orientation='horizontal'
/>
<ScrollingCollectionList
queryKey={["livetv", "shows"]}
@@ -67,7 +67,7 @@ export default function page() {
});
return res.data.Items || [];
}}
orientation="horizontal"
orientation='horizontal'
/>
<ScrollingCollectionList
queryKey={["livetv", "movies"]}
@@ -85,7 +85,7 @@ export default function page() {
});
return res.data.Items || [];
}}
orientation="horizontal"
orientation='horizontal'
/>
<ScrollingCollectionList
queryKey={["livetv", "sports"]}
@@ -103,7 +103,7 @@ export default function page() {
});
return res.data.Items || [];
}}
orientation="horizontal"
orientation='horizontal'
/>
<ScrollingCollectionList
queryKey={["livetv", "kids"]}
@@ -121,7 +121,7 @@ export default function page() {
});
return res.data.Items || [];
}}
orientation="horizontal"
orientation='horizontal'
/>
<ScrollingCollectionList
queryKey={["livetv", "news"]}
@@ -139,7 +139,7 @@ export default function page() {
});
return res.data.Items || [];
}}
orientation="horizontal"
orientation='horizontal'
/>
</View>
</ScrollView>

View File

@@ -1,12 +1,12 @@
import { Text } from "@/components/common/Text";
import React from "react";
import { View } from "react-native";
import { useTranslation } from "react-i18next";
import { View } from "react-native";
export default function page() {
const { t } = useTranslation();
return (
<View className="flex items-center justify-center h-full -mt-12">
<View className='flex items-center justify-center h-full -mt-12'>
<Text>{t("live_tv.coming_soon")}</Text>
</View>
);

View File

@@ -14,9 +14,10 @@ import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai";
import React, { useEffect, useMemo } from "react";
import { Platform, View } from "react-native";
import type React from "react";
import { useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Platform, View } from "react-native";
const page: React.FC = () => {
const navigation = useNavigation();
@@ -49,7 +50,7 @@ const page: React.FC = () => {
quality: 90,
width: 1000,
}),
[item]
[item],
);
const logoUrl = useMemo(
@@ -58,7 +59,7 @@ const page: React.FC = () => {
api,
item,
}),
[item]
[item],
);
const { data: allEpisodes, isLoading } = useQuery({
@@ -83,22 +84,22 @@ const page: React.FC = () => {
item &&
allEpisodes &&
allEpisodes.length > 0 && (
<View className="flex flex-row items-center space-x-2">
<View className='flex flex-row items-center space-x-2'>
<AddToFavorites item={item} />
{!Platform.isTV && (
<>
<DownloadItems
size="large"
size='large'
title={t("item_card.download.download_series")}
items={allEpisodes || []}
MissingDownloadIconComponent={() => (
<Ionicons name="download" size={22} color="white" />
<Ionicons name='download' size={22} color='white' />
)}
DownloadedIconComponent={() => (
<Ionicons
name="checkmark-done-outline"
name='checkmark-done-outline'
size={24}
color="#9333ea"
color='#9333ea'
/>
)}
/>
@@ -142,9 +143,9 @@ const page: React.FC = () => {
</>
}
>
<View className="flex flex-col pt-4">
<View className='flex flex-col pt-4'>
<SeriesHeader item={item} />
<View className="mb-4">
<View className='mb-4'>
<NextUp seriesId={seriesId} />
</View>
<SeasonPicker item={item} initialSeasonIndex={Number(seasonIndex)} />

View File

@@ -1,35 +1,35 @@
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { useLocalSearchParams, useNavigation } from "expo-router";
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { useAtom } from "jotai";
import React, { useCallback, useEffect, useMemo } from "react";
import { FlatList, useWindowDimensions, View } from "react-native";
import { FlatList, View, useWindowDimensions } from "react-native";
import { ItemCardText } from "@/components/ItemCardText";
import { Loader } from "@/components/Loader";
import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import { FilterButton } from "@/components/filters/FilterButton";
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
import { ItemCardText } from "@/components/ItemCardText";
import { Loader } from "@/components/Loader";
import { ItemPoster } from "@/components/posters/ItemPoster";
import { useOrientation } from "@/hooks/useOrientation";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import {
SortByOption,
SortOrderOption,
genreFilterAtom,
getSortByPreference,
getSortOrderPreference,
sortByAtom,
SortByOption,
sortByPreferenceAtom,
sortOptions,
sortOrderAtom,
SortOrderOption,
sortOrderOptions,
sortOrderPreferenceAtom,
tagsFilterAtom,
yearFilterAtom,
} from "@/utils/atoms/filters";
import {
import type {
BaseItemDto,
BaseItemDtoQueryResult,
BaseItemKind,
@@ -40,8 +40,8 @@ import {
getUserLibraryApi,
} from "@jellyfin/sdk/lib/utils/api";
import { FlashList } from "@shopify/flash-list";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useTranslation } from "react-i18next";
import { useSafeAreaInsets } from "react-native-safe-area-context";
const Page = () => {
const searchParams = useLocalSearchParams();
@@ -58,7 +58,7 @@ const Page = () => {
const [sortOrder, _setSortOrder] = useAtom(sortOrderAtom);
const [sortByPreference, setSortByPreference] = useAtom(sortByPreferenceAtom);
const [sortOrderPreference, setOderByPreference] = useAtom(
sortOrderPreferenceAtom
sortOrderPreferenceAtom,
);
const { orientation } = useOrientation();
@@ -88,7 +88,7 @@ const Page = () => {
}
_setSortBy(sortBy);
},
[libraryId, sortByPreference]
[libraryId, sortByPreference],
);
const setSortOrder = useCallback(
@@ -102,7 +102,7 @@ const Page = () => {
}
_setSortOrder(sortOrder);
},
[libraryId, sortOrderPreference]
[libraryId, sortOrderPreference],
);
const nrOfCols = useMemo(() => {
@@ -169,7 +169,7 @@ const Page = () => {
fields: ["PrimaryImageAspectRatio", "SortName"],
genres: selectedGenres,
tags: selectedTags,
years: selectedYears.map((year) => parseInt(year)),
years: selectedYears.map((year) => Number.parseInt(year)),
includeItemTypes: itemType ? [itemType] : undefined,
});
@@ -185,7 +185,7 @@ const Page = () => {
selectedTags,
sortBy,
sortOrder,
]
],
);
const { data, isFetching, fetchNextPage, hasNextPage, isLoading } =
@@ -211,7 +211,7 @@ const Page = () => {
const totalItems = lastPage.TotalRecordCount;
const accumulatedItems = pages.reduce(
(acc, curr) => acc + (curr?.Items?.length || 0),
0
0,
);
if (accumulatedItems < totalItems) {
@@ -248,8 +248,8 @@ const Page = () => {
? index % nrOfCols === 0
? "flex-end"
: (index + 1) % nrOfCols === 0
? "flex-start"
: "center"
? "flex-start"
: "center"
: "center",
width: "89%",
}}
@@ -260,14 +260,14 @@ const Page = () => {
</View>
</TouchableItemRouter>
),
[orientation]
[orientation],
);
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
const ListHeaderComponent = useCallback(
() => (
<View className="">
<View className=''>
<FlatList
horizontal
showsHorizontalScrollIndicator={false}
@@ -286,13 +286,13 @@ const Page = () => {
key: "genre",
component: (
<FilterButton
className="mr-1"
className='mr-1'
collectionId={libraryId}
queryKey="genreFilter"
queryKey='genreFilter'
queryFn={async () => {
if (!api) return null;
const response = await getFilterApi(
api
api,
).getQueryFiltersLegacy({
userId: user?.Id,
parentId: libraryId,
@@ -313,13 +313,13 @@ const Page = () => {
key: "year",
component: (
<FilterButton
className="mr-1"
className='mr-1'
collectionId={libraryId}
queryKey="yearFilter"
queryKey='yearFilter'
queryFn={async () => {
if (!api) return null;
const response = await getFilterApi(
api
api,
).getQueryFiltersLegacy({
userId: user?.Id,
parentId: libraryId,
@@ -338,13 +338,13 @@ const Page = () => {
key: "tags",
component: (
<FilterButton
className="mr-1"
className='mr-1'
collectionId={libraryId}
queryKey="tagsFilter"
queryKey='tagsFilter'
queryFn={async () => {
if (!api) return null;
const response = await getFilterApi(
api
api,
).getQueryFiltersLegacy({
userId: user?.Id,
parentId: libraryId,
@@ -365,9 +365,9 @@ const Page = () => {
key: "sortBy",
component: (
<FilterButton
className="mr-1"
className='mr-1'
collectionId={libraryId}
queryKey="sortBy"
queryKey='sortBy'
queryFn={async () => sortOptions.map((s) => s.key)}
set={setSortBy}
values={sortBy}
@@ -385,9 +385,9 @@ const Page = () => {
key: "sortOrder",
component: (
<FilterButton
className="mr-1"
className='mr-1'
collectionId={libraryId}
queryKey="sortOrder"
queryKey='sortOrder'
queryFn={async () => sortOrderOptions.map((s) => s.key)}
set={setSortOrder}
values={sortOrder}
@@ -422,22 +422,24 @@ const Page = () => {
sortOrder,
setSortOrder,
isFetching,
]
],
);
const insets = useSafeAreaInsets();
if (isLoading || isLibraryLoading)
return (
<View className="w-full h-full flex items-center justify-center">
<View className='w-full h-full flex items-center justify-center'>
<Loader />
</View>
);
if (flatData.length === 0)
return (
<View className="h-full w-full flex justify-center items-center">
<Text className="text-lg text-neutral-500">{t("library.no_items_found")}</Text>
<View className='h-full w-full flex justify-center items-center'>
<Text className='text-lg text-neutral-500'>
{t("library.no_items_found")}
</Text>
</View>
);
@@ -445,11 +447,13 @@ const Page = () => {
<FlashList
key={orientation}
ListEmptyComponent={
<View className="flex flex-col items-center justify-center h-full">
<Text className="font-bold text-xl text-neutral-500">{t("library.no_results")}</Text>
<View className='flex flex-col items-center justify-center h-full'>
<Text className='font-bold text-xl text-neutral-500'>
{t("library.no_results")}
</Text>
</View>
}
contentInsetAdjustmentBehavior="automatic"
contentInsetAdjustmentBehavior='automatic'
data={flatData}
renderItem={renderItem}
extraData={[orientation, nrOfCols]}

View File

@@ -16,7 +16,7 @@ export default function IndexLayout() {
return (
<Stack>
<Stack.Screen
name="index"
name='index'
options={{
headerShown: true,
headerLargeTitle: true,
@@ -33,9 +33,9 @@ export default function IndexLayout() {
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<Ionicons
name="ellipsis-horizontal-outline"
name='ellipsis-horizontal-outline'
size={24}
color="white"
color='white'
/>
</DropdownMenu.Trigger>
<DropdownMenu.Content
@@ -50,9 +50,9 @@ export default function IndexLayout() {
<DropdownMenu.Label>
{t("library.options.display")}
</DropdownMenu.Label>
<DropdownMenu.Group key="display-group">
<DropdownMenu.Group key='display-group'>
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger key="image-style-trigger">
<DropdownMenu.SubTrigger key='image-style-trigger'>
{t("library.options.display")}
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent
@@ -63,7 +63,7 @@ export default function IndexLayout() {
sideOffset={10}
>
<DropdownMenu.CheckboxItem
key="display-option-1"
key='display-option-1'
value={settings.libraryOptions.display === "row"}
onValueChange={() =>
updateSettings({
@@ -75,12 +75,12 @@ export default function IndexLayout() {
}
>
<DropdownMenu.ItemIndicator />
<DropdownMenu.ItemTitle key="display-title-1">
<DropdownMenu.ItemTitle key='display-title-1'>
{t("library.options.row")}
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
key="display-option-2"
key='display-option-2'
value={settings.libraryOptions.display === "list"}
onValueChange={() =>
updateSettings({
@@ -92,14 +92,14 @@ export default function IndexLayout() {
}
>
<DropdownMenu.ItemIndicator />
<DropdownMenu.ItemTitle key="display-title-2">
<DropdownMenu.ItemTitle key='display-title-2'>
{t("library.options.list")}
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger key="image-style-trigger">
<DropdownMenu.SubTrigger key='image-style-trigger'>
{t("library.options.image_style")}
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent
@@ -110,7 +110,7 @@ export default function IndexLayout() {
sideOffset={10}
>
<DropdownMenu.CheckboxItem
key="poster-option"
key='poster-option'
value={
settings.libraryOptions.imageStyle === "poster"
}
@@ -124,12 +124,12 @@ export default function IndexLayout() {
}
>
<DropdownMenu.ItemIndicator />
<DropdownMenu.ItemTitle key="poster-title">
<DropdownMenu.ItemTitle key='poster-title'>
{t("library.options.poster")}
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
key="cover-option"
key='cover-option'
value={settings.libraryOptions.imageStyle === "cover"}
onValueChange={() =>
updateSettings({
@@ -141,17 +141,17 @@ export default function IndexLayout() {
}
>
<DropdownMenu.ItemIndicator />
<DropdownMenu.ItemTitle key="cover-title">
<DropdownMenu.ItemTitle key='cover-title'>
{t("library.options.cover")}
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
</DropdownMenu.Group>
<DropdownMenu.Group key="show-titles-group">
<DropdownMenu.Group key='show-titles-group'>
<DropdownMenu.CheckboxItem
disabled={settings.libraryOptions.imageStyle === "poster"}
key="show-titles-option"
key='show-titles-option'
value={settings.libraryOptions.showTitles}
onValueChange={(newValue: string) => {
if (settings.libraryOptions.imageStyle === "poster")
@@ -165,12 +165,12 @@ export default function IndexLayout() {
}}
>
<DropdownMenu.ItemIndicator />
<DropdownMenu.ItemTitle key="show-titles-title">
<DropdownMenu.ItemTitle key='show-titles-title'>
{t("library.options.show_titles")}
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
key="show-stats-option"
key='show-stats-option'
value={settings.libraryOptions.showStats}
onValueChange={(newValue: string) => {
updateSettings({
@@ -182,7 +182,7 @@ export default function IndexLayout() {
}}
>
<DropdownMenu.ItemIndicator />
<DropdownMenu.ItemTitle key="show-stats-title">
<DropdownMenu.ItemTitle key='show-stats-title'>
{t("library.options.show_stats")}
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
@@ -195,7 +195,7 @@ export default function IndexLayout() {
}}
/>
<Stack.Screen
name="[libraryId]"
name='[libraryId]'
options={{
title: "",
headerShown: true,
@@ -208,7 +208,7 @@ export default function IndexLayout() {
<Stack.Screen key={name} name={name} options={options} />
))}
<Stack.Screen
name="collections/[collectionId]"
name='collections/[collectionId]'
options={{
title: "",
headerShown: true,

View File

@@ -1,6 +1,6 @@
import { Loader } from "@/components/Loader";
import { Text } from "@/components/common/Text";
import { LibraryItemCard } from "@/components/library/LibraryItemCard";
import { Loader } from "@/components/Loader";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import {
@@ -11,9 +11,9 @@ import { FlashList } from "@shopify/flash-list";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAtom } from "jotai";
import { useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { StyleSheet, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useTranslation } from "react-i18next";
export default function index() {
const [api] = useAtom(apiAtom);
@@ -23,7 +23,7 @@ export default function index() {
const { t } = useTranslation();
const { data, isLoading: isLoading } = useQuery({
const { data, isLoading } = useQuery({
queryKey: ["user-views", user?.Id],
queryFn: async () => {
const response = await getUserViewsApi(api!).getUserViews({
@@ -41,7 +41,7 @@ export default function index() {
?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!))
.filter((l) => l.CollectionType !== "music")
.filter((l) => l.CollectionType !== "books") || [],
[data, settings?.hiddenLibraries]
[data, settings?.hiddenLibraries],
);
useEffect(() => {
@@ -65,22 +65,24 @@ export default function index() {
if (isLoading)
return (
<View className="justify-center items-center h-full">
<View className='justify-center items-center h-full'>
<Loader />
</View>
);
if (!libraries)
return (
<View className="h-full w-full flex justify-center items-center">
<Text className="text-lg text-neutral-500">{t("library.no_libraries_found")}</Text>
<View className='h-full w-full flex justify-center items-center'>
<Text className='text-lg text-neutral-500'>
{t("library.no_libraries_found")}
</Text>
</View>
);
return (
<FlashList
extraData={settings}
contentInsetAdjustmentBehavior="automatic"
contentInsetAdjustmentBehavior='automatic'
contentContainerStyle={{
paddingTop: 17,
paddingHorizontal: settings?.libraryOptions?.display === "row" ? 0 : 17,
@@ -97,10 +99,10 @@ export default function index() {
style={{
height: StyleSheet.hairlineWidth,
}}
className="bg-neutral-800 mx-2 my-4"
className='bg-neutral-800 mx-2 my-4'
></View>
) : (
<View className="h-4" />
<View className='h-4' />
)
}
estimatedItemSize={200}

View File

@@ -3,15 +3,15 @@ import {
nestedTabPageScreenOptions,
} from "@/components/stacks/NestedTabPageStack";
import { Stack } from "expo-router";
import { Platform } from "react-native";
import { useTranslation } from "react-i18next";
import { Platform } from "react-native";
export default function SearchLayout() {
const { t } = useTranslation();
return (
<Stack>
<Stack.Screen
name="index"
name='index'
options={{
headerShown: true,
headerLargeTitle: true,
@@ -28,7 +28,7 @@ export default function SearchLayout() {
<Stack.Screen key={name} name={name} options={options} />
))}
<Stack.Screen
name="collections/[collectionId]"
name='collections/[collectionId]'
options={{
title: "",
headerShown: true,
@@ -37,17 +37,17 @@ export default function SearchLayout() {
headerShadowVisible: false,
}}
/>
<Stack.Screen name="jellyseerr/page" options={commonScreenOptions} />
<Stack.Screen name='jellyseerr/page' options={commonScreenOptions} />
<Stack.Screen
name="jellyseerr/person/[personId]"
name='jellyseerr/person/[personId]'
options={commonScreenOptions}
/>
<Stack.Screen
name="jellyseerr/company/[companyId]"
name='jellyseerr/company/[companyId]'
options={commonScreenOptions}
/>
<Stack.Screen
name="jellyseerr/genre/[genreId]"
name='jellyseerr/genre/[genreId]'
options={commonScreenOptions}
/>
</Stack>

View File

@@ -1,30 +1,43 @@
import {Text} from "@/components/common/Text";
import {TouchableItemRouter} from "@/components/common/TouchableItemRouter";
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
import {Tag} from "@/components/GenreTags";
import {ItemCardText} from "@/components/ItemCardText";
import {JellyseerrSearchSort, JellyserrIndexPage} from "@/components/jellyseerr/JellyseerrIndexPage";
import { Tag } from "@/components/GenreTags";
import { ItemCardText } from "@/components/ItemCardText";
import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import { FilterButton } from "@/components/filters/FilterButton";
import {
JellyseerrSearchSort,
JellyserrIndexPage,
} from "@/components/jellyseerr/JellyseerrIndexPage";
import MoviePoster from "@/components/posters/MoviePoster";
import SeriesPoster from "@/components/posters/SeriesPoster";
import {LoadingSkeleton} from "@/components/search/LoadingSkeleton";
import {SearchItemWrapper} from "@/components/search/SearchItemWrapper";
import {useJellyseerr} from "@/hooks/useJellyseerr";
import {apiAtom, userAtom} from "@/providers/JellyfinProvider";
import {useSettings} from "@/utils/atoms/settings";
import {BaseItemDto, BaseItemKind,} from "@jellyfin/sdk/lib/generated-client/models";
import {getItemsApi, getSearchApi} from "@jellyfin/sdk/lib/utils/api";
import {useQuery} from "@tanstack/react-query";
import { LoadingSkeleton } from "@/components/search/LoadingSkeleton";
import { SearchItemWrapper } from "@/components/search/SearchItemWrapper";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { sortOrderOptions } from "@/utils/atoms/filters";
import { useSettings } from "@/utils/atoms/settings";
import { eventBus } from "@/utils/eventBus";
import type {
BaseItemDto,
BaseItemKind,
} from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi, getSearchApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import axios from "axios";
import {router, useLocalSearchParams, useNavigation} from "expo-router";
import {useAtom} from "jotai";
import React, {useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState,} from "react";
import {Platform, ScrollView, TouchableOpacity, View} from "react-native";
import {useSafeAreaInsets} from "react-native-safe-area-context";
import {useDebounce} from "use-debounce";
import {useTranslation} from "react-i18next";
import {eventBus} from "@/utils/eventBus";
import {sortOrderOptions} from "@/utils/atoms/filters";
import {FilterButton} from "@/components/filters/FilterButton";
import { router, useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai";
import React, {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useDebounce } from "use-debounce";
type SearchType = "Library" | "Discover";
@@ -55,8 +68,15 @@ export default function search() {
const [settings] = useSettings();
const { jellyseerrApi } = useJellyseerr();
const [jellyseerrOrderBy, setJellyseerrOrderBy] = useState<JellyseerrSearchSort>(JellyseerrSearchSort[JellyseerrSearchSort.DEFAULT] as unknown as JellyseerrSearchSort)
const [jellyseerrSortOrder, setJellyseerrSortOrder] = useState<"asc" | "desc">("desc")
const [jellyseerrOrderBy, setJellyseerrOrderBy] =
useState<JellyseerrSearchSort>(
JellyseerrSearchSort[
JellyseerrSearchSort.DEFAULT
] as unknown as JellyseerrSearchSort,
);
const [jellyseerrSortOrder, setJellyseerrSortOrder] = useState<
"asc" | "desc"
>("desc");
const searchEngine = useMemo(() => {
return settings?.searchEngine || "Jellyfin";
@@ -112,7 +132,7 @@ export default function search() {
return []; // Ensure an empty array is returned in case of an error
}
},
[api, searchEngine, settings]
[api, searchEngine, settings],
);
type HeaderSearchBarRef = {
@@ -220,26 +240,29 @@ export default function search() {
return (
<>
<ScrollView
keyboardDismissMode="on-drag"
contentInsetAdjustmentBehavior="automatic"
keyboardDismissMode='on-drag'
contentInsetAdjustmentBehavior='automatic'
contentContainerStyle={{
paddingLeft: insets.left,
paddingRight: insets.right,
}}
>
<View
className="flex flex-col"
className='flex flex-col'
style={{
marginTop: Platform.OS === "android" ? 16 : 0,
}}
>
{jellyseerrApi && (
<>
<ScrollView horizontal className="flex flex-row flex-wrap space-x-2 px-4 mb-2">
<ScrollView
horizontal
className='flex flex-row flex-wrap space-x-2 px-4 mb-2'
>
<TouchableOpacity onPress={() => setSearchType("Library")}>
<Tag
text={t("search.library")}
textClass="p-1"
textClass='p-1'
className={
searchType === "Library" ? "bg-purple-600" : undefined
}
@@ -248,41 +271,50 @@ export default function search() {
<TouchableOpacity onPress={() => setSearchType("Discover")}>
<Tag
text={t("search.discover")}
textClass="p-1"
textClass='p-1'
className={
searchType === "Discover" ? "bg-purple-600" : undefined
}
/>
</TouchableOpacity>
{searchType === "Discover" && !loading && noResults && debouncedSearch.length > 0 && (
<View className="flex flex-row justify-end items-center space-x-1">
<FilterButton
collectionId="search"
queryKey="jellyseerr_search"
queryFn={async () => Object.keys(JellyseerrSearchSort).filter(v => isNaN(Number(v)))}
set={value => setJellyseerrOrderBy(value[0])}
values={[jellyseerrOrderBy]}
title={t("library.filters.sort_by")}
renderItemLabel={(item) => t(`home.settings.plugins.jellyseerr.order_by.${item}`)}
showSearch={false}
/>
<FilterButton
collectionId="order"
queryKey="jellysearr_search"
queryFn={async () => ["asc", "desc"]}
set={value => setJellyseerrSortOrder(value[0])}
values={[jellyseerrSortOrder]}
title={t("library.filters.sort_order")}
renderItemLabel={(item) => t(`library.filters.${item}`)}
showSearch={false}
/>
</View>
)}
{searchType === "Discover" &&
!loading &&
noResults &&
debouncedSearch.length > 0 && (
<View className='flex flex-row justify-end items-center space-x-1'>
<FilterButton
collectionId='search'
queryKey='jellyseerr_search'
queryFn={async () =>
Object.keys(JellyseerrSearchSort).filter((v) =>
isNaN(Number(v)),
)
}
set={(value) => setJellyseerrOrderBy(value[0])}
values={[jellyseerrOrderBy]}
title={t("library.filters.sort_by")}
renderItemLabel={(item) =>
t(`home.settings.plugins.jellyseerr.order_by.${item}`)
}
showSearch={false}
/>
<FilterButton
collectionId='order'
queryKey='jellysearr_search'
queryFn={async () => ["asc", "desc"]}
set={(value) => setJellyseerrSortOrder(value[0])}
values={[jellyseerrSortOrder]}
title={t("library.filters.sort_order")}
renderItemLabel={(item) => t(`library.filters.${item}`)}
showSearch={false}
/>
</View>
)}
</ScrollView>
</>
)}
<View className="mt-2">
<View className='mt-2'>
<LoadingSkeleton isLoading={loading} />
</View>
@@ -294,14 +326,14 @@ export default function search() {
renderItem={(item: BaseItemDto) => (
<TouchableItemRouter
key={item.Id}
className="flex flex-col w-28 mr-2"
className='flex flex-col w-28 mr-2'
item={item}
>
<MoviePoster item={item} key={item.Id} />
<Text numberOfLines={2} className="mt-2">
<Text numberOfLines={2} className='mt-2'>
{item.Name}
</Text>
<Text className="opacity-50 text-xs">
<Text className='opacity-50 text-xs'>
{item.ProductionYear}
</Text>
</TouchableItemRouter>
@@ -314,13 +346,13 @@ export default function search() {
<TouchableItemRouter
key={item.Id}
item={item}
className="flex flex-col w-28 mr-2"
className='flex flex-col w-28 mr-2'
>
<SeriesPoster item={item} key={item.Id} />
<Text numberOfLines={2} className="mt-2">
<Text numberOfLines={2} className='mt-2'>
{item.Name}
</Text>
<Text className="opacity-50 text-xs">
<Text className='opacity-50 text-xs'>
{item.ProductionYear}
</Text>
</TouchableItemRouter>
@@ -333,7 +365,7 @@ export default function search() {
<TouchableItemRouter
item={item}
key={item.Id}
className="flex flex-col w-44 mr-2"
className='flex flex-col w-44 mr-2'
>
<ContinueWatchingPoster item={item} />
<ItemCardText item={item} />
@@ -347,10 +379,10 @@ export default function search() {
<TouchableItemRouter
key={item.Id}
item={item}
className="flex flex-col w-28 mr-2"
className='flex flex-col w-28 mr-2'
>
<MoviePoster item={item} key={item.Id} />
<Text numberOfLines={2} className="mt-2">
<Text numberOfLines={2} className='mt-2'>
{item.Name}
</Text>
</TouchableItemRouter>
@@ -363,7 +395,7 @@ export default function search() {
<TouchableItemRouter
item={item}
key={item.Id}
className="flex flex-col w-28 mr-2"
className='flex flex-col w-28 mr-2'
>
<MoviePoster item={item} />
<ItemCardText item={item} />
@@ -383,22 +415,22 @@ export default function search() {
<>
{!loading && noResults && debouncedSearch.length > 0 ? (
<View>
<Text className="text-center text-lg font-bold mt-4">
<Text className='text-center text-lg font-bold mt-4'>
{t("search.no_results_found_for")}
</Text>
<Text className="text-xs text-purple-600 text-center">
<Text className='text-xs text-purple-600 text-center'>
"{debouncedSearch}"
</Text>
</View>
) : debouncedSearch.length === 0 ? (
<View className="mt-4 flex flex-col items-center space-y-2">
<View className='mt-4 flex flex-col items-center space-y-2'>
{exampleSearches.map((e) => (
<TouchableOpacity
onPress={() => setSearch(e)}
key={e}
className="mb-2"
className='mb-2'
>
<Text className="text-purple-600">{e}</Text>
<Text className='text-purple-600'>{e}</Text>
</TouchableOpacity>
))}
</View>

View File

@@ -1,26 +1,26 @@
import React, { useCallback, useRef } from "react";
import { Platform } from "react-native";
import { useTranslation } from "react-i18next";
import { Platform } from "react-native";
import { useFocusEffect, useRouter, withLayoutContext } from "expo-router";
import {
type NativeBottomTabNavigationEventMap,
createNativeBottomTabNavigator,
NativeBottomTabNavigationEventMap,
} from "@bottom-tabs/react-navigation";
const { Navigator } = createNativeBottomTabNavigator();
import { BottomTabNavigationOptions } from "@react-navigation/bottom-tabs";
import type { BottomTabNavigationOptions } from "@react-navigation/bottom-tabs";
import { Colors } from "@/constants/Colors";
import { useSettings } from "@/utils/atoms/settings";
import { eventBus } from "@/utils/eventBus";
import { storage } from "@/utils/mmkv";
import type {
ParamListBase,
TabNavigationState,
} from "@react-navigation/native";
import { SystemBars } from "react-native-edge-to-edge";
import { eventBus } from "@/utils/eventBus";
export const NativeTabs = withLayoutContext<
BottomTabNavigationOptions,
@@ -46,12 +46,12 @@ export default function TabLayout() {
clearTimeout(timer);
};
}
}, [])
}, []),
);
return (
<>
<SystemBars hidden={false} style="light" />
<SystemBars hidden={false} style='light' />
<NativeTabs
sidebarAdaptable={false}
ignoresTopSafeArea
@@ -59,16 +59,16 @@ export default function TabLayout() {
backgroundColor: "#121212",
}}
tabBarActiveTintColor={Colors.primary}
scrollEdgeAppearance="default"
scrollEdgeAppearance='default'
>
<NativeTabs.Screen redirect name="index" />
<NativeTabs.Screen redirect name='index' />
<NativeTabs.Screen
listeners={({ navigation }) => ({
tabPress: (e) => {
eventBus.emit("scrollToTop");
},
})}
name="(home)"
name='(home)'
options={{
title: t("tabs.home"),
tabBarIcon:
@@ -87,7 +87,7 @@ export default function TabLayout() {
eventBus.emit("searchTabPressed");
},
})}
name="(search)"
name='(search)'
options={{
title: t("tabs.search"),
tabBarIcon:
@@ -101,7 +101,7 @@ export default function TabLayout() {
}}
/>
<NativeTabs.Screen
name="(favorites)"
name='(favorites)'
options={{
title: t("tabs.favorites"),
tabBarIcon:
@@ -117,7 +117,7 @@ export default function TabLayout() {
}}
/>
<NativeTabs.Screen
name="(libraries)"
name='(libraries)'
options={{
title: t("tabs.library"),
tabBarIcon:
@@ -131,7 +131,7 @@ export default function TabLayout() {
}}
/>
<NativeTabs.Screen
name="(custom-links)"
name='(custom-links)'
options={{
title: t("tabs.custom_links"),
// @ts-expect-error

View File

@@ -21,7 +21,9 @@ export default function Layout() {
if (settings.followDeviceOrientation === true) {
ScreenOrientation.unlockAsync();
} else {
ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.PORTRAIT_UP);
ScreenOrientation.lockAsync(
ScreenOrientation.OrientationLock.PORTRAIT_UP,
);
}
};
});
@@ -31,7 +33,7 @@ export default function Layout() {
<SystemBars hidden />
<Stack>
<Stack.Screen
name="direct-player"
name='direct-player'
options={{
headerShown: false,
autoHideHomeIndicator: true,

View File

@@ -1,13 +1,13 @@
import { BITRATES } from "@/components/BitrateSelector";
import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader";
import { Text } from "@/components/common/Text";
import { Controls } from "@/components/video-player/controls/Controls";
import { getDownloadedFileUrl } from "@/hooks/useDownloadedFileOpener";
import { useHaptic } from "@/hooks/useHaptic";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import { useWebSocket } from "@/hooks/useWebsockets";
import { VlcPlayerView } from "@/modules";
import {
import type {
PipStartedPayload,
PlaybackStatePayload,
ProgressUpdatePayload,
@@ -20,22 +20,33 @@ import { writeToLog } from "@/utils/log";
import native from "@/utils/profiles/native";
import { msToTicks, ticksToSeconds } from "@/utils/time";
import {
BaseItemDto,
MediaSourceInfo,
type BaseItemDto,
type MediaSourceInfo,
PlaybackOrder,
PlaybackProgressInfo,
type PlaybackProgressInfo,
RepeatMode,
} from "@jellyfin/sdk/lib/generated-client";
import { getPlaystateApi, getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
import {
getPlaystateApi,
getUserLibraryApi,
} from "@jellyfin/sdk/lib/utils/api";
import { activateKeepAwakeAsync, deactivateKeepAwake } from "expo-keep-awake";
import { useGlobalSearchParams, useNavigation } from "expo-router";
import { useAtomValue } from "jotai";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import { Alert, Platform, View } from "react-native";
import { useSharedValue } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
const downloadProvider = !Platform.isTV ? require("@/providers/DownloadProvider") : null;
const downloadProvider = !Platform.isTV
? require("@/providers/DownloadProvider")
: null;
export default function page() {
const videoRef = useRef<VlcPlayerViewRef>(null);
@@ -88,9 +99,15 @@ export default function page() {
const insets = useSafeAreaInsets();
const offline = offlineStr === "true";
const audioIndex = audioIndexStr ? parseInt(audioIndexStr, 10) : undefined;
const subtitleIndex = subtitleIndexStr ? parseInt(subtitleIndexStr, 10) : -1;
const bitrateValue = bitrateValueStr ? parseInt(bitrateValueStr, 10) : BITRATES[0].value;
const audioIndex = audioIndexStr
? Number.parseInt(audioIndexStr, 10)
: undefined;
const subtitleIndex = subtitleIndexStr
? Number.parseInt(subtitleIndexStr, 10)
: -1;
const bitrateValue = bitrateValueStr
? Number.parseInt(bitrateValueStr, 10)
: BITRATES[0].value;
const [item, setItem] = useState<BaseItemDto | null>(null);
const [itemStatus, setItemStatus] = useState({
@@ -165,7 +182,10 @@ export default function page() {
if (!res) return;
const { mediaSource, sessionId, url } = res;
if (!sessionId || !mediaSource || !url) {
Alert.alert(t("player.error"), t("player.failed_to_get_stream_url"));
Alert.alert(
t("player.error"),
t("player.failed_to_get_stream_url"),
);
return;
}
result = { mediaSource, sessionId, url };
@@ -252,7 +272,17 @@ export default function page() {
reportPlaybackProgress();
},
[item?.Id, audioIndex, subtitleIndex, mediaSourceId, isPlaying, stream, isSeeking, isPlaybackStopped, isBuffering]
[
item?.Id,
audioIndex,
subtitleIndex,
mediaSourceId,
isPlaying,
stream,
isSeeking,
isPlaybackStopped,
isBuffering,
],
);
const onPipStarted = useCallback((e: PipStartedPayload) => {
@@ -265,11 +295,23 @@ export default function page() {
await getPlaystateApi(api).reportPlaybackProgress({
playbackProgressInfo: currentPlayStateInfo() as PlaybackProgressInfo,
});
}, [api, isPlaying, offline, stream, item?.Id, audioIndex, subtitleIndex, mediaSourceId, progress]);
}, [
api,
isPlaying,
offline,
stream,
item?.Id,
audioIndex,
subtitleIndex,
mediaSourceId,
progress,
]);
const startPosition = useMemo(() => {
if (offline) return 0;
return item?.UserData?.PlaybackPositionTicks ? ticksToSeconds(item.UserData.PlaybackPositionTicks) : 0;
return item?.UserData?.PlaybackPositionTicks
? ticksToSeconds(item.UserData.PlaybackPositionTicks)
: 0;
}, [item]);
useWebSocket({
@@ -303,16 +345,19 @@ export default function page() {
setIsBuffering(true);
}
},
[reportPlaybackProgress]
[reportPlaybackProgress],
);
const allAudio = stream?.mediaSource.MediaStreams?.filter((audio) => audio.Type === "Audio") || [];
const allAudio =
stream?.mediaSource.MediaStreams?.filter(
(audio) => audio.Type === "Audio",
) || [];
// Move all the external subtitles last, because vlc places them last.
const allSubs =
stream?.mediaSource.MediaStreams?.filter((sub) => sub.Type === "Subtitle").sort(
(a, b) => Number(a.IsExternal) - Number(b.IsExternal)
) || [];
stream?.mediaSource.MediaStreams?.filter(
(sub) => sub.Type === "Subtitle",
).sort((a, b) => Number(a.IsExternal) - Number(b.IsExternal)) || [];
const externalSubtitles = allSubs
.filter((sub: any) => sub.DeliveryMethod === "External")
@@ -323,13 +368,20 @@ export default function page() {
const textSubs = allSubs.filter((sub) => sub.IsTextSubtitleStream);
const chosenSubtitleTrack = allSubs.find((sub) => sub.Index === subtitleIndex);
const chosenSubtitleTrack = allSubs.find(
(sub) => sub.Index === subtitleIndex,
);
const chosenAudioTrack = allAudio.find((audio) => audio.Index === audioIndex);
const notTranscoding = !stream?.mediaSource.TranscodingUrl;
let initOptions = [`--sub-text-scale=${settings.subtitleSize}`];
if (chosenSubtitleTrack && (notTranscoding || chosenSubtitleTrack.IsTextSubtitleStream)) {
const finalIndex = notTranscoding ? allSubs.indexOf(chosenSubtitleTrack) : textSubs.indexOf(chosenSubtitleTrack);
const initOptions = [`--sub-text-scale=${settings.subtitleSize}`];
if (
chosenSubtitleTrack &&
(notTranscoding || chosenSubtitleTrack.IsTextSubtitleStream)
) {
const finalIndex = notTranscoding
? allSubs.indexOf(chosenSubtitleTrack)
: textSubs.indexOf(chosenSubtitleTrack);
initOptions.push(`--sub-track=${finalIndex}`);
}
@@ -347,7 +399,7 @@ export default function page() {
if (itemStatus.isLoading || streamStatus.isLoading) {
return (
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
<View className='w-screen h-screen flex flex-col items-center justify-center bg-black'>
<Loader />
</View>
);
@@ -355,8 +407,8 @@ export default function page() {
if (!item || !stream || itemStatus.isError || streamStatus.isError)
return (
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
<Text className="text-white">{t("player.error")}</Text>
<View className='w-screen h-screen flex flex-col items-center justify-center bg-black'>
<Text className='text-white'>{t("player.error")}</Text>
</View>
);
@@ -394,7 +446,10 @@ export default function page() {
}}
onVideoError={(e) => {
console.error("Video Error:", e.nativeEvent);
Alert.alert(t("player.error"), t("player.an_error_occured_while_playing_the_video"));
Alert.alert(
t("player.error"),
t("player.an_error_occured_while_playing_the_video"),
);
writeToLog("ERROR", "Video Error", e.nativeEvent);
}}
/>

View File

@@ -1,5 +1,5 @@
import { ScrollViewStyleReset } from "expo-router/html";
import { type PropsWithChildren } from "react";
import type { PropsWithChildren } from "react";
/**
* This file is web-only and used to configure the root HTML for every web page during static rendering.
@@ -7,13 +7,13 @@ import { type PropsWithChildren } from "react";
*/
export default function Root({ children }: PropsWithChildren) {
return (
<html lang="en">
<html lang='en'>
<head>
<meta charSet="utf-8" />
<meta httpEquiv="X-UA-Compatible" content="IE=edge" />
<meta charSet='utf-8' />
<meta httpEquiv='X-UA-Compatible' content='IE=edge' />
<meta
name="viewport"
content="width=device-width, initial-scale=1, shrink-to-fit=no"
name='viewport'
content='width=device-width, initial-scale=1, shrink-to-fit=no'
/>
{/*

View File

@@ -9,9 +9,9 @@ export default function NotFoundScreen() {
<>
<Stack.Screen options={{ title: "Oops!" }} />
<ThemedView style={styles.container}>
<ThemedText type="title">This screen doesn't exist.</ThemedText>
<ThemedText type='title'>This screen doesn't exist.</ThemedText>
<Link href={"/home"} style={styles.link}>
<ThemedText type="link">Go to home screen!</ThemedText>
<ThemedText type='link'>Go to home screen!</ThemedText>
</Link>
</ThemedView>
</>

View File

@@ -1,49 +1,61 @@
import "@/augmentations";
import { Platform } from "react-native";
import i18n from "@/i18n";
import { DownloadProvider } from "@/providers/DownloadProvider";
import { getOrSetDeviceId, getTokenFromStorage, JellyfinProvider, apiAtom } from "@/providers/JellyfinProvider";
import {
JellyfinProvider,
apiAtom,
getOrSetDeviceId,
getTokenFromStorage,
} from "@/providers/JellyfinProvider";
import { JobQueueProvider } from "@/providers/JobQueueProvider";
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
import { WebSocketProvider } from "@/providers/WebSocketProvider";
import { Settings, useSettings } from "@/utils/atoms/settings";
import { type Settings, useSettings } from "@/utils/atoms/settings";
import {
BACKGROUND_FETCH_TASK,
BACKGROUND_FETCH_TASK_SESSIONS,
registerBackgroundFetchAsyncSessions,
} from "@/utils/background-tasks";
import {LogProvider, writeErrorLog, writeToLog} from "@/utils/log";
import { LogProvider, writeErrorLog, writeToLog } from "@/utils/log";
import { storage } from "@/utils/mmkv";
import { cancelJobById, getAllJobsByDeviceId } from "@/utils/optimize-server";
import { ActionSheetProvider } from "@expo/react-native-action-sheet";
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
const BackGroundDownloader = !Platform.isTV ? require("@kesha-antonov/react-native-background-downloader") : null;
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { Platform } from "react-native";
const BackGroundDownloader = !Platform.isTV
? require("@kesha-antonov/react-native-background-downloader")
: null;
import { DarkTheme, ThemeProvider } from "@react-navigation/native";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const BackgroundFetch = !Platform.isTV ? require("expo-background-fetch") : null;
const BackgroundFetch = !Platform.isTV
? require("expo-background-fetch")
: null;
import * as FileSystem from "expo-file-system";
const Notifications = !Platform.isTV ? require("expo-notifications") : null;
import { router, Stack, useSegments } from "expo-router";
import * as SplashScreen from "expo-splash-screen";
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { Stack, router, useSegments } from "expo-router";
import * as SplashScreen from "expo-splash-screen";
const TaskManager = !Platform.isTV ? require("expo-task-manager") : null;
import { getLocales } from "expo-localization";
import { Provider as JotaiProvider } from "jotai";
import {useEffect, useRef, useState} from "react";
import { useEffect, useRef, useState } from "react";
import { I18nextProvider } from "react-i18next";
import { Appearance, AppState } from "react-native";
import { AppState, Appearance } from "react-native";
import { SystemBars } from "react-native-edge-to-edge";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import "react-native-reanimated";
import { Toaster } from "sonner-native";
import { useAtom } from "jotai";
import { userAtom } from "@/providers/JellyfinProvider";
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
import { store } from "@/utils/store";
import {EventSubscription} from "expo-modules-core";
import {ExpoPushToken} from "expo-notifications/build/Tokens.types";
import {Notification, NotificationResponse} from "expo-notifications/build/Notifications.types";
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
import type { EventSubscription } from "expo-modules-core";
import type {
Notification,
NotificationResponse,
} from "expo-notifications/build/Notifications.types";
import type { ExpoPushToken } from "expo-notifications/build/Tokens.types";
import { useAtom } from "jotai";
import { Toaster } from "sonner-native";
if (!Platform.isTV) {
Notifications.setNotificationHandler({
@@ -77,16 +89,20 @@ function useNotificationObserver() {
}
}
Notifications.getLastNotificationResponseAsync().then((response: { notification: any }) => {
if (!isMounted || !response?.notification) {
return;
}
redirect(response?.notification);
});
Notifications.getLastNotificationResponseAsync().then(
(response: { notification: any }) => {
if (!isMounted || !response?.notification) {
return;
}
redirect(response?.notification);
},
);
const subscription = Notifications.addNotificationResponseReceivedListener((response: { notification: any }) => {
redirect(response.notification);
});
const subscription = Notifications.addNotificationResponseReceivedListener(
(response: { notification: any }) => {
redirect(response.notification);
},
);
return () => {
isMounted = false;
@@ -124,13 +140,15 @@ if (!Platform.isTV) {
const settings: Partial<Settings> = JSON.parse(settingsData);
const url = settings?.optimizedVersionsServerUrl;
if (!settings?.autoDownload || !url) return BackgroundFetch.BackgroundFetchResult.NoData;
if (!settings?.autoDownload || !url)
return BackgroundFetch.BackgroundFetchResult.NoData;
const token = getTokenFromStorage();
const deviceId = getOrSetDeviceId();
const baseDirectory = FileSystem.documentDirectory;
if (!token || !deviceId || !baseDirectory) return BackgroundFetch.BackgroundFetchResult.NoData;
if (!token || !deviceId || !baseDirectory)
return BackgroundFetch.BackgroundFetchResult.NoData;
const jobs = await getAllJobsByDeviceId({
deviceId,
@@ -140,7 +158,7 @@ if (!Platform.isTV) {
console.log("TaskManager ~ Active jobs: ", jobs.length);
for (let job of jobs) {
for (const job of jobs) {
if (job.status === "completed") {
const downloadUrl = url + "download/" + job.id;
const tasks = await BackGroundDownloader.checkForExistingDownloads();
@@ -207,7 +225,9 @@ if (!Platform.isTV) {
const checkAndRequestPermissions = async () => {
try {
const hasAskedBefore = storage.getString("hasAskedForNotificationPermission");
const hasAskedBefore = storage.getString(
"hasAskedForNotificationPermission",
);
if (hasAskedBefore !== "true") {
const { status } = await Notifications.requestPermissionsAsync();
@@ -225,7 +245,11 @@ const checkAndRequestPermissions = async () => {
console.log("Already asked for notification permissions before.");
}
} catch (error) {
writeToLog("ERROR", "Error checking/requesting notification permissions:", error);
writeToLog(
"ERROR",
"Error checking/requesting notification permissions:",
error,
);
console.error("Error checking/requesting notification permissions:", error);
}
};
@@ -266,7 +290,9 @@ function Layout() {
const segments = useSegments();
useEffect(() => {
i18n.changeLanguage(settings?.preferedLanguage ?? getLocales()[0].languageCode ?? "en");
i18n.changeLanguage(
settings?.preferedLanguage ?? getLocales()[0].languageCode ?? "en",
);
}, [settings?.preferedLanguage, i18n]);
if (!Platform.isTV) {
@@ -278,21 +304,24 @@ function Layout() {
useEffect(() => {
if (expoPushToken && api && user) {
api?.post("/Streamyfin/device", {
token: expoPushToken.data,
deviceId: getOrSetDeviceId(),
userId: user.Id
}).then(_ => console.log("Posted expo push token"))
.catch(_ => writeErrorLog("Failed to push expo push token to plugin"))
}
else console.log("No token available")
api
?.post("/Streamyfin/device", {
token: expoPushToken.data,
deviceId: getOrSetDeviceId(),
userId: user.Id,
})
.then((_) => console.log("Posted expo push token"))
.catch((_) =>
writeErrorLog("Failed to push expo push token to plugin"),
);
} else console.log("No token available");
}, [api, expoPushToken, user]);
async function registerNotifications() {
if (Platform.OS === 'android') {
console.log("Setting android notification channel 'default'")
await Notifications?.setNotificationChannelAsync('default', {
name: 'default'
if (Platform.OS === "android") {
console.log("Setting android notification channel 'default'");
await Notifications?.setNotificationChannelAsync("default", {
name: "default",
});
}
@@ -308,22 +337,35 @@ function Layout() {
}
useEffect(() => {
registerNotifications()
registerNotifications();
notificationListener.current = Notifications?.addNotificationReceivedListener((notification: Notification) => {
console.log("Notification received while app running", notification);
});
notificationListener.current =
Notifications?.addNotificationReceivedListener(
(notification: Notification) => {
console.log(
"Notification received while app running",
notification,
);
},
);
responseListener.current = Notifications?.addNotificationResponseReceivedListener((response: NotificationResponse) => {
console.log("Notification interacted with", response);
});
responseListener.current =
Notifications?.addNotificationResponseReceivedListener(
(response: NotificationResponse) => {
console.log("Notification interacted with", response);
},
);
return () => {
notificationListener.current &&
Notifications?.removeNotificationSubscription(notificationListener.current);
Notifications?.removeNotificationSubscription(
notificationListener.current,
);
responseListener.current &&
Notifications?.removeNotificationSubscription(responseListener.current);
}
Notifications?.removeNotificationSubscription(
responseListener.current,
);
};
}, []);
useEffect(() => {
@@ -337,16 +379,24 @@ function Layout() {
ScreenOrientation.unlockAsync();
} else {
// If the user has auto rotate disabled, lock the orientation to portrait
ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.PORTRAIT_UP);
ScreenOrientation.lockAsync(
ScreenOrientation.OrientationLock.PORTRAIT_UP,
);
}
}, [settings.followDeviceOrientation, segments]);
useEffect(() => {
const subscription = AppState.addEventListener("change", (nextAppState) => {
if (appState.current.match(/inactive|background/) && nextAppState === "active") {
BackGroundDownloader.checkForExistingDownloads();
}
});
const subscription = AppState.addEventListener(
"change",
(nextAppState) => {
if (
appState.current.match(/inactive|background/) &&
nextAppState === "active"
) {
BackGroundDownloader.checkForExistingDownloads();
}
},
);
BackGroundDownloader.checkForExistingDownloads();
@@ -365,11 +415,11 @@ function Layout() {
<WebSocketProvider>
<DownloadProvider>
<BottomSheetModalProvider>
<SystemBars style="light" hidden={false} />
<SystemBars style='light' hidden={false} />
<ThemeProvider value={DarkTheme}>
<Stack initialRouteName="(auth)/(tabs)">
<Stack initialRouteName='(auth)/(tabs)'>
<Stack.Screen
name="(auth)/(tabs)"
name='(auth)/(tabs)'
options={{
headerShown: false,
title: "",
@@ -377,7 +427,7 @@ function Layout() {
}}
/>
<Stack.Screen
name="(auth)/player"
name='(auth)/player'
options={{
headerShown: false,
title: "",
@@ -385,14 +435,14 @@ function Layout() {
}}
/>
<Stack.Screen
name="login"
name='login'
options={{
headerShown: true,
title: "",
headerTransparent: true,
}}
/>
<Stack.Screen name="+not-found" />
<Stack.Screen name='+not-found' />
</Stack>
<Toaster
duration={4000}
@@ -423,7 +473,9 @@ function Layout() {
function saveDownloadedItemInfo(item: BaseItemDto) {
try {
const downloadedItems = storage.getString("downloadedItems");
let items: BaseItemDto[] = downloadedItems ? JSON.parse(downloadedItems) : [];
const items: BaseItemDto[] = downloadedItems
? JSON.parse(downloadedItems)
: [];
const existingItemIndex = items.findIndex((i) => i.Id === item.Id);
if (existingItemIndex !== -1) {

View File

@@ -1,16 +1,17 @@
import { Button } from "@/components/Button";
import { Input } from "@/components/common/Input";
import { Text } from "@/components/common/Text";
import JellyfinServerDiscovery from "@/components/JellyfinServerDiscovery";
import { PreviousServersList } from "@/components/PreviousServersList";
import { Input } from "@/components/common/Input";
import { Text } from "@/components/common/Text";
import { Colors } from "@/constants/Colors";
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
import { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
import type { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation } from "expo-router";
import { useAtomValue } from "jotai";
import React, { useCallback, useEffect, useState } from "react";
import type React from "react";
import { useCallback, useEffect, useState } from "react";
import {
Alert,
KeyboardAvoidingView,
@@ -21,8 +22,8 @@ import {
} from "react-native";
import { Keyboard } from "react-native";
import { z } from "zod";
import { t } from "i18next";
import { z } from "zod";
const CredentialsSchema = z.object({
username: z.string().min(1, t("login.username_required")),
});
@@ -81,10 +82,10 @@ const Login: React.FC = () => {
onPress={() => {
removeServer();
}}
className="flex flex-row items-center"
className='flex flex-row items-center'
>
<Ionicons name="chevron-back" size={18} color={Colors.primary} />
<Text className="ml-2 text-purple-600">
<Ionicons name='chevron-back' size={18} color={Colors.primary} />
<Text className='ml-2 text-purple-600'>
{t("login.change_server")}
</Text>
</TouchableOpacity>
@@ -107,7 +108,7 @@ const Login: React.FC = () => {
} else {
Alert.alert(
t("login.connection_failed"),
t("login.an_unexpected_error_occured")
t("login.an_unexpected_error_occured"),
);
}
} finally {
@@ -176,7 +177,7 @@ const Login: React.FC = () => {
if (result === undefined) {
Alert.alert(
t("login.connection_failed"),
t("login.could_not_connect_to_server")
t("login.could_not_connect_to_server"),
);
return;
}
@@ -195,13 +196,13 @@ const Login: React.FC = () => {
{
text: t("login.got_it"),
},
]
],
);
}
} catch (error) {
Alert.alert(
t("login.error_title"),
t("login.failed_to_initiate_quick_connect")
t("login.failed_to_initiate_quick_connect"),
);
}
};
@@ -213,22 +214,22 @@ const Login: React.FC = () => {
>
{api?.basePath ? (
<>
<View className="flex flex-col h-full relative items-center justify-center">
<View className="px-4 -mt-20 w-full">
<View className="flex flex-col space-y-2">
<Text className="text-2xl font-bold -mb-2">
<View className='flex flex-col h-full relative items-center justify-center'>
<View className='px-4 -mt-20 w-full'>
<View className='flex flex-col space-y-2'>
<Text className='text-2xl font-bold -mb-2'>
<>
{serverName ? (
<>
{t("login.login_to_title") + " "}
<Text className="text-purple-600">{serverName}</Text>
<Text className='text-purple-600'>{serverName}</Text>
</>
) : (
t("login.login_title")
)}
</>
</Text>
<Text className="text-xs text-neutral-400">
<Text className='text-xs text-neutral-400'>
{api.basePath}
</Text>
<Input
@@ -237,13 +238,13 @@ const Login: React.FC = () => {
setCredentials({ ...credentials, username: text })
}
value={credentials.username}
keyboardType="default"
returnKeyType="done"
autoCapitalize="none"
keyboardType='default'
returnKeyType='done'
autoCapitalize='none'
// Changed from username to oneTimeCode because it is a known issue in RN
// https://github.com/facebook/react-native/issues/47106#issuecomment-2521270037
textContentType="oneTimeCode"
clearButtonMode="while-editing"
textContentType='oneTimeCode'
clearButtonMode='while-editing'
maxLength={500}
/>
@@ -254,42 +255,42 @@ const Login: React.FC = () => {
}
value={credentials.password}
secureTextEntry
keyboardType="default"
returnKeyType="done"
autoCapitalize="none"
textContentType="password"
clearButtonMode="while-editing"
keyboardType='default'
returnKeyType='done'
autoCapitalize='none'
textContentType='password'
clearButtonMode='while-editing'
maxLength={500}
/>
<View className="flex flex-row items-center justify-between">
<View className='flex flex-row items-center justify-between'>
<Button
onPress={handleLogin}
loading={loading}
className="flex-1 mr-2"
className='flex-1 mr-2'
>
{t("login.login_button")}
</Button>
<TouchableOpacity
onPress={handleQuickConnect}
className="p-2 bg-neutral-900 rounded-xl h-12 w-12 flex items-center justify-center"
className='p-2 bg-neutral-900 rounded-xl h-12 w-12 flex items-center justify-center'
>
<MaterialCommunityIcons
name="cellphone-lock"
name='cellphone-lock'
size={24}
color="white"
color='white'
/>
</TouchableOpacity>
</View>
</View>
</View>
<View className="absolute bottom-0 left-0 w-full px-4 mb-2"></View>
<View className='absolute bottom-0 left-0 w-full px-4 mb-2'></View>
</View>
</>
) : (
<>
<View className="flex flex-col h-full items-center justify-center w-full">
<View className="flex flex-col gap-y-2 px-4 w-full -mt-36">
<View className='flex flex-col h-full items-center justify-center w-full'>
<View className='flex flex-col gap-y-2 px-4 w-full -mt-36'>
<Image
style={{
width: 100,
@@ -299,19 +300,19 @@ const Login: React.FC = () => {
}}
source={require("@/assets/images/StreamyFinFinal.png")}
/>
<Text className="text-3xl font-bold">Streamyfin</Text>
<Text className="text-neutral-500">
<Text className='text-3xl font-bold'>Streamyfin</Text>
<Text className='text-neutral-500'>
{t("server.enter_url_to_jellyfin_server")}
</Text>
<Input
aria-label="Server URL"
aria-label='Server URL'
placeholder={t("server.server_url_placeholder")}
onChangeText={setServerURL}
value={serverURL}
keyboardType="url"
returnKeyType="done"
autoCapitalize="none"
textContentType="URL"
keyboardType='url'
returnKeyType='done'
autoCapitalize='none'
textContentType='URL'
maxLength={500}
/>
<Button
@@ -320,7 +321,7 @@ const Login: React.FC = () => {
onPress={async () => {
await handleConnect(serverURL);
}}
className="w-full grow"
className='w-full grow'
>
{t("server.connect_button")}
</Button>