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

1
.husky/pre-commit Normal file
View File

@@ -0,0 +1 @@
lint-staged

44
.vscode/settings.json vendored
View File

@@ -1,24 +1,24 @@
{ {
"[javascript]": { "[javascript]": {
"editor.defaultFormatter": "biomejs.biome", "editor.defaultFormatter": "biomejs.biome",
"editor.formatOnSave": true "editor.formatOnSave": true
}, },
"[typescriptreact]": { "[typescriptreact]": {
"editor.defaultFormatter": "biomejs.biome", "editor.defaultFormatter": "biomejs.biome",
"editor.formatOnSave": true "editor.formatOnSave": true
}, },
"prettier.printWidth": 120, "prettier.printWidth": 120,
"[swift]": { "[swift]": {
"editor.defaultFormatter": "sswg.swift-lang" "editor.defaultFormatter": "sswg.swift-lang"
}, },
"editor.formatOnSave": true, "editor.formatOnSave": true,
"editor.defaultFormatter": "biomejs.biome", "editor.defaultFormatter": "biomejs.biome",
"[typescript]": { "[typescript]": {
"editor.defaultFormatter": "biomejs.biome", "editor.defaultFormatter": "biomejs.biome",
"editor.formatOnSave": true "editor.formatOnSave": true
}, },
"[javascriptreact]": { "[javascriptreact]": {
"editor.defaultFormatter": "biomejs.biome", "editor.defaultFormatter": "biomejs.biome",
"editor.formatOnSave": true "editor.formatOnSave": true
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,16 +1,16 @@
import { Text } from "@/components/common/Text"; 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 { EpisodeCard } from "@/components/downloads/EpisodeCard";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { import {
SeasonDropdown, SeasonDropdown,
SeasonIndexState, type SeasonIndexState,
} from "@/components/series/SeasonDropdown"; } from "@/components/series/SeasonDropdown";
import { useDownload } from "@/providers/DownloadProvider";
import { storage } from "@/utils/mmkv"; import { storage } from "@/utils/mmkv";
import { Ionicons } from "@expo/vector-icons"; 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() { export default function page() {
const navigation = useNavigation(); const navigation = useNavigation();
@@ -21,7 +21,7 @@ export default function page() {
}; };
const [seasonIndexState, setSeasonIndexState] = useState<SeasonIndexState>( const [seasonIndexState, setSeasonIndexState] = useState<SeasonIndexState>(
{} {},
); );
const { downloadedFiles, deleteItems } = useDownload(); const { downloadedFiles, deleteItems } = useDownload();
@@ -31,7 +31,7 @@ export default function page() {
downloadedFiles downloadedFiles
?.filter((f) => f.item.SeriesId == seriesId) ?.filter((f) => f.item.SeriesId == seriesId)
?.sort( ?.sort(
(a, b) => a?.item.ParentIndexNumber! - b.item.ParentIndexNumber! (a, b) => a?.item.ParentIndexNumber! - b.item.ParentIndexNumber!,
) || [] ) || []
); );
} catch { } catch {
@@ -64,7 +64,7 @@ export default function page() {
() => () =>
Object.values(groupBySeason)?.[0]?.ParentIndexNumber ?? Object.values(groupBySeason)?.[0]?.ParentIndexNumber ??
series?.[0]?.item?.ParentIndexNumber, series?.[0]?.item?.ParentIndexNumber,
[groupBySeason] [groupBySeason],
); );
useEffect(() => { useEffect(() => {
@@ -92,14 +92,14 @@ export default function page() {
onPress: () => deleteItems(groupBySeason), onPress: () => deleteItems(groupBySeason),
style: "destructive", style: "destructive",
}, },
] ],
); );
}, [groupBySeason]); }, [groupBySeason]);
return ( return (
<View className="flex-1"> <View className='flex-1'>
{series.length > 0 && ( {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 <SeasonDropdown
item={series[0].item} item={series[0].item}
seasons={series.map((s) => s.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"> <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> <Text className='text-xs font-bold'>{groupBySeason.length}</Text>
</View> </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}> <TouchableOpacity onPress={deleteSeries}>
<Ionicons name="trash" size={20} color="white" /> <Ionicons name='trash' size={20} color='white' />
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</View> </View>
)} )}
<ScrollView key={seasonIndex} className="px-4"> <ScrollView key={seasonIndex} className='px-4'>
{groupBySeason.map((episode, index) => ( {groupBySeason.map((episode, index) => (
<EpisodeCard key={index} item={episode} /> <EpisodeCard key={index} item={episode} />
))} ))}

View File

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

View File

@@ -15,26 +15,26 @@ export default function page() {
useFocusEffect( useFocusEffect(
useCallback(() => { useCallback(() => {
storage.set("hasShownIntro", true); storage.set("hasShownIntro", true);
}, []) }, []),
); );
return ( 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> <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")} {t("home.intro.welcome_to_streamyfin")}
</Text> </Text>
<Text className="text-center"> <Text className='text-center'>
{t("home.intro.a_free_and_open_source_client_for_jellyfin")} {t("home.intro.a_free_and_open_source_client_for_jellyfin")}
</Text> </Text>
</View> </View>
<View> <View>
<Text className="text-lg font-bold"> <Text className='text-lg font-bold'>
{t("home.intro.features_title")} {t("home.intro.features_title")}
</Text> </Text>
<Text className="text-xs">{t("home.intro.features_description")}</Text> <Text className='text-xs'>{t("home.intro.features_description")}</Text>
<View className="flex flex-row items-center mt-4"> <View className='flex flex-row items-center mt-4'>
<Image <Image
source={require("@/assets/icons/jellyseerr-logo.svg")} source={require("@/assets/icons/jellyseerr-logo.svg")}
style={{ style={{
@@ -42,70 +42,70 @@ export default function page() {
height: 50, height: 50,
}} }}
/> />
<View className="shrink ml-2"> <View className='shrink ml-2'>
<Text className="font-bold mb-1">Jellyseerr</Text> <Text className='font-bold mb-1'>Jellyseerr</Text>
<Text className="shrink text-xs"> <Text className='shrink text-xs'>
{t("home.intro.jellyseerr_feature_description")} {t("home.intro.jellyseerr_feature_description")}
</Text> </Text>
</View> </View>
</View> </View>
<View className="flex flex-row items-center mt-4"> <View className='flex flex-row items-center mt-4'>
<View <View
style={{ style={{
width: 50, width: 50,
height: 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>
<View className="shrink ml-2"> <View className='shrink ml-2'>
<Text className="font-bold mb-1"> <Text className='font-bold mb-1'>
{t("home.intro.downloads_feature_title")} {t("home.intro.downloads_feature_title")}
</Text> </Text>
<Text className="shrink text-xs"> <Text className='shrink text-xs'>
{t("home.intro.downloads_feature_description")} {t("home.intro.downloads_feature_description")}
</Text> </Text>
</View> </View>
</View> </View>
<View className="flex flex-row items-center mt-4"> <View className='flex flex-row items-center mt-4'>
<View <View
style={{ style={{
width: 50, width: 50,
height: 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>
<View className="shrink ml-2"> <View className='shrink ml-2'>
<Text className="font-bold mb-1">Chromecast</Text> <Text className='font-bold mb-1'>Chromecast</Text>
<Text className="shrink text-xs"> <Text className='shrink text-xs'>
{t("home.intro.chromecast_feature_description")} {t("home.intro.chromecast_feature_description")}
</Text> </Text>
</View> </View>
</View> </View>
<View className="flex flex-row items-center mt-4"> <View className='flex flex-row items-center mt-4'>
<View <View
style={{ style={{
width: 50, width: 50,
height: 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>
<View className="shrink ml-2"> <View className='shrink ml-2'>
<Text className="font-bold mb-1"> <Text className='font-bold mb-1'>
{t("home.intro.centralised_settings_plugin_title")} {t("home.intro.centralised_settings_plugin_title")}
</Text> </Text>
<Text className="shrink text-xs"> <Text className='shrink text-xs'>
{t("home.intro.centralised_settings_plugin_description")}{" "} {t("home.intro.centralised_settings_plugin_description")}{" "}
<Text <Text
className="text-purple-600" className='text-purple-600'
onPress={() => { onPress={() => {
Linking.openURL( 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={() => { onPress={() => {
router.back(); router.back();
}} }}
className="mt-4" className='mt-4'
> >
{t("home.intro.done_button")} {t("home.intro.done_button")}
</Button> </Button>
@@ -129,9 +129,9 @@ export default function page() {
router.back(); router.back();
router.push("/settings"); 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")} {t("home.intro.go_to_settings_button")}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>

View File

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

View File

@@ -3,6 +3,7 @@ import { ListGroup } from "@/components/list/ListGroup";
import { ListItem } from "@/components/list/ListItem"; import { ListItem } from "@/components/list/ListItem";
import { AppLanguageSelector } from "@/components/settings/AppLanguageSelector"; import { AppLanguageSelector } from "@/components/settings/AppLanguageSelector";
import { AudioToggles } from "@/components/settings/AudioToggles"; import { AudioToggles } from "@/components/settings/AudioToggles";
import { ChromecastSettings } from "@/components/settings/ChromecastSettings";
import DownloadSettings from "@/components/settings/DownloadSettings"; import DownloadSettings from "@/components/settings/DownloadSettings";
import { MediaProvider } from "@/components/settings/MediaContext"; import { MediaProvider } from "@/components/settings/MediaContext";
import { MediaToggles } from "@/components/settings/MediaToggles"; import { MediaToggles } from "@/components/settings/MediaToggles";
@@ -14,16 +15,15 @@ import { SubtitleToggles } from "@/components/settings/SubtitleToggles";
import { UserInfo } from "@/components/settings/UserInfo"; import { UserInfo } from "@/components/settings/UserInfo";
import { useHaptic } from "@/hooks/useHaptic"; import { useHaptic } from "@/hooks/useHaptic";
import { useJellyfin } from "@/providers/JellyfinProvider"; import { useJellyfin } from "@/providers/JellyfinProvider";
import { userAtom } from "@/providers/JellyfinProvider";
import { clearLogs } from "@/utils/log"; import { clearLogs } from "@/utils/log";
import { storage } from "@/utils/mmkv"; import { storage } from "@/utils/mmkv";
import { useNavigation, useRouter } from "expo-router"; import { useNavigation, useRouter } from "expo-router";
import { t } from "i18next"; import { t } from "i18next";
import { useAtom } from "jotai";
import React, { useEffect } from "react"; import React, { useEffect } from "react";
import { ScrollView, Switch, TouchableOpacity, View } from "react-native"; import { ScrollView, Switch, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; 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() { export default function settings() {
const router = useRouter(); const router = useRouter();
@@ -46,7 +46,7 @@ export default function settings() {
logout(); logout();
}} }}
> >
<Text className="text-red-600"> <Text className='text-red-600'>
{t("home.settings.log_out_button")} {t("home.settings.log_out_button")}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
@@ -61,15 +61,15 @@ export default function settings() {
paddingRight: insets.right, paddingRight: insets.right,
}} }}
> >
<View className="p-4 flex flex-col gap-y-4"> <View className='p-4 flex flex-col gap-y-4'>
<UserInfo /> <UserInfo />
<QuickConnect className="mb-4" /> <QuickConnect className='mb-4' />
<MediaProvider> <MediaProvider>
<MediaToggles className="mb-4" /> <MediaToggles className='mb-4' />
<AudioToggles className="mb-4" /> <AudioToggles className='mb-4' />
<SubtitleToggles className="mb-4" /> <SubtitleToggles className='mb-4' />
</MediaProvider> </MediaProvider>
<OtherSettings /> <OtherSettings />
@@ -90,7 +90,7 @@ export default function settings() {
title={t("home.settings.intro.show_intro")} title={t("home.settings.intro.show_intro")}
/> />
<ListItem <ListItem
textColor="red" textColor='red'
onPress={() => { onPress={() => {
storage.set("hasShownIntro", false); storage.set("hasShownIntro", false);
}} }}
@@ -98,7 +98,7 @@ export default function settings() {
/> />
</ListGroup> </ListGroup>
<View className="mb-4"> <View className='mb-4'>
<ListGroup title={t("home.settings.logs.logs_title")}> <ListGroup title={t("home.settings.logs.logs_title")}>
<ListItem <ListItem
onPress={() => router.push("/settings/logs/page")} onPress={() => router.push("/settings/logs/page")}
@@ -106,7 +106,7 @@ export default function settings() {
title={t("home.settings.logs.logs_title")} title={t("home.settings.logs.logs_title")}
/> />
<ListItem <ListItem
textColor="red" textColor='red'
onPress={onClearLogsClicked} onPress={onClearLogsClicked}
title={t("home.settings.logs.delete_all_logs")} 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 { Text } from "@/components/common/Text";
import { ListGroup } from "@/components/list/ListGroup"; import { ListGroup } from "@/components/list/ListGroup";
import { ListItem } from "@/components/list/ListItem"; import { ListItem } from "@/components/list/ListItem";
import { Loader } from "@/components/Loader"; import DisabledSetting from "@/components/settings/DisabledSetting";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { getUserViewsApi } from "@jellyfin/sdk/lib/utils/api"; import { getUserViewsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { useTranslation } from "react-i18next";
import { Switch, View } from "react-native"; import { Switch, View } from "react-native";
import { useTranslation } from "react-i18next";
import DisabledSetting from "@/components/settings/DisabledSetting";
export default function page() { export default function page() {
const [settings, updateSettings, pluginSettings] = useSettings(); const [settings, updateSettings, pluginSettings] = useSettings();
@@ -18,7 +18,7 @@ export default function page() {
const { t } = useTranslation(); const { t } = useTranslation();
const { data, isLoading: isLoading } = useQuery({ const { data, isLoading } = useQuery({
queryKey: ["user-views", user?.Id], queryKey: ["user-views", user?.Id],
queryFn: async () => { queryFn: async () => {
const response = await getUserViewsApi(api!).getUserViews({ const response = await getUserViewsApi(api!).getUserViews({
@@ -33,7 +33,7 @@ export default function page() {
if (isLoading) if (isLoading)
return ( return (
<View className="mt-4"> <View className='mt-4'>
<Loader /> <Loader />
</View> </View>
); );
@@ -41,7 +41,7 @@ export default function page() {
return ( return (
<DisabledSetting <DisabledSetting
disabled={pluginSettings?.hiddenLibraries?.locked === true} disabled={pluginSettings?.hiddenLibraries?.locked === true}
className="px-4" className='px-4'
> >
<ListGroup> <ListGroup>
{data?.map((view) => ( {data?.map((view) => (
@@ -59,8 +59,8 @@ export default function page() {
</ListItem> </ListItem>
))} ))}
</ListGroup> </ListGroup>
<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.other.select_liraries_you_want_to_hide")} {t("home.settings.other.select_liraries_you_want_to_hide")}
</Text> </Text>
</DisabledSetting> </DisabledSetting>
); );

View File

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

View File

@@ -1,17 +1,17 @@
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { useLog } from "@/utils/log"; import { useLog } from "@/utils/log";
import { ScrollView, View } from "react-native";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ScrollView, View } from "react-native";
export default function page() { export default function page() {
const { logs } = useLog(); const { logs } = useLog();
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<ScrollView className="p-4"> <ScrollView className='p-4'>
<View className="flex flex-col space-y-2"> <View className='flex flex-col space-y-2'>
{logs?.map((log, index) => ( {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 <Text
className={` className={`
mb-1 mb-1
@@ -21,13 +21,15 @@ export default function page() {
> >
{log.level} {log.level}
</Text> </Text>
<Text uiTextView selectable className="text-xs"> <Text uiTextView selectable className='text-xs'>
{log.message} {log.message}
</Text> </Text>
</View> </View>
))} ))}
{logs?.length === 0 && ( {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> </View>
</ScrollView> </ScrollView>

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,11 +1,13 @@
import { Text } from "@/components/common/Text";
import { ItemContent } from "@/components/ItemContent"; import { ItemContent } from "@/components/ItemContent";
import { Text } from "@/components/common/Text";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api"; import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useLocalSearchParams } from "expo-router"; import { useLocalSearchParams } from "expo-router";
import { useAtom } from "jotai"; 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 { View } from "react-native";
import Animated, { import Animated, {
runOnJS, runOnJS,
@@ -13,7 +15,6 @@ import Animated, {
useSharedValue, useSharedValue,
withTiming, withTiming,
} from "react-native-reanimated"; } from "react-native-reanimated";
import { useTranslation } from "react-i18next";
const Page: React.FC = () => { const Page: React.FC = () => {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
@@ -75,36 +76,36 @@ const Page: React.FC = () => {
if (isError) if (isError)
return ( 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> <Text>{t("item_card.could_not_load_item")}</Text>
</View> </View>
); );
return ( return (
<View className="flex flex-1 relative"> <View className='flex flex-1 relative'>
<Animated.View <Animated.View
pointerEvents={"none"} pointerEvents={"none"}
style={[animatedStyle]} 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 <View
style={{ style={{
height: item?.Type === "Episode" ? 300 : 450, 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>
<View className="h-6 bg-neutral-900 rounded mb-4 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-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='h-3 bg-neutral-900 rounded mb-3 w-8'></View>
<View className="flex flex-row space-x-1 mb-8"> <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-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>
<View className="h-3 bg-neutral-900 rounded w-2/3 mb-1"></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-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-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-24 bg-neutral-900 rounded-lg mb-1 w-full'></View>
</Animated.View> </Animated.View>
{item && <ItemContent item={item} />} {item && <ItemContent item={item} />}
</View> </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 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 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() { export default function page() {
const local = useLocalSearchParams(); const local = useLocalSearchParams();
const {jellyseerrApi} = useJellyseerr(); const { jellyseerrApi } = useJellyseerr();
const {companyId, name, image, type} = local as unknown as { const { companyId, name, image, type } = local as unknown as {
companyId: string, companyId: string;
name: string, name: string;
image: string, image: string;
type: DiscoverSliderType type: DiscoverSliderType;
}; };
const {data, fetchNextPage, hasNextPage} = useInfiniteQuery({ const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
queryKey: ["jellyseerr", "company", type, companyId], queryKey: ["jellyseerr", "company", type, companyId],
queryFn: async ({pageParam}) => { queryFn: async ({ pageParam }) => {
let params: any = { const params: any = {
page: Number(pageParam), page: Number(pageParam),
}; };
return jellyseerrApi?.discover( return jellyseerrApi?.discover(
( (type == DiscoverSliderType.NETWORKS
type == DiscoverSliderType.NETWORKS ? Endpoints.DISCOVER_TV_NETWORK
? Endpoints.DISCOVER_TV_NETWORK : Endpoints.DISCOVER_MOVIES_STUDIO) + `/${companyId}`,
: Endpoints.DISCOVER_MOVIES_STUDIO params,
) + `/${companyId}`, );
params
)
}, },
enabled: !!jellyseerrApi && !!companyId, enabled: !!jellyseerrApi && !!companyId,
initialPageParam: 1, initialPageParam: 1,
@@ -46,46 +48,58 @@ export default function page() {
}); });
const flatData = useMemo( 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( const backdrops = useMemo(
() => jellyseerrApi () =>
? flatData.map((r) => jellyseerrApi.imageProxy((r as TvResult | MovieResult).backdropPath, "w1920_and_h800_multi_faces")) jellyseerrApi
: [], ? flatData.map((r) =>
[jellyseerrApi, flatData] jellyseerrApi.imageProxy(
(r as TvResult | MovieResult).backdropPath,
"w1920_and_h800_multi_faces",
),
)
: [],
[jellyseerrApi, flatData],
); );
return ( return (
<ParallaxSlideShow <ParallaxSlideShow
data={flatData} data={flatData}
images={backdrops} images={backdrops}
listHeader="" listHeader=''
keyExtractor={(item) => item.id.toString()} keyExtractor={(item) => item.id.toString()}
onEndReached={() => { onEndReached={() => {
if (hasNextPage) { if (hasNextPage) {
fetchNextPage() fetchNextPage();
} }
}} }}
logo={ logo={
<Image <Image
id={companyId} id={companyId}
key={companyId} key={companyId}
className="bottom-1 w-1/2" className='bottom-1 w-1/2'
source={{ source={{
uri: jellyseerrApi?.imageProxy(image, COMPANY_LOGO_IMAGE_FILTER), uri: jellyseerrApi?.imageProxy(image, COMPANY_LOGO_IMAGE_FILTER),
}} }}
cachePolicy={"memory-disk"} cachePolicy={"memory-disk"}
contentFit="contain" contentFit='contain'
style={{ style={{
aspectRatio: "4/3", aspectRatio: "4/3",
}} }}
/> />
} }
renderItem={(item, index) => renderItem={(item, index) => (
<JellyseerrPoster item={item as MovieResult | TvResult} /> <JellyseerrPoster item={item as MovieResult | TvResult} />
} )}
/> />
); );
} }

View File

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

View File

@@ -1,27 +1,29 @@
import { Button } from "@/components/Button"; import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { GenreTags } from "@/components/GenreTags"; import { GenreTags } from "@/components/GenreTags";
import Cast from "@/components/jellyseerr/Cast";
import DetailFacts from "@/components/jellyseerr/DetailFacts";
import { OverviewText } from "@/components/OverviewText"; import { OverviewText } from "@/components/OverviewText";
import { ParallaxScrollView } from "@/components/ParallaxPage"; import { ParallaxScrollView } from "@/components/ParallaxPage";
import { JellyserrRatings } from "@/components/Ratings"; 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 JellyseerrSeasons from "@/components/series/JellyseerrSeasons";
import { ItemActions } from "@/components/series/SeriesActions"; import { ItemActions } from "@/components/series/SeriesActions";
import { useJellyseerr } from "@/hooks/useJellyseerr"; import { useJellyseerr } from "@/hooks/useJellyseerr";
import { useJellyseerrCanRequest } from "@/utils/_jellyseerr/useJellyseerrCanRequest"; import { useJellyseerrCanRequest } from "@/utils/_jellyseerr/useJellyseerrCanRequest";
import { import {
IssueType, type IssueType,
IssueTypeName, IssueTypeName,
} from "@/utils/jellyseerr/server/constants/issue"; } from "@/utils/jellyseerr/server/constants/issue";
import { MediaType } from "@/utils/jellyseerr/server/constants/media"; import { MediaType } from "@/utils/jellyseerr/server/constants/media";
import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search"; import type {
import { TvDetails } from "@/utils/jellyseerr/server/models/Tv"; MovieResult,
import { useTranslation } from "react-i18next"; TvResult,
} from "@/utils/jellyseerr/server/models/Search";
import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { import {
BottomSheetBackdrop, BottomSheetBackdrop,
BottomSheetBackdropProps, type BottomSheetBackdropProps,
BottomSheetModal, BottomSheetModal,
BottomSheetTextInput, BottomSheetTextInput,
BottomSheetView, BottomSheetView,
@@ -29,20 +31,16 @@ import {
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation } from "expo-router"; import { useLocalSearchParams, useNavigation } from "expo-router";
import React, { import type React from "react";
useCallback, import { useCallback, useEffect, useMemo, useRef, useState } from "react";
useEffect, import { useTranslation } from "react-i18next";
useMemo,
useRef,
useState,
} from "react";
import { Platform, TouchableOpacity, View } from "react-native"; import { Platform, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null; const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
import RequestModal from "@/components/jellyseerr/RequestModal"; import RequestModal from "@/components/jellyseerr/RequestModal";
import { ANIME_KEYWORD_ID } from "@/utils/jellyseerr/server/api/themoviedb/constants"; import { ANIME_KEYWORD_ID } from "@/utils/jellyseerr/server/api/themoviedb/constants";
import { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces"; import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
import {MovieDetails} from "@/utils/jellyseerr/server/models/Movie"; import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
const Page: React.FC = () => { const Page: React.FC = () => {
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
@@ -83,8 +81,8 @@ const Page: React.FC = () => {
refetchInterval: 0, refetchInterval: 0,
queryFn: async () => { queryFn: async () => {
return mediaType === MediaType.MOVIE return mediaType === MediaType.MOVIE
? jellyseerrApi?.movieDetails(result.id!!) ? jellyseerrApi?.movieDetails(result.id!)
: jellyseerrApi?.tvDetails(result.id!!); : jellyseerrApi?.tvDetails(result.id!);
}, },
}); });
@@ -99,7 +97,7 @@ const Page: React.FC = () => {
appearsOnIndex={0} appearsOnIndex={0}
/> />
), ),
[] [],
); );
const submitIssue = useCallback(() => { const submitIssue = useCallback(() => {
@@ -114,15 +112,18 @@ const Page: React.FC = () => {
} }
}, [jellyseerrApi, details, result, issueType, issueMessage]); }, [jellyseerrApi, details, result, issueType, issueMessage]);
const setRequestBody = useCallback((body: MediaRequestBody) => { const setRequestBody = useCallback(
_setRequestBody(body) (body: MediaRequestBody) => {
advancedReqModalRef?.current?.present?.(); _setRequestBody(body);
}, [requestBody, _setRequestBody, advancedReqModalRef]) advancedReqModalRef?.current?.present?.();
},
[requestBody, _setRequestBody, advancedReqModalRef],
);
const request = useCallback(async () => { const request = useCallback(async () => {
const body: MediaRequestBody = { const body: MediaRequestBody = {
mediaId: Number(result.id!!), mediaId: Number(result.id!),
mediaType: mediaType!!, mediaType: mediaType!,
tvdbId: details?.externalIds?.tvdbId, tvdbId: details?.externalIds?.tvdbId,
seasons: (details as TvDetails)?.seasons seasons: (details as TvDetails)?.seasons
?.filter?.((s) => s.seasonNumber !== 0) ?.filter?.((s) => s.seasonNumber !== 0)
@@ -130,7 +131,7 @@ const Page: React.FC = () => {
}; };
if (hasAdvancedRequestPermission) { if (hasAdvancedRequestPermission) {
setRequestBody(body) setRequestBody(body);
return; return;
} }
@@ -141,14 +142,14 @@ const Page: React.FC = () => {
() => () =>
(details?.keywords.some((k) => k.id === ANIME_KEYWORD_ID) || false) && (details?.keywords.some((k) => k.id === ANIME_KEYWORD_ID) || false) &&
mediaType === MediaType.TV, mediaType === MediaType.TV,
[details] [details],
); );
useEffect(() => { useEffect(() => {
if (details) { if (details) {
navigation.setOptions({ navigation.setOptions({
headerRight: () => ( headerRight: () => (
<TouchableOpacity className="rounded-full p-2 bg-neutral-800/80"> <TouchableOpacity className='rounded-full p-2 bg-neutral-800/80'>
<ItemActions item={details} /> <ItemActions item={details} />
</TouchableOpacity> </TouchableOpacity>
), ),
@@ -158,14 +159,14 @@ const Page: React.FC = () => {
return ( return (
<View <View
className="flex-1 relative" className='flex-1 relative'
style={{ style={{
paddingLeft: insets.left, paddingLeft: insets.left,
paddingRight: insets.right, paddingRight: insets.right,
}} }}
> >
<ParallaxScrollView <ParallaxScrollView
className="flex-1 opacity-100" className='flex-1 opacity-100'
headerHeight={300} headerHeight={300}
headerImage={ headerImage={
<View> <View>
@@ -180,7 +181,7 @@ const Page: React.FC = () => {
source={{ source={{
uri: jellyseerrApi?.imageProxy( uri: jellyseerrApi?.imageProxy(
result.backdropPath, result.backdropPath,
"w1920_and_h800_multi_faces" "w1920_and_h800_multi_faces",
), ),
}} }}
/> />
@@ -190,12 +191,12 @@ const Page: React.FC = () => {
width: "100%", width: "100%",
height: "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 <Ionicons
name="image-outline" name='image-outline'
size={24} size={24}
color="white" color='white'
style={{ opacity: 0.4 }} style={{ opacity: 0.4 }}
/> />
</View> </View>
@@ -203,23 +204,31 @@ const Page: React.FC = () => {
</View> </View>
} }
> >
<View className="flex flex-col"> <View className='flex flex-col'>
<View className="space-y-4"> <View className='space-y-4'>
<View className="px-4"> <View className='px-4'>
<View className="flex flex-row justify-between w-full"> <View className='flex flex-row justify-between w-full'>
<View className="flex flex-col w-56"> <View className='flex flex-col w-56'>
<JellyserrRatings result={result as MovieResult | TvResult | MovieDetails | TvDetails} /> <JellyserrRatings
result={
result as
| MovieResult
| TvResult
| MovieDetails
| TvDetails
}
/>
<Text <Text
uiTextView uiTextView
selectable selectable
className="font-bold text-2xl mb-1" className='font-bold text-2xl mb-1'
> >
{mediaTitle} {mediaTitle}
</Text> </Text>
<Text className="opacity-50">{releaseYear}</Text> <Text className='opacity-50'>{releaseYear}</Text>
</View> </View>
<Image <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"} cachePolicy={"memory-disk"}
transition={300} transition={300}
source={{ source={{
@@ -227,22 +236,22 @@ const Page: React.FC = () => {
}} }}
/> />
</View> </View>
<View className="mb-4"> <View className='mb-4'>
<GenreTags genres={details?.genres?.map((g) => g.name) || []} /> <GenreTags genres={details?.genres?.map((g) => g.name) || []} />
</View> </View>
{isLoading || isFetching ? ( {isLoading || isFetching ? (
<Button loading={true} disabled={true} color="purple"></Button> <Button loading={true} disabled={true} color='purple'></Button>
) : canRequest ? ( ) : canRequest ? (
<Button color="purple" onPress={request}> <Button color='purple' onPress={request}>
{t("jellyseerr.request_button")} {t("jellyseerr.request_button")}
</Button> </Button>
) : ( ) : (
<Button <Button
className="bg-yellow-500/50 border-yellow-400 ring-yellow-400 text-yellow-100" className='bg-yellow-500/50 border-yellow-400 ring-yellow-400 text-yellow-100'
color="transparent" color='transparent'
onPress={() => bottomSheetModalRef?.current?.present()} onPress={() => bottomSheetModalRef?.current?.present()}
iconLeft={ iconLeft={
<Ionicons name="warning-outline" size={24} color="white" /> <Ionicons name='warning-outline' size={24} color='white' />
} }
style={{ style={{
borderWidth: 1, borderWidth: 1,
@@ -252,7 +261,7 @@ const Page: React.FC = () => {
{t("jellyseerr.report_issue_button")} {t("jellyseerr.report_issue_button")}
</Button> </Button>
)} )}
<OverviewText text={result.overview} className="mt-4" /> <OverviewText text={result.overview} className='mt-4' />
</View> </View>
{mediaType === MediaType.TV && ( {mediaType === MediaType.TV && (
@@ -261,13 +270,11 @@ const Page: React.FC = () => {
details={details as TvDetails} details={details as TvDetails}
refetch={refetch} refetch={refetch}
hasAdvancedRequest={hasAdvancedRequestPermission} hasAdvancedRequest={hasAdvancedRequestPermission}
onAdvancedRequest={(data) => onAdvancedRequest={(data) => setRequestBody(data)}
setRequestBody(data)
}
/> />
)} )}
<DetailFacts <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} details={details}
/> />
<Cast details={details} /> <Cast details={details} />
@@ -278,11 +285,11 @@ const Page: React.FC = () => {
ref={advancedReqModalRef} ref={advancedReqModalRef}
requestBody={requestBody} requestBody={requestBody}
title={mediaTitle} title={mediaTitle}
id={result.id!!} id={result.id!}
type={mediaType} type={mediaType}
isAnime={isAnime} isAnime={isAnime}
onRequested={() => { onRequested={() => {
_setRequestBody(undefined) _setRequestBody(undefined);
advancedReqModalRef?.current?.close(); advancedReqModalRef?.current?.close();
refetch(); refetch();
}} }}
@@ -300,22 +307,22 @@ const Page: React.FC = () => {
backdropComponent={renderBackdrop} backdropComponent={renderBackdrop}
> >
<BottomSheetView> <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> <View>
<Text className="font-bold text-2xl text-neutral-100"> <Text className='font-bold text-2xl text-neutral-100'>
{t("jellyseerr.whats_wrong")} {t("jellyseerr.whats_wrong")}
</Text> </Text>
</View> </View>
<View className="flex flex-col space-y-2 items-start"> <View className='flex flex-col space-y-2 items-start'>
<View className="flex flex-col"> <View className='flex flex-col'>
<DropdownMenu.Root> <DropdownMenu.Root>
<DropdownMenu.Trigger> <DropdownMenu.Trigger>
<View className="flex flex-col"> <View className='flex flex-col'>
<Text className="opacity-50 mb-1 text-xs"> <Text className='opacity-50 mb-1 text-xs'>
{t("jellyseerr.issue_type")} {t("jellyseerr.issue_type")}
</Text> </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"> <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}> <Text style={{}} className='' numberOfLines={1}>
{issueType {issueType
? IssueTypeName[issueType] ? IssueTypeName[issueType]
: t("jellyseerr.select_an_issue")} : t("jellyseerr.select_an_issue")}
@@ -325,8 +332,8 @@ const Page: React.FC = () => {
</DropdownMenu.Trigger> </DropdownMenu.Trigger>
<DropdownMenu.Content <DropdownMenu.Content
loop={false} loop={false}
side="bottom" side='bottom'
align="center" align='center'
alignOffset={0} alignOffset={0}
avoidCollisions={true} avoidCollisions={true}
collisionPadding={0} collisionPadding={0}
@@ -353,14 +360,14 @@ const Page: React.FC = () => {
</DropdownMenu.Root> </DropdownMenu.Root>
</View> </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 <BottomSheetTextInput
multiline multiline
maxLength={254} maxLength={254}
style={{ color: "white" }} style={{ color: "white" }}
clearButtonMode="always" clearButtonMode='always'
placeholder={t("jellyseerr.describe_the_issue")} placeholder={t("jellyseerr.describe_the_issue")}
placeholderTextColor="#9CA3AF" placeholderTextColor='#9CA3AF'
// Issue with multiline + Textinput inside a portal // Issue with multiline + Textinput inside a portal
// https://github.com/callstack/react-native-paper/issues/1668 // https://github.com/callstack/react-native-paper/issues/1668
defaultValue={issueMessage} defaultValue={issueMessage}
@@ -368,7 +375,7 @@ const Page: React.FC = () => {
/> />
</View> </View>
</View> </View>
<Button className="mt-auto" onPress={submitIssue} color="purple"> <Button className='mt-auto' onPress={submitIssue} color='purple'>
{t("jellyseerr.submit_button")} {t("jellyseerr.submit_button")}
</Button> </Button>
</View> </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 { OverviewText } from "@/components/OverviewText";
import {orderBy, uniqBy} from "lodash"; import { Text } from "@/components/common/Text";
import { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person";
import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow"; import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow";
import JellyseerrPoster from "@/components/posters/JellyseerrPoster"; 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"; import { useTranslation } from "react-i18next";
export default function page() { export default function page() {
const local = useLocalSearchParams(); const local = useLocalSearchParams();
const { t } = useTranslation(); 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 }; const { personId } = local as { personId: string };
@@ -34,18 +39,27 @@ export default function page() {
const castedRoles: PersonCreditCast[] = useMemo( const castedRoles: PersonCreditCast[] = useMemo(
() => () =>
uniqBy(orderBy( uniqBy(
data?.combinedCredits?.cast, orderBy(
["voteCount", "voteAverage"], data?.combinedCredits?.cast,
"desc" ["voteCount", "voteAverage"],
), 'id'), "desc",
[data?.combinedCredits] ),
"id",
),
[data?.combinedCredits],
); );
const backdrops = useMemo( const backdrops = useMemo(
() => jellyseerrApi () =>
? castedRoles.map((c) => jellyseerrApi.imageProxy(c.backdropPath, "w1920_and_h800_multi_faces")) jellyseerrApi
: [], ? castedRoles.map((c) =>
[jellyseerrApi, data?.combinedCredits] jellyseerrApi.imageProxy(
c.backdropPath,
"w1920_and_h800_multi_faces",
),
)
: [],
[jellyseerrApi, data?.combinedCredits],
); );
return ( return (
@@ -58,15 +72,15 @@ export default function page() {
<Image <Image
key={data?.details?.id} key={data?.details?.id}
id={data?.details?.id.toString()} id={data?.details?.id.toString()}
className="rounded-full bottom-1" className='rounded-full bottom-1'
source={{ source={{
uri: jellyseerrApi?.imageProxy( uri: jellyseerrApi?.imageProxy(
data?.details?.profilePath, data?.details?.profilePath,
"w600_and_h600_bestv2" "w600_and_h600_bestv2",
), ),
}} }}
cachePolicy={"memory-disk"} cachePolicy={"memory-disk"}
contentFit="cover" contentFit='cover'
style={{ style={{
width: 125, width: 125,
height: 125, height: 125,
@@ -75,27 +89,27 @@ export default function page() {
} }
HeaderContent={() => ( HeaderContent={() => (
<> <>
<Text className="font-bold text-2xl mb-1"> <Text className='font-bold text-2xl mb-1'>{data?.details?.name}</Text>
{data?.details?.name} <Text className='opacity-50'>
</Text>
<Text className="opacity-50">
{t("jellyseerr.born")}{" "} {t("jellyseerr.born")}{" "}
{new Date(data?.details?.birthday!!).toLocaleDateString( {new Date(data?.details?.birthday!).toLocaleDateString(
`${locale}-${region}`, `${locale}-${region}`,
{ {
year: "numeric", year: "numeric",
month: "long", month: "long",
day: "numeric", day: "numeric",
} },
)}{" "} )}{" "}
| {data?.details?.placeOfBirth} | {data?.details?.placeOfBirth}
</Text> </Text>
</> </>
)} )}
MainContent={() => ( 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, MaterialTopTabNavigationOptions,
} from "@react-navigation/material-top-tabs"; } from "@react-navigation/material-top-tabs";
import { createMaterialTopTabNavigator } 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 { Stack, withLayoutContext } from "expo-router";
import React from "react"; import React from "react";
@@ -21,8 +24,8 @@ const Layout = () => {
<> <>
<Stack.Screen options={{ title: "Live TV" }} /> <Stack.Screen options={{ title: "Live TV" }} />
<Tab <Tab
initialRouteName="programs" initialRouteName='programs'
keyboardDismissMode="none" keyboardDismissMode='none'
screenOptions={{ screenOptions={{
tabBarBounces: true, tabBarBounces: true,
tabBarLabelStyle: { fontSize: 10 }, tabBarLabelStyle: { fontSize: 10 },
@@ -37,10 +40,10 @@ const Layout = () => {
tabBarScrollEnabled: true, tabBarScrollEnabled: true,
}} }}
> >
<Tab.Screen name="programs" /> <Tab.Screen name='programs' />
<Tab.Screen name="guide" /> <Tab.Screen name='guide' />
<Tab.Screen name="channels" /> <Tab.Screen name='channels' />
<Tab.Screen name="recordings" /> <Tab.Screen name='recordings' />
</Tab> </Tab>
</> </>
); );

View File

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

View File

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

View File

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

View File

@@ -1,12 +1,12 @@
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import React from "react"; import React from "react";
import { View } from "react-native";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { View } from "react-native";
export default function page() { export default function page() {
const { t } = useTranslation(); const { t } = useTranslation();
return ( 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> <Text>{t("live_tv.coming_soon")}</Text>
</View> </View>
); );

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
import { ScrollViewStyleReset } from "expo-router/html"; 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. * 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) { export default function Root({ children }: PropsWithChildren) {
return ( return (
<html lang="en"> <html lang='en'>
<head> <head>
<meta charSet="utf-8" /> <meta charSet='utf-8' />
<meta httpEquiv="X-UA-Compatible" content="IE=edge" /> <meta httpEquiv='X-UA-Compatible' content='IE=edge' />
<meta <meta
name="viewport" name='viewport'
content="width=device-width, initial-scale=1, shrink-to-fit=no" 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!" }} /> <Stack.Screen options={{ title: "Oops!" }} />
<ThemedView style={styles.container}> <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}> <Link href={"/home"} style={styles.link}>
<ThemedText type="link">Go to home screen!</ThemedText> <ThemedText type='link'>Go to home screen!</ThemedText>
</Link> </Link>
</ThemedView> </ThemedView>
</> </>

View File

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

View File

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

View File

@@ -1,21 +1,21 @@
import { Api, AUTHORIZATION_HEADER } from "@jellyfin/sdk"; import type { StreamyfinPluginConfig } from "@/utils/atoms/settings";
import { AxiosRequestConfig, AxiosResponse } from "axios"; import { AUTHORIZATION_HEADER, Api } from "@jellyfin/sdk";
import { StreamyfinPluginConfig } from "@/utils/atoms/settings"; import type { AxiosRequestConfig, AxiosResponse } from "axios";
declare module "@jellyfin/sdk" { declare module "@jellyfin/sdk" {
interface Api { interface Api {
get<T, D = any>( get<T, D = any>(
url: string, url: string,
config?: AxiosRequestConfig<D> config?: AxiosRequestConfig<D>,
): Promise<AxiosResponse<T>>; ): Promise<AxiosResponse<T>>;
post<T, D = any>( post<T, D = any>(
url: string, url: string,
data: D, data: D,
config?: AxiosRequestConfig<D> config?: AxiosRequestConfig<D>,
): Promise<AxiosResponse<T>>; ): Promise<AxiosResponse<T>>;
delete<T, D = any>( delete<T, D = any>(
url: string, url: string,
config?: AxiosRequestConfig<D> config?: AxiosRequestConfig<D>,
): Promise<AxiosResponse<T>>; ): Promise<AxiosResponse<T>>;
getStreamyfinPluginConfig(): Promise<AxiosResponse<StreamyfinPluginConfig>>; getStreamyfinPluginConfig(): Promise<AxiosResponse<StreamyfinPluginConfig>>;
} }
@@ -23,7 +23,7 @@ declare module "@jellyfin/sdk" {
Api.prototype.get = function <T, D = any>( Api.prototype.get = function <T, D = any>(
url: string, url: string,
config: AxiosRequestConfig<D> = {} config: AxiosRequestConfig<D> = {},
): Promise<AxiosResponse<T>> { ): Promise<AxiosResponse<T>> {
return this.axiosInstance.get<T>(`${this.basePath}${url}`, { return this.axiosInstance.get<T>(`${this.basePath}${url}`, {
...(config ?? {}), ...(config ?? {}),
@@ -34,7 +34,7 @@ Api.prototype.get = function <T, D = any>(
Api.prototype.post = function <T, D = any>( Api.prototype.post = function <T, D = any>(
url: string, url: string,
data: D, data: D,
config: AxiosRequestConfig<D> config: AxiosRequestConfig<D>,
): Promise<AxiosResponse<T>> { ): Promise<AxiosResponse<T>> {
return this.axiosInstance.post<T>(`${this.basePath}${url}`, data, { return this.axiosInstance.post<T>(`${this.basePath}${url}`, data, {
...(config || {}), ...(config || {}),
@@ -44,7 +44,7 @@ Api.prototype.post = function <T, D = any>(
Api.prototype.delete = function <T, D = any>( Api.prototype.delete = function <T, D = any>(
url: string, url: string,
config: AxiosRequestConfig<D> config: AxiosRequestConfig<D>,
): Promise<AxiosResponse<T>> { ): Promise<AxiosResponse<T>> {
return this.axiosInstance.delete<T>(`${this.basePath}${url}`, { return this.axiosInstance.delete<T>(`${this.basePath}${url}`, {
...(config || {}), ...(config || {}),

View File

@@ -1,22 +1,21 @@
import {MMKV} from "react-native-mmkv"; import { MMKV } from "react-native-mmkv";
declare module "react-native-mmkv" { declare module "react-native-mmkv" {
interface MMKV { interface MMKV {
get<T>(key: string): T | undefined get<T>(key: string): T | undefined;
setAny(key: string, value: any | undefined): void setAny(key: string, value: any | undefined): void;
} }
} }
MMKV.prototype.get = function <T> (key: string): T | undefined { MMKV.prototype.get = function <T>(key: string): T | undefined {
const serializedItem = this.getString(key); const serializedItem = this.getString(key);
return serializedItem ? JSON.parse(serializedItem) : undefined; return serializedItem ? JSON.parse(serializedItem) : undefined;
} };
MMKV.prototype.setAny = function (key: string, value: any | undefined): void { MMKV.prototype.setAny = function (key: string, value: any | undefined): void {
if (value === undefined) { if (value === undefined) {
this.delete(key) this.delete(key);
} } else {
else {
this.set(key, JSON.stringify(value)); this.set(key, JSON.stringify(value));
} }
} };

View File

@@ -7,17 +7,19 @@ declare global {
} }
} }
Number.prototype.bytesToReadable = function (decimals: number = 2) { Number.prototype.bytesToReadable = function (decimals = 2) {
const bytes = this.valueOf(); const bytes = this.valueOf();
if (bytes === 0) return '0 Bytes'; if (bytes === 0) return "0 Bytes";
const k = 1024; const k = 1024;
const dm = decimals < 0 ? 0 : decimals; const dm = decimals < 0 ? 0 : decimals;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
const i = Math.floor(Math.log(bytes) / Math.log(k)); const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]; return (
Number.parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i]
);
}; };
Number.prototype.secondsToMilliseconds = function () { Number.prototype.secondsToMilliseconds = function () {

View File

@@ -5,12 +5,10 @@ declare global {
} }
String.prototype.toTitle = function () { String.prototype.toTitle = function () {
return this return this.replaceAll("_", " ").replace(
.replaceAll("_", " ") /\w\S*/g,
.replace( (text) => text.charAt(0).toUpperCase() + text.substring(1).toLowerCase(),
/\w\S*/g, );
text => text.charAt(0).toUpperCase() + text.substring(1).toLowerCase() };
);
}
export {}; export {};

View File

@@ -1,4 +1,4 @@
module.exports = function (api) { module.exports = (api) => {
api.cache(true); api.cache(true);
return { return {
presets: ["babel-preset-expo"], presets: ["babel-preset-expo"],

View File

@@ -7,6 +7,10 @@
"linter": { "linter": {
"enabled": true, "enabled": true,
"rules": { "rules": {
"style": {
"useImportType": "off",
"noNonNullAssertion": "off"
},
"recommended": true, "recommended": true,
"style": { "style": {
"useImportType": "off", "useImportType": "off",

View File

@@ -1,21 +1,20 @@
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { useFavorite } from "@/hooks/useFavorite";
import {View, ViewProps} from "react-native";
import { RoundButton } from "@/components/RoundButton"; import { RoundButton } from "@/components/RoundButton";
import {FC} from "react"; import { useFavorite } from "@/hooks/useFavorite";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import type { FC } from "react";
import { View, type ViewProps } from "react-native";
interface Props extends ViewProps { interface Props extends ViewProps {
item: BaseItemDto; item: BaseItemDto;
} }
export const AddToFavorites:FC<Props> = ({ item, ...props }) => { export const AddToFavorites: FC<Props> = ({ item, ...props }) => {
const { isFavorite, toggleFavorite } = useFavorite(item); const { isFavorite, toggleFavorite } = useFavorite(item);
return ( return (
<View {...props}> <View {...props}>
<RoundButton <RoundButton
size="large" size='large'
icon={isFavorite ? "heart" : "heart-outline"} icon={isFavorite ? "heart" : "heart-outline"}
fillColor={isFavorite ? "primary" : undefined} fillColor={isFavorite ? "primary" : undefined}
onPress={toggleFavorite} onPress={toggleFavorite}

View File

@@ -1,9 +1,9 @@
import { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models"; import type { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
import { useMemo } from "react"; import { useMemo } from "react";
import { Platform, TouchableOpacity, View } from "react-native"; import { Platform, TouchableOpacity, View } from "react-native";
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null; const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
import { Text } from "./common/Text";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Text } from "./common/Text";
interface Props extends React.ComponentProps<typeof View> { interface Props extends React.ComponentProps<typeof View> {
source?: MediaSourceInfo; source?: MediaSourceInfo;
@@ -20,31 +20,31 @@ export const AudioTrackSelector: React.FC<Props> = ({
if (Platform.isTV) return null; if (Platform.isTV) return null;
const audioStreams = useMemo( const audioStreams = useMemo(
() => source?.MediaStreams?.filter((x) => x.Type === "Audio"), () => source?.MediaStreams?.filter((x) => x.Type === "Audio"),
[source] [source],
); );
const selectedAudioSteam = useMemo( const selectedAudioSteam = useMemo(
() => audioStreams?.find((x) => x.Index === selected), () => audioStreams?.find((x) => x.Index === selected),
[audioStreams, selected] [audioStreams, selected],
); );
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<View <View
className="flex shrink" className='flex shrink'
style={{ style={{
minWidth: 50, minWidth: 50,
}} }}
> >
<DropdownMenu.Root> <DropdownMenu.Root>
<DropdownMenu.Trigger> <DropdownMenu.Trigger>
<View className="flex flex-col" {...props}> <View className='flex flex-col' {...props}>
<Text className="opacity-50 mb-1 text-xs"> <Text className='opacity-50 mb-1 text-xs'>
{t("item_card.audio")} {t("item_card.audio")}
</Text> </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"> <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 className="" numberOfLines={1}> <Text className='' numberOfLines={1}>
{selectedAudioSteam?.DisplayTitle} {selectedAudioSteam?.DisplayTitle}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
@@ -52,8 +52,8 @@ export const AudioTrackSelector: React.FC<Props> = ({
</DropdownMenu.Trigger> </DropdownMenu.Trigger>
<DropdownMenu.Content <DropdownMenu.Content
loop={true} loop={true}
side="bottom" side='bottom'
align="start" align='start'
alignOffset={0} alignOffset={0}
avoidCollisions={true} avoidCollisions={true}
collisionPadding={8} collisionPadding={8}

View File

@@ -1,4 +1,4 @@
import { View, ViewProps } from "react-native"; import { View, type ViewProps } from "react-native";
import { Text } from "./common/Text"; import { Text } from "./common/Text";
interface Props extends ViewProps { interface Props extends ViewProps {
@@ -22,7 +22,7 @@ export const Badge: React.FC<Props> = ({
${variant === "gray" && "bg-neutral-800"} ${variant === "gray" && "bg-neutral-800"}
`} `}
> >
{iconLeft && <View className="mr-1">{iconLeft}</View>} {iconLeft && <View className='mr-1'>{iconLeft}</View>}
<Text <Text
className={` className={`
text-xs text-xs

View File

@@ -1,8 +1,8 @@
import { Platform, TouchableOpacity, View } from "react-native"; import { Platform, TouchableOpacity, View } from "react-native";
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null; const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
import { Text } from "./common/Text";
import { useMemo } from "react"; import { useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Text } from "./common/Text";
export type Bitrate = { export type Bitrate = {
key: string; key: string;
@@ -40,7 +40,11 @@ export const BITRATES: Bitrate[] = [
key: "250 Kb/s", key: "250 Kb/s",
value: 250000, value: 250000,
}, },
].sort((a, b) => (b.value || Infinity) - (a.value || Infinity)); ].sort(
(a, b) =>
(b.value || Number.POSITIVE_INFINITY) -
(a.value || Number.POSITIVE_INFINITY),
);
interface Props extends React.ComponentProps<typeof View> { interface Props extends React.ComponentProps<typeof View> {
onChange: (value: Bitrate) => void; onChange: (value: Bitrate) => void;
@@ -58,10 +62,14 @@ export const BitrateSelector: React.FC<Props> = ({
const sorted = useMemo(() => { const sorted = useMemo(() => {
if (inverted) if (inverted)
return BITRATES.sort( return BITRATES.sort(
(a, b) => (a.value || Infinity) - (b.value || Infinity) (a, b) =>
(a.value || Number.POSITIVE_INFINITY) -
(b.value || Number.POSITIVE_INFINITY),
); );
return BITRATES.sort( return BITRATES.sort(
(a, b) => (b.value || Infinity) - (a.value || Infinity) (a, b) =>
(b.value || Number.POSITIVE_INFINITY) -
(a.value || Number.POSITIVE_INFINITY),
); );
}, []); }, []);
@@ -69,7 +77,7 @@ export const BitrateSelector: React.FC<Props> = ({
return ( return (
<View <View
className="flex shrink" className='flex shrink'
style={{ style={{
minWidth: 60, minWidth: 60,
maxWidth: 200, maxWidth: 200,
@@ -77,12 +85,12 @@ export const BitrateSelector: React.FC<Props> = ({
> >
<DropdownMenu.Root> <DropdownMenu.Root>
<DropdownMenu.Trigger> <DropdownMenu.Trigger>
<View className="flex flex-col" {...props}> <View className='flex flex-col' {...props}>
<Text className="opacity-50 mb-1 text-xs"> <Text className='opacity-50 mb-1 text-xs'>
{t("item_card.quality")} {t("item_card.quality")}
</Text> </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"> <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}> <Text style={{}} className='' numberOfLines={1}>
{BITRATES.find((b) => b.value === selected?.value)?.key} {BITRATES.find((b) => b.value === selected?.value)?.key}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
@@ -90,8 +98,8 @@ export const BitrateSelector: React.FC<Props> = ({
</DropdownMenu.Trigger> </DropdownMenu.Trigger>
<DropdownMenu.Content <DropdownMenu.Content
loop={false} loop={false}
side="bottom" side='bottom'
align="center" align='center'
alignOffset={0} alignOffset={0}
avoidCollisions={true} avoidCollisions={true}
collisionPadding={0} collisionPadding={0}

View File

@@ -1,5 +1,6 @@
import { useHaptic } from "@/hooks/useHaptic"; import { useHaptic } from "@/hooks/useHaptic";
import React, { PropsWithChildren, ReactNode, useMemo } from "react"; import type React from "react";
import { type PropsWithChildren, type ReactNode, useMemo } from "react";
import { Platform, Text, TouchableOpacity, View } from "react-native"; import { Platform, Text, TouchableOpacity, View } from "react-native";
import { Loader } from "./Loader"; import { Loader } from "./Loader";
@@ -63,7 +64,7 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
{...props} {...props}
> >
{loading ? ( {loading ? (
<View className="p-0.5"> <View className='p-0.5'>
<Loader /> <Loader />
</View> </View>
) : ( ) : (
@@ -72,7 +73,7 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
flex flex-row items-center justify-between w-full flex flex-row items-center justify-between w-full
${justify === "between" ? "justify-between" : "justify-center"}`} ${justify === "between" ? "justify-between" : "justify-center"}`}
> >
{iconLeft ? iconLeft : <View className="w-4"></View>} {iconLeft ? iconLeft : <View className='w-4'></View>}
<Text <Text
className={` className={`
text-white font-bold text-base text-white font-bold text-base
@@ -84,7 +85,7 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
> >
{children} {children}
</Text> </Text>
{iconRight ? iconRight : <View className="w-4"></View>} {iconRight ? iconRight : <View className='w-4'></View>}
</View> </View>
)} )}
</TouchableOpacity> </TouchableOpacity>

View File

@@ -1,6 +1,6 @@
import { Feather } from "@expo/vector-icons"; import { Feather } from "@expo/vector-icons";
import React, { useCallback, useEffect } from "react"; import React, { useCallback, useEffect } from "react";
import { Platform, TouchableOpacity, ViewProps } from "react-native"; import { Platform, TouchableOpacity, type ViewProps } from "react-native";
import GoogleCast, { import GoogleCast, {
CastButton, CastButton,
CastContext, CastContext,
@@ -45,18 +45,18 @@ export function Chromecast({
const AndroidCastButton = useCallback( const AndroidCastButton = useCallback(
() => () =>
Platform.OS === "android" ? ( Platform.OS === "android" ? (
<CastButton tintColor="transparent" /> <CastButton tintColor='transparent' />
) : ( ) : (
<></> <></>
), ),
[Platform.OS] [Platform.OS],
); );
if (background === "transparent") if (background === "transparent")
return ( return (
<RoundButton <RoundButton
size="large" size='large'
className="mr-2" className='mr-2'
background={false} background={false}
onPress={() => { onPress={() => {
if (mediaStatus?.currentItemId) CastContext.showExpandedControls(); if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
@@ -65,13 +65,13 @@ export function Chromecast({
{...props} {...props}
> >
<AndroidCastButton /> <AndroidCastButton />
<Feather name="cast" size={22} color={"white"} /> <Feather name='cast' size={22} color={"white"} />
</RoundButton> </RoundButton>
); );
return ( return (
<RoundButton <RoundButton
size="large" size='large'
onPress={() => { onPress={() => {
if (mediaStatus?.currentItemId) CastContext.showExpandedControls(); if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
else CastContext.showCastDialog(); else CastContext.showCastDialog();
@@ -79,7 +79,7 @@ export function Chromecast({
{...props} {...props}
> >
<AndroidCastButton /> <AndroidCastButton />
<Feather name="cast" size={22} color={"white"} /> <Feather name='cast' size={22} color={"white"} />
</RoundButton> </RoundButton>
); );
} }

View File

@@ -1,12 +1,12 @@
import { apiAtom } from "@/providers/JellyfinProvider"; import { apiAtom } from "@/providers/JellyfinProvider";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { useMemo } from "react"; import { useMemo } from "react";
import type React from "react";
import { View } from "react-native"; import { View } from "react-native";
import { WatchedIndicator } from "./WatchedIndicator"; import { WatchedIndicator } from "./WatchedIndicator";
import React from "react";
import { Ionicons } from "@expo/vector-icons";
type ContinueWatchingPosterProps = { type ContinueWatchingPosterProps = {
item: BaseItemDto; item: BaseItemDto;
@@ -71,7 +71,7 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
if (!url) if (!url)
return ( return (
<View className="aspect-video border border-neutral-800 w-44"></View> <View className='aspect-video border border-neutral-800 w-44'></View>
); );
return ( return (
@@ -81,7 +81,7 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
${size === "small" ? "w-32" : "w-44"} ${size === "small" ? "w-32" : "w-44"}
`} `}
> >
<View className="w-full h-full flex items-center justify-center"> <View className='w-full h-full flex items-center justify-center'>
<Image <Image
key={item.Id} key={item.Id}
id={item.Id} id={item.Id}
@@ -89,12 +89,12 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
uri: url, uri: url,
}} }}
cachePolicy={"memory-disk"} cachePolicy={"memory-disk"}
contentFit="cover" contentFit='cover'
className="w-full h-full" className='w-full h-full'
/> />
{showPlayButton && ( {showPlayButton && (
<View className="absolute inset-0 flex items-center justify-center"> <View className='absolute inset-0 flex items-center justify-center'>
<Ionicons name="play-circle" size={40} color="white" /> <Ionicons name='play-circle' size={40} color='white' />
</View> </View>
)} )}
</View> </View>

View File

@@ -10,29 +10,30 @@ import download from "@/utils/profiles/download";
import Ionicons from "@expo/vector-icons/Ionicons"; import Ionicons from "@expo/vector-icons/Ionicons";
import { import {
BottomSheetBackdrop, BottomSheetBackdrop,
BottomSheetBackdropProps, type BottomSheetBackdropProps,
BottomSheetModal, BottomSheetModal,
BottomSheetView, BottomSheetView,
} from "@gorhom/bottom-sheet"; } from "@gorhom/bottom-sheet";
import { import type {
BaseItemDto, BaseItemDto,
MediaSourceInfo, MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models"; } from "@jellyfin/sdk/lib/generated-client/models";
import { Href, router, useFocusEffect } from "expo-router"; import { type Href, router, useFocusEffect } from "expo-router";
import { t } from "i18next";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import React, { useCallback, useMemo, useRef, useState } from "react"; import type React from "react";
import { Alert, Platform, View, ViewProps } from "react-native"; import { useCallback, useMemo, useRef, useState } from "react";
import { Alert, Platform, View, type ViewProps } from "react-native";
import { toast } from "sonner-native"; import { toast } from "sonner-native";
import { AudioTrackSelector } from "./AudioTrackSelector"; import { AudioTrackSelector } from "./AudioTrackSelector";
import { Bitrate, BitrateSelector } from "./BitrateSelector"; import { type Bitrate, BitrateSelector } from "./BitrateSelector";
import { Button } from "./Button"; import { Button } from "./Button";
import { Text } from "./common/Text";
import { Loader } from "./Loader"; import { Loader } from "./Loader";
import { MediaSourceSelector } from "./MediaSourceSelector"; import { MediaSourceSelector } from "./MediaSourceSelector";
import ProgressCircle from "./ProgressCircle"; import ProgressCircle from "./ProgressCircle";
import { RoundButton } from "./RoundButton"; import { RoundButton } from "./RoundButton";
import { SubtitleTrackSelector } from "./SubtitleTrackSelector"; import { SubtitleTrackSelector } from "./SubtitleTrackSelector";
import { t } from "i18next"; import { Text } from "./common/Text";
interface DownloadProps extends ViewProps { interface DownloadProps extends ViewProps {
items: BaseItemDto[]; items: BaseItemDto[];
@@ -70,16 +71,16 @@ export const DownloadItems: React.FC<DownloadProps> = ({
settings?.defaultBitrate ?? { settings?.defaultBitrate ?? {
key: "Max", key: "Max",
value: undefined, value: undefined,
} },
); );
const userCanDownload = useMemo( const userCanDownload = useMemo(
() => user?.Policy?.EnableContentDownloading, () => user?.Policy?.EnableContentDownloading,
[user] [user],
); );
const usingOptimizedServer = useMemo( const usingOptimizedServer = useMemo(
() => settings?.downloadMethod === DownloadMethod.Optimized, () => settings?.downloadMethod === DownloadMethod.Optimized,
[settings] [settings],
); );
const bottomSheetModalRef = useRef<BottomSheetModal>(null); const bottomSheetModalRef = useRef<BottomSheetModal>(null);
@@ -99,7 +100,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
const itemsNotDownloaded = useMemo( const itemsNotDownloaded = useMemo(
() => () =>
items.filter((i) => !downloadedFiles?.some((f) => f.item.Id === i.Id)), items.filter((i) => !downloadedFiles?.some((f) => f.item.Id === i.Id)),
[items, downloadedFiles] [items, downloadedFiles],
); );
const allItemsDownloaded = useMemo(() => { const allItemsDownloaded = useMemo(() => {
@@ -108,7 +109,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
}, [items, itemsNotDownloaded]); }, [items, itemsNotDownloaded]);
const itemsProcesses = useMemo( const itemsProcesses = useMemo(
() => processes?.filter((p) => itemIds.includes(p.item.Id)), () => processes?.filter((p) => itemIds.includes(p.item.Id)),
[processes, itemIds] [processes, itemIds],
); );
const progress = useMemo(() => { const progress = useMemo(() => {
@@ -140,7 +141,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
params: { params: {
episodeSeasonIndex: firstItem.ParentIndexNumber, episodeSeasonIndex: firstItem.ParentIndexNumber,
}, },
} as Href) } as Href),
); );
}; };
@@ -160,12 +161,12 @@ export const DownloadItems: React.FC<DownloadProps> = ({
id: item.Id!, id: item.Id!,
execute: async () => await initiateDownload(item), execute: async () => await initiateDownload(item),
item, item,
})) })),
); );
} }
} else { } else {
toast.error( toast.error(
t("home.downloads.toasts.you_are_not_allowed_to_download_files") t("home.downloads.toasts.you_are_not_allowed_to_download_files"),
); );
} }
}, [ }, [
@@ -189,7 +190,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
(itemsNotDownloaded.length === 1 && !selectedMediaSource?.Id) (itemsNotDownloaded.length === 1 && !selectedMediaSource?.Id)
) { ) {
throw new Error( throw new Error(
"DownloadItem ~ initiateDownload: No api or user or item" "DownloadItem ~ initiateDownload: No api or user or item",
); );
} }
let mediaSource = selectedMediaSource; let mediaSource = selectedMediaSource;
@@ -220,7 +221,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
if (!res) { if (!res) {
Alert.alert( Alert.alert(
t("home.downloads.something_went_wrong"), t("home.downloads.something_went_wrong"),
t("home.downloads.could_not_get_stream_url_from_jellyfin") t("home.downloads.could_not_get_stream_url_from_jellyfin"),
); );
continue; continue;
} }
@@ -250,7 +251,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
usingOptimizedServer, usingOptimizedServer,
startBackgroundDownload, startBackgroundDownload,
startRemuxing, startRemuxing,
] ],
); );
const renderBackdrop = useCallback( const renderBackdrop = useCallback(
@@ -261,7 +262,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
appearsOnIndex={0} appearsOnIndex={0}
/> />
), ),
[] [],
); );
useFocusEffect( useFocusEffect(
useCallback(() => { useCallback(() => {
@@ -274,7 +275,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
setSelectedAudioStream(audioIndex ?? 0); setSelectedAudioStream(audioIndex ?? 0);
setSelectedSubtitleStream(subtitleIndex ?? -1); setSelectedSubtitleStream(subtitleIndex ?? -1);
setMaxBitrate(bitrate); setMaxBitrate(bitrate);
}, [items, itemsNotDownloaded, settings]) }, [items, itemsNotDownloaded, settings]),
); );
const renderButtonContent = () => { const renderButtonContent = () => {
@@ -282,18 +283,18 @@ export const DownloadItems: React.FC<DownloadProps> = ({
return progress === 0 ? ( return progress === 0 ? (
<Loader /> <Loader />
) : ( ) : (
<View className="-rotate-45"> <View className='-rotate-45'>
<ProgressCircle <ProgressCircle
size={24} size={24}
fill={progress} fill={progress}
width={4} width={4}
tintColor="#9334E9" tintColor='#9334E9'
backgroundColor="#bdc3c7" backgroundColor='#bdc3c7'
/> />
</View> </View>
); );
} else if (itemsQueued) { } else if (itemsQueued) {
return <Ionicons name="hourglass" size={24} color="white" />; return <Ionicons name='hourglass' size={24} color='white' />;
} else if (allItemsDownloaded) { } else if (allItemsDownloaded) {
return <DownloadedIconComponent />; return <DownloadedIconComponent />;
} else { } else {
@@ -331,19 +332,19 @@ export const DownloadItems: React.FC<DownloadProps> = ({
backdropComponent={renderBackdrop} backdropComponent={renderBackdrop}
> >
<BottomSheetView> <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> <View>
<Text className="font-bold text-2xl text-neutral-100"> <Text className='font-bold text-2xl text-neutral-100'>
{title} {title}
</Text> </Text>
<Text className="text-neutral-300"> <Text className='text-neutral-300'>
{subtitle || {subtitle ||
t("item_card.download.download_x_item", { t("item_card.download.download_x_item", {
item_count: itemsNotDownloaded.length, item_count: itemsNotDownloaded.length,
})} })}
</Text> </Text>
</View> </View>
<View className="flex flex-col space-y-2 w-full items-start"> <View className='flex flex-col space-y-2 w-full items-start'>
<BitrateSelector <BitrateSelector
inverted inverted
onChange={setMaxBitrate} onChange={setMaxBitrate}
@@ -357,7 +358,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
selected={selectedMediaSource} selected={selectedMediaSource}
/> />
{selectedMediaSource && ( {selectedMediaSource && (
<View className="flex flex-col space-y-2"> <View className='flex flex-col space-y-2'>
<AudioTrackSelector <AudioTrackSelector
source={selectedMediaSource} source={selectedMediaSource}
onChange={setSelectedAudioStream} onChange={setSelectedAudioStream}
@@ -374,14 +375,14 @@ export const DownloadItems: React.FC<DownloadProps> = ({
)} )}
</View> </View>
<Button <Button
className="mt-auto" className='mt-auto'
onPress={acceptDownloadOptions} onPress={acceptDownloadOptions}
color="purple" color='purple'
> >
{t("item_card.download.download_button")} {t("item_card.download.download_button")}
</Button> </Button>
<View className="opacity-70 text-center w-full flex items-center"> <View className='opacity-70 text-center w-full flex items-center'>
<Text className="text-xs"> <Text className='text-xs'>
{usingOptimizedServer {usingOptimizedServer
? t("item_card.download.using_optimized_server") ? t("item_card.download.using_optimized_server")
: t("item_card.download.using_default_method")} : t("item_card.download.using_default_method")}
@@ -411,10 +412,10 @@ export const DownloadSingleItem: React.FC<{
subtitle={item.Name!} subtitle={item.Name!}
items={[item]} items={[item]}
MissingDownloadIconComponent={() => ( MissingDownloadIconComponent={() => (
<Ionicons name="cloud-download-outline" size={24} color="white" /> <Ionicons name='cloud-download-outline' size={24} color='white' />
)} )}
DownloadedIconComponent={() => ( DownloadedIconComponent={() => (
<Ionicons name="cloud-download" size={26} color="#9333ea" /> <Ionicons name='cloud-download' size={26} color='#9333ea' />
)} )}
/> />
); );

View File

@@ -1,49 +1,57 @@
// GenreTags.tsx // GenreTags.tsx
import React from "react"; import type React from "react";
import {StyleProp, TextStyle, View, ViewProps} from "react-native"; import {
type StyleProp,
type TextStyle,
View,
type ViewProps,
} from "react-native";
import { Text } from "./common/Text"; import { Text } from "./common/Text";
interface TagProps { interface TagProps {
tags?: string[]; tags?: string[];
textClass?: ViewProps["className"] textClass?: ViewProps["className"];
} }
export const Tag: React.FC<{ text: string, textClass?: ViewProps["className"], textStyle?: StyleProp<TextStyle>} & ViewProps> = ({ export const Tag: React.FC<
text, {
textClass, text: string;
textStyle, textClass?: ViewProps["className"];
...props textStyle?: StyleProp<TextStyle>;
}) => { } & ViewProps
> = ({ text, textClass, textStyle, ...props }) => {
return ( return (
<View className="bg-neutral-800 rounded-full px-2 py-1" {...props}> <View className='bg-neutral-800 rounded-full px-2 py-1' {...props}>
<Text className={textClass} style={textStyle}>{text}</Text> <Text className={textClass} style={textStyle}>
{text}
</Text>
</View> </View>
); );
}; };
export const Tags: React.FC<TagProps & {tagProps?: ViewProps} & ViewProps> = ({ export const Tags: React.FC<
tags, TagProps & { tagProps?: ViewProps } & ViewProps
textClass = "text-xs", > = ({ tags, textClass = "text-xs", tagProps, ...props }) => {
tagProps,
...props
}) => {
if (!tags || tags.length === 0) return null; if (!tags || tags.length === 0) return null;
return ( return (
<View className={`flex flex-row flex-wrap gap-1 ${props.className}`} {...props}> <View
className={`flex flex-row flex-wrap gap-1 ${props.className}`}
{...props}
>
{tags.map((tag, idx) => ( {tags.map((tag, idx) => (
<View key={idx}> <View key={idx}>
<Tag key={idx} textClass={textClass} text={tag} {...tagProps}/> <Tag key={idx} textClass={textClass} text={tag} {...tagProps} />
</View> </View>
))} ))}
</View> </View>
); );
}; };
export const GenreTags: React.FC<{ genres?: string[]}> = ({ genres }) => { export const GenreTags: React.FC<{ genres?: string[] }> = ({ genres }) => {
return ( return (
<View className="mt-2"> <View className='mt-2'>
<Tags tags={genres}/> <Tags tags={genres} />
</View> </View>
); );
}; };

View File

@@ -1,8 +1,8 @@
import React from "react"; import { tc } from "@/utils/textTools";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import type React from "react";
import { View } from "react-native"; import { View } from "react-native";
import { Text } from "./common/Text"; import { Text } from "./common/Text";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { tc } from "@/utils/textTools";
type ItemCardProps = { type ItemCardProps = {
item: BaseItemDto; item: BaseItemDto;
@@ -10,13 +10,13 @@ type ItemCardProps = {
export const ItemCardText: React.FC<ItemCardProps> = ({ item }) => { export const ItemCardText: React.FC<ItemCardProps> = ({ item }) => {
return ( return (
<View className="mt-2 flex flex-col"> <View className='mt-2 flex flex-col'>
{item.Type === "Episode" ? ( {item.Type === "Episode" ? (
<> <>
<Text numberOfLines={1} ellipsizeMode="tail" className=""> <Text numberOfLines={1} ellipsizeMode='tail' className=''>
{item.Name} {item.Name}
</Text> </Text>
<Text numberOfLines={1} className="text-xs opacity-50"> <Text numberOfLines={1} className='text-xs opacity-50'>
{`S${item.ParentIndexNumber?.toString()}:E${item.IndexNumber?.toString()}`} {`S${item.ParentIndexNumber?.toString()}:E${item.IndexNumber?.toString()}`}
{" - "} {" - "}
{item.SeriesName} {item.SeriesName}
@@ -24,8 +24,10 @@ export const ItemCardText: React.FC<ItemCardProps> = ({ item }) => {
</> </>
) : ( ) : (
<> <>
<Text numberOfLines={1} ellipsizeMode="tail">{item.Name}</Text> <Text numberOfLines={1} ellipsizeMode='tail'>
<Text className="text-xs opacity-50">{item.ProductionYear}</Text> {item.Name}
</Text>
<Text className='text-xs opacity-50'>{item.ProductionYear}</Text>
</> </>
)} )}
</View> </View>

View File

@@ -1,5 +1,5 @@
import { AudioTrackSelector } from "@/components/AudioTrackSelector"; import { AudioTrackSelector } from "@/components/AudioTrackSelector";
import { Bitrate, BitrateSelector } from "@/components/BitrateSelector"; import { type Bitrate, BitrateSelector } from "@/components/BitrateSelector";
import { DownloadSingleItem } from "@/components/DownloadItem"; import { DownloadSingleItem } from "@/components/DownloadItem";
import { OverviewText } from "@/components/OverviewText"; import { OverviewText } from "@/components/OverviewText";
import { ParallaxScrollView } from "@/components/ParallaxPage"; import { ParallaxScrollView } from "@/components/ParallaxPage";
@@ -19,7 +19,7 @@ import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { apiAtom } from "@/providers/JellyfinProvider"; import { apiAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById"; import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
import { import type {
BaseItemDto, BaseItemDto,
MediaSourceInfo, MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models"; } from "@jellyfin/sdk/lib/generated-client/models";
@@ -86,18 +86,18 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
navigation.setOptions({ navigation.setOptions({
headerRight: () => headerRight: () =>
item && ( item && (
<View className="flex flex-row items-center space-x-2"> <View className='flex flex-row items-center space-x-2'>
<Chromecast.Chromecast <Chromecast.Chromecast
background="blur" background='blur'
width={22} width={22}
height={22} height={22}
/> />
{item.Type !== "Program" && ( {item.Type !== "Program" && (
<View className="flex flex-row items-center space-x-2"> <View className='flex flex-row items-center space-x-2'>
{!Platform.isTV && ( {!Platform.isTV && (
<DownloadSingleItem item={item} size="large" /> <DownloadSingleItem item={item} size='large' />
)} )}
<PlayedStatus items={[item]} size="large" /> <PlayedStatus items={[item]} size='large' />
<AddToFavorites item={item} /> <AddToFavorites item={item} />
</View> </View>
)} )}
@@ -123,7 +123,7 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
return ( return (
<View <View
className="flex-1 relative" className='flex-1 relative'
style={{ style={{
paddingLeft: insets.left, paddingLeft: insets.left,
paddingRight: insets.right, paddingRight: insets.right,
@@ -165,22 +165,22 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
</> </>
} }
> >
<View className="flex flex-col bg-transparent shrink"> <View className='flex flex-col bg-transparent shrink'>
<View className="flex flex-col px-4 w-full space-y-2 pt-2 mb-2 shrink"> <View className='flex flex-col px-4 w-full space-y-2 pt-2 mb-2 shrink'>
<ItemHeader item={item} className="mb-4" /> <ItemHeader item={item} className='mb-4' />
{item.Type !== "Program" && !Platform.isTV && ( {item.Type !== "Program" && !Platform.isTV && (
<View className="flex flex-row items-center justify-start w-full h-16"> <View className='flex flex-row items-center justify-start w-full h-16'>
<BitrateSelector <BitrateSelector
className="mr-1" className='mr-1'
onChange={(val) => onChange={(val) =>
setSelectedOptions( setSelectedOptions(
(prev) => prev && { ...prev, bitrate: val } (prev) => prev && { ...prev, bitrate: val },
) )
} }
selected={selectedOptions.bitrate} selected={selectedOptions.bitrate}
/> />
<MediaSourceSelector <MediaSourceSelector
className="mr-1" className='mr-1'
item={item} item={item}
onChange={(val) => onChange={(val) =>
setSelectedOptions( setSelectedOptions(
@@ -188,13 +188,13 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
prev && { prev && {
...prev, ...prev,
mediaSource: val, mediaSource: val,
} },
) )
} }
selected={selectedOptions.mediaSource} selected={selectedOptions.mediaSource}
/> />
<AudioTrackSelector <AudioTrackSelector
className="mr-1" className='mr-1'
source={selectedOptions.mediaSource} source={selectedOptions.mediaSource}
onChange={(val) => { onChange={(val) => {
setSelectedOptions( setSelectedOptions(
@@ -202,7 +202,7 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
prev && { prev && {
...prev, ...prev,
audioIndex: val, audioIndex: val,
} },
); );
}} }}
selected={selectedOptions.audioIndex} selected={selectedOptions.audioIndex}
@@ -215,7 +215,7 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
prev && { prev && {
...prev, ...prev,
subtitleIndex: val, subtitleIndex: val,
} },
) )
} }
selected={selectedOptions.subtitleIndex} selected={selectedOptions.subtitleIndex}
@@ -224,7 +224,7 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
)} )}
<PlayButton <PlayButton
className="grow" className='grow'
selectedOptions={selectedOptions} selectedOptions={selectedOptions}
item={item} item={item}
/> />
@@ -235,24 +235,24 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
)} )}
<ItemTechnicalDetails source={selectedOptions.mediaSource} /> <ItemTechnicalDetails source={selectedOptions.mediaSource} />
<OverviewText text={item.Overview} className="px-4 mb-4" /> <OverviewText text={item.Overview} className='px-4 mb-4' />
{item.Type !== "Program" && ( {item.Type !== "Program" && (
<> <>
{item.Type === "Episode" && ( {item.Type === "Episode" && (
<CurrentSeries item={item} className="mb-4" /> <CurrentSeries item={item} className='mb-4' />
)} )}
<CastAndCrew item={item} className="mb-4" loading={loading} /> <CastAndCrew item={item} className='mb-4' loading={loading} />
{item.People && item.People.length > 0 && ( {item.People && item.People.length > 0 && (
<View className="mb-4"> <View className='mb-4'>
{item.People.slice(0, 3).map((person, idx) => ( {item.People.slice(0, 3).map((person, idx) => (
<MoreMoviesWithActor <MoreMoviesWithActor
currentItem={item} currentItem={item}
key={idx} key={idx}
actorId={person.Id!} actorId={person.Id!}
className="mb-4" className='mb-4'
/> />
))} ))}
</View> </View>
@@ -265,5 +265,5 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
</ParallaxScrollView> </ParallaxScrollView>
</View> </View>
); );
} },
); );

View File

@@ -1,9 +1,9 @@
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import React from "react"; import type React from "react";
import { View, ViewProps } from "react-native"; import { View, type ViewProps } from "react-native";
import { GenreTags } from "./GenreTags"; import { GenreTags } from "./GenreTags";
import { MoviesTitleHeader } from "./movies/MoviesTitleHeader";
import { Ratings } from "./Ratings"; import { Ratings } from "./Ratings";
import { MoviesTitleHeader } from "./movies/MoviesTitleHeader";
import { EpisodeTitleHeader } from "./series/EpisodeTitleHeader"; import { EpisodeTitleHeader } from "./series/EpisodeTitleHeader";
import { ItemActions } from "./series/SeriesActions"; import { ItemActions } from "./series/SeriesActions";
@@ -15,21 +15,21 @@ export const ItemHeader: React.FC<Props> = ({ item, ...props }) => {
if (!item) if (!item)
return ( return (
<View <View
className="flex flex-col space-y-1.5 w-full items-start h-32" className='flex flex-col space-y-1.5 w-full items-start h-32'
{...props} {...props}
> >
<View className="w-1/3 h-6 bg-neutral-900 rounded" /> <View className='w-1/3 h-6 bg-neutral-900 rounded' />
<View className="w-2/3 h-8 bg-neutral-900 rounded" /> <View className='w-2/3 h-8 bg-neutral-900 rounded' />
<View className="w-2/3 h-4 bg-neutral-900 rounded" /> <View className='w-2/3 h-4 bg-neutral-900 rounded' />
<View className="w-1/4 h-4 bg-neutral-900 rounded" /> <View className='w-1/4 h-4 bg-neutral-900 rounded' />
</View> </View>
); );
return ( return (
<View className="flex flex-col" {...props}> <View className='flex flex-col' {...props}>
<View className="flex flex-col" {...props}> <View className='flex flex-col' {...props}>
<View className="flex flex-row items-center justify-between"> <View className='flex flex-row items-center justify-between'>
<Ratings item={item} className="mb-2" /> <Ratings item={item} className='mb-2' />
<ItemActions item={item} /> <ItemActions item={item} />
</View> </View>
{item.Type === "Episode" && ( {item.Type === "Episode" && (

View File

@@ -1,22 +1,23 @@
import { formatBitrate } from "@/utils/bitrate";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { import {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetScrollView,
BottomSheetView,
} from "@gorhom/bottom-sheet";
import type {
MediaSourceInfo, MediaSourceInfo,
type MediaStream, MediaStream,
} from "@jellyfin/sdk/lib/generated-client"; } from "@jellyfin/sdk/lib/generated-client";
import React, { useMemo, useRef } from "react"; import type React from "react";
import { useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import { TouchableOpacity, View } from "react-native"; import { TouchableOpacity, View } from "react-native";
import { Badge } from "./Badge"; import { Badge } from "./Badge";
import { Text } from "./common/Text";
import {
BottomSheetModal,
BottomSheetBackdropProps,
BottomSheetBackdrop,
BottomSheetView,
BottomSheetScrollView,
} from "@gorhom/bottom-sheet";
import { Button } from "./Button"; import { Button } from "./Button";
import { useTranslation } from "react-i18next"; import { Text } from "./common/Text";
import { formatBitrate } from "@/utils/bitrate";
interface Props { interface Props {
source?: MediaSourceInfo; source?: MediaSourceInfo;
@@ -27,13 +28,13 @@ export const ItemTechnicalDetails: React.FC<Props> = ({ source, ...props }) => {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<View className="px-4 mt-2 mb-4"> <View className='px-4 mt-2 mb-4'>
<Text className="text-lg font-bold mb-4">{t("item_card.video")}</Text> <Text className='text-lg font-bold mb-4'>{t("item_card.video")}</Text>
<TouchableOpacity onPress={() => bottomSheetModalRef.current?.present()}> <TouchableOpacity onPress={() => bottomSheetModalRef.current?.present()}>
<View className="flex flex-row space-x-2"> <View className='flex flex-row space-x-2'>
<VideoStreamInfo source={source} /> <VideoStreamInfo source={source} />
</View> </View>
<Text className="text-purple-600">{t("item_card.more_details")}</Text> <Text className='text-purple-600'>{t("item_card.more_details")}</Text>
</TouchableOpacity> </TouchableOpacity>
<BottomSheetModal <BottomSheetModal
ref={bottomSheetModalRef} ref={bottomSheetModalRef}
@@ -53,37 +54,37 @@ export const ItemTechnicalDetails: React.FC<Props> = ({ source, ...props }) => {
)} )}
> >
<BottomSheetScrollView> <BottomSheetScrollView>
<View className="flex flex-col space-y-2 p-4 mb-4"> <View className='flex flex-col space-y-2 p-4 mb-4'>
<View className=""> <View className=''>
<Text className="text-lg font-bold mb-4"> <Text className='text-lg font-bold mb-4'>
{t("item_card.video")} {t("item_card.video")}
</Text> </Text>
<View className="flex flex-row space-x-2"> <View className='flex flex-row space-x-2'>
<VideoStreamInfo source={source} /> <VideoStreamInfo source={source} />
</View> </View>
</View> </View>
<View className=""> <View className=''>
<Text className="text-lg font-bold mb-2"> <Text className='text-lg font-bold mb-2'>
{t("item_card.audio")} {t("item_card.audio")}
</Text> </Text>
<AudioStreamInfo <AudioStreamInfo
audioStreams={ audioStreams={
source?.MediaStreams?.filter( source?.MediaStreams?.filter(
(stream) => stream.Type === "Audio" (stream) => stream.Type === "Audio",
) || [] ) || []
} }
/> />
</View> </View>
<View className=""> <View className=''>
<Text className="text-lg font-bold mb-2"> <Text className='text-lg font-bold mb-2'>
{t("item_card.subtitles")} {t("item_card.subtitles")}
</Text> </Text>
<SubtitleStreamInfo <SubtitleStreamInfo
subtitleStreams={ subtitleStreams={
source?.MediaStreams?.filter( source?.MediaStreams?.filter(
(stream) => stream.Type === "Subtitle" (stream) => stream.Type === "Subtitle",
) || [] ) || []
} }
/> />
@@ -101,25 +102,25 @@ const SubtitleStreamInfo = ({
subtitleStreams: MediaStream[]; subtitleStreams: MediaStream[];
}) => { }) => {
return ( return (
<View className="flex flex-col"> <View className='flex flex-col'>
{subtitleStreams.map((stream, index) => ( {subtitleStreams.map((stream, index) => (
<View key={stream.Index} className="flex flex-col"> <View key={stream.Index} className='flex flex-col'>
<Text className="text-xs mb-3 text-neutral-400"> <Text className='text-xs mb-3 text-neutral-400'>
{stream.DisplayTitle} {stream.DisplayTitle}
</Text> </Text>
<View className="flex flex-row flex-wrap gap-2"> <View className='flex flex-row flex-wrap gap-2'>
<Badge <Badge
variant="gray" variant='gray'
iconLeft={ iconLeft={
<Ionicons name="language-outline" size={16} color="white" /> <Ionicons name='language-outline' size={16} color='white' />
} }
text={stream.Language} text={stream.Language}
/> />
<Badge <Badge
variant="gray" variant='gray'
text={stream.Codec} text={stream.Codec}
iconLeft={ iconLeft={
<Ionicons name="layers-outline" size={16} color="white" /> <Ionicons name='layers-outline' size={16} color='white' />
} }
/> />
</View> </View>
@@ -131,40 +132,40 @@ const SubtitleStreamInfo = ({
const AudioStreamInfo = ({ audioStreams }: { audioStreams: MediaStream[] }) => { const AudioStreamInfo = ({ audioStreams }: { audioStreams: MediaStream[] }) => {
return ( return (
<View className="flex flex-col"> <View className='flex flex-col'>
{audioStreams.map((audioStreams, index) => ( {audioStreams.map((audioStreams, index) => (
<View key={index} className="flex flex-col"> <View key={index} className='flex flex-col'>
<Text className="mb-3 text-neutral-400 text-xs"> <Text className='mb-3 text-neutral-400 text-xs'>
{audioStreams.DisplayTitle} {audioStreams.DisplayTitle}
</Text> </Text>
<View className="flex-row flex-wrap gap-2"> <View className='flex-row flex-wrap gap-2'>
<Badge <Badge
variant="gray" variant='gray'
iconLeft={ iconLeft={
<Ionicons name="language-outline" size={16} color="white" /> <Ionicons name='language-outline' size={16} color='white' />
} }
text={audioStreams.Language} text={audioStreams.Language}
/> />
<Badge <Badge
variant="gray" variant='gray'
iconLeft={ iconLeft={
<Ionicons <Ionicons
name="musical-notes-outline" name='musical-notes-outline'
size={16} size={16}
color="white" color='white'
/> />
} }
text={audioStreams.Codec} text={audioStreams.Codec}
/> />
<Badge <Badge
variant="gray" variant='gray'
iconLeft={<Ionicons name="mic-outline" size={16} color="white" />} iconLeft={<Ionicons name='mic-outline' size={16} color='white' />}
text={audioStreams.ChannelLayout} text={audioStreams.ChannelLayout}
/> />
<Badge <Badge
variant="gray" variant='gray'
iconLeft={ iconLeft={
<Ionicons name="speedometer-outline" size={16} color="white" /> <Ionicons name='speedometer-outline' size={16} color='white' />
} }
text={formatBitrate(audioStreams.BitRate)} text={formatBitrate(audioStreams.BitRate)}
/> />
@@ -180,48 +181,48 @@ const VideoStreamInfo = ({ source }: { source?: MediaSourceInfo }) => {
const videoStream = useMemo(() => { const videoStream = useMemo(() => {
return source.MediaStreams?.find( return source.MediaStreams?.find(
(stream) => stream.Type === "Video" (stream) => stream.Type === "Video",
) as MediaStream; ) as MediaStream;
}, [source.MediaStreams]); }, [source.MediaStreams]);
if (!videoStream) return null; if (!videoStream) return null;
return ( return (
<View className="flex-row flex-wrap gap-2"> <View className='flex-row flex-wrap gap-2'>
<Badge <Badge
variant="gray" variant='gray'
iconLeft={<Ionicons name="film-outline" size={16} color="white" />} iconLeft={<Ionicons name='film-outline' size={16} color='white' />}
text={formatFileSize(source.Size)} text={formatFileSize(source.Size)}
/> />
<Badge <Badge
variant="gray" variant='gray'
iconLeft={<Ionicons name="film-outline" size={16} color="white" />} iconLeft={<Ionicons name='film-outline' size={16} color='white' />}
text={`${videoStream.Width}x${videoStream.Height}`} text={`${videoStream.Width}x${videoStream.Height}`}
/> />
<Badge <Badge
variant="gray" variant='gray'
iconLeft={ iconLeft={
<Ionicons name="color-palette-outline" size={16} color="white" /> <Ionicons name='color-palette-outline' size={16} color='white' />
} }
text={videoStream.VideoRange} text={videoStream.VideoRange}
/> />
<Badge <Badge
variant="gray" variant='gray'
iconLeft={ iconLeft={
<Ionicons name="code-working-outline" size={16} color="white" /> <Ionicons name='code-working-outline' size={16} color='white' />
} }
text={videoStream.Codec} text={videoStream.Codec}
/> />
<Badge <Badge
variant="gray" variant='gray'
iconLeft={ iconLeft={
<Ionicons name="speedometer-outline" size={16} color="white" /> <Ionicons name='speedometer-outline' size={16} color='white' />
} }
text={formatBitrate(videoStream.BitRate)} text={formatBitrate(videoStream.BitRate)}
/> />
<Badge <Badge
variant="gray" variant='gray'
iconLeft={<Ionicons name="play-outline" size={16} color="white" />} iconLeft={<Ionicons name='play-outline' size={16} color='white' />}
text={`${videoStream.AverageFrameRate?.toFixed(0)} fps`} text={`${videoStream.AverageFrameRate?.toFixed(0)} fps`}
/> />
</View> </View>
@@ -233,6 +234,8 @@ const formatFileSize = (bytes?: number | null) => {
const sizes = ["Bytes", "KB", "MB", "GB", "TB"]; const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
if (bytes === 0) return "0 Byte"; if (bytes === 0) return "0 Byte";
const i = parseInt(Math.floor(Math.log(bytes) / Math.log(1024)).toString()); const i = Number.parseInt(
Math.floor(Math.log(bytes) / Math.log(1024)).toString(),
);
return Math.round((bytes / Math.pow(1024, i)) * 100) / 100 + " " + sizes[i]; return Math.round((bytes / Math.pow(1024, i)) * 100) / 100 + " " + sizes[i];
}; };

View File

@@ -1,10 +1,10 @@
import React from "react";
import { View, Text, TouchableOpacity } from "react-native";
import { useJellyfinDiscovery } from "@/hooks/useJellyfinDiscovery"; import { useJellyfinDiscovery } from "@/hooks/useJellyfinDiscovery";
import type React from "react";
import { useTranslation } from "react-i18next";
import { Text, TouchableOpacity, View } from "react-native";
import { Button } from "./Button"; import { Button } from "./Button";
import { ListGroup } from "./list/ListGroup"; import { ListGroup } from "./list/ListGroup";
import { ListItem } from "./list/ListItem"; import { ListItem } from "./list/ListItem";
import { useTranslation } from "react-i18next";
interface Props { interface Props {
onServerSelect?: (server: { address: string; serverName?: string }) => void; onServerSelect?: (server: { address: string; serverName?: string }) => void;
@@ -15,15 +15,17 @@ const JellyfinServerDiscovery: React.FC<Props> = ({ onServerSelect }) => {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<View className="mt-2"> <View className='mt-2'>
<Button onPress={startDiscovery} color="black"> <Button onPress={startDiscovery} color='black'>
<Text className="text-white text-center"> <Text className='text-white text-center'>
{isSearching ? t("server.searching") : t("server.search_for_local_servers")} {isSearching
? t("server.searching")
: t("server.search_for_local_servers")}
</Text> </Text>
</Button> </Button>
{servers.length ? ( {servers.length ? (
<ListGroup title={t("server.servers")} className="mt-4"> <ListGroup title={t("server.servers")} className='mt-4'>
{servers.map((server) => ( {servers.map((server) => (
<ListItem <ListItem
key={server.address} key={server.address}

View File

@@ -1,6 +1,6 @@
import { import {
ActivityIndicator, ActivityIndicator,
ActivityIndicatorProps, type ActivityIndicatorProps,
Platform, Platform,
View, View,
} from "react-native"; } from "react-native";

View File

@@ -1,12 +1,12 @@
import { import type {
BaseItemDto, BaseItemDto,
MediaSourceInfo, MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models"; } from "@jellyfin/sdk/lib/generated-client/models";
import { useMemo } from "react"; import { useMemo } from "react";
import { Platform, TouchableOpacity, View } from "react-native"; import { Platform, TouchableOpacity, View } from "react-native";
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null; const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
import { Text } from "./common/Text";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Text } from "./common/Text";
interface Props extends React.ComponentProps<typeof View> { interface Props extends React.ComponentProps<typeof View> {
item: BaseItemDto; item: BaseItemDto;
@@ -24,9 +24,9 @@ export const MediaSourceSelector: React.FC<Props> = ({
const selectedName = useMemo( const selectedName = useMemo(
() => () =>
item.MediaSources?.find((x) => x.Id === selected?.Id)?.MediaStreams?.find( item.MediaSources?.find((x) => x.Id === selected?.Id)?.MediaStreams?.find(
(x) => x.Type === "Video" (x) => x.Type === "Video",
)?.DisplayTitle || "", )?.DisplayTitle || "",
[item, selected] [item, selected],
); );
const { t } = useTranslation(); const { t } = useTranslation();
@@ -54,26 +54,26 @@ export const MediaSourceSelector: React.FC<Props> = ({
return ( return (
<View <View
className="flex shrink" className='flex shrink'
style={{ style={{
minWidth: 50, minWidth: 50,
}} }}
> >
<DropdownMenu.Root> <DropdownMenu.Root>
<DropdownMenu.Trigger> <DropdownMenu.Trigger>
<View className="flex flex-col" {...props}> <View className='flex flex-col' {...props}>
<Text className="opacity-50 mb-1 text-xs"> <Text className='opacity-50 mb-1 text-xs'>
{t("item_card.video")} {t("item_card.video")}
</Text> </Text>
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center"> <TouchableOpacity className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center'>
<Text numberOfLines={1}>{selectedName}</Text> <Text numberOfLines={1}>{selectedName}</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</DropdownMenu.Trigger> </DropdownMenu.Trigger>
<DropdownMenu.Content <DropdownMenu.Content
loop={true} loop={true}
side="bottom" side='bottom'
align="start" align='start'
alignOffset={0} alignOffset={0}
avoidCollisions={true} avoidCollisions={true}
collisionPadding={8} collisionPadding={8}

View File

@@ -1,17 +1,17 @@
import React from "react"; import { ItemCardText } from "@/components/ItemCardText";
import { View, ViewProps } from "react-native";
import { Text } from "@/components/common/Text";
import { HorizontalScroll } from "@/components/common/HorrizontalScroll"; import { HorizontalScroll } from "@/components/common/HorrizontalScroll";
import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter"; import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import MoviePoster from "@/components/posters/MoviePoster"; import MoviePoster from "@/components/posters/MoviePoster";
import { ItemCardText } from "@/components/ItemCardText";
import { useAtom } from "jotai";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useQuery } from "@tanstack/react-query";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData"; import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { useAtom } from "jotai";
import type React from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { View, type ViewProps } from "react-native";
interface Props extends ViewProps { interface Props extends ViewProps {
actorId: string; actorId: string;
@@ -77,8 +77,8 @@ export const MoreMoviesWithActor: React.FC<Props> = ({
return ( return (
<View {...props}> <View {...props}>
<Text className="text-lg font-bold mb-2 px-4"> <Text className='text-lg font-bold mb-2 px-4'>
{t("item_card.more_with", {name: actor?.Name})} {t("item_card.more_with", { name: actor?.Name })}
</Text> </Text>
<HorizontalScroll <HorizontalScroll
data={items} data={items}
@@ -88,7 +88,7 @@ export const MoreMoviesWithActor: React.FC<Props> = ({
<TouchableItemRouter <TouchableItemRouter
key={idx} key={idx}
item={item} item={item}
className="flex flex-col w-28" className='flex flex-col w-28'
> >
<View> <View>
<MoviePoster item={item} /> <MoviePoster item={item} />

View File

@@ -1,8 +1,8 @@
import { TouchableOpacity, View, ViewProps } from "react-native";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { tc } from "@/utils/textTools"; import { tc } from "@/utils/textTools";
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { TouchableOpacity, View, type ViewProps } from "react-native";
interface Props extends ViewProps { interface Props extends ViewProps {
text?: string | null; text?: string | null;
@@ -20,20 +20,22 @@ export const OverviewText: React.FC<Props> = ({
if (!text) return null; if (!text) return null;
return ( return (
<View className="flex flex-col" {...props}> <View className='flex flex-col' {...props}>
<Text className="text-lg font-bold mb-2">{t("item_card.overview")}</Text> <Text className='text-lg font-bold mb-2'>{t("item_card.overview")}</Text>
<TouchableOpacity <TouchableOpacity
onPress={() => onPress={() =>
setLimit((prev) => setLimit((prev) =>
prev === characterLimit ? text.length : characterLimit prev === characterLimit ? text.length : characterLimit,
) )
} }
> >
<View> <View>
<Text>{tc(text, limit)}</Text> <Text>{tc(text, limit)}</Text>
{text.length > characterLimit && ( {text.length > characterLimit && (
<Text className="text-purple-600 mt-1"> <Text className='text-purple-600 mt-1'>
{limit === characterLimit ? t("item_card.show_more") : t("item_card.show_less")} {limit === characterLimit
? t("item_card.show_more")
: t("item_card.show_less")}
</Text> </Text>
)} )}
</View> </View>

View File

@@ -1,6 +1,11 @@
import { LinearGradient } from "expo-linear-gradient"; import { LinearGradient } from "expo-linear-gradient";
import { type PropsWithChildren, type ReactElement } from "react"; import type { PropsWithChildren, ReactElement } from "react";
import {NativeScrollEvent, NativeSyntheticEvent, View, ViewProps} from "react-native"; import {
type NativeScrollEvent,
NativeSyntheticEvent,
View,
type ViewProps,
} from "react-native";
import Animated, { import Animated, {
interpolate, interpolate,
useAnimatedRef, useAnimatedRef,
@@ -35,36 +40,40 @@ export const ParallaxScrollView: React.FC<PropsWithChildren<Props>> = ({
translateY: interpolate( translateY: interpolate(
scrollOffset.value, scrollOffset.value,
[-headerHeight, 0, headerHeight], [-headerHeight, 0, headerHeight],
[-headerHeight / 2, 0, headerHeight * 0.75] [-headerHeight / 2, 0, headerHeight * 0.75],
), ),
}, },
{ {
scale: interpolate( scale: interpolate(
scrollOffset.value, scrollOffset.value,
[-headerHeight, 0, headerHeight], [-headerHeight, 0, headerHeight],
[2, 1, 1] [2, 1, 1],
), ),
}, },
], ],
}; };
}); });
function isCloseToBottom({
function isCloseToBottom({layoutMeasurement, contentOffset, contentSize}: NativeScrollEvent) { layoutMeasurement,
return layoutMeasurement.height + contentOffset.y >= contentSize.height - 20; contentOffset,
contentSize,
}: NativeScrollEvent) {
return (
layoutMeasurement.height + contentOffset.y >= contentSize.height - 20
);
} }
return ( return (
<View className="flex-1" {...props}> <View className='flex-1' {...props}>
<Animated.ScrollView <Animated.ScrollView
style={{ style={{
position: "relative", position: "relative",
}} }}
ref={scrollRef} ref={scrollRef}
scrollEventThrottle={16} scrollEventThrottle={16}
onScroll={e => { onScroll={(e) => {
if (isCloseToBottom(e.nativeEvent)) if (isCloseToBottom(e.nativeEvent)) onEndReached?.();
onEndReached?.()
}} }}
> >
{logo && ( {logo && (
@@ -73,7 +82,7 @@ export const ParallaxScrollView: React.FC<PropsWithChildren<Props>> = ({
top: headerHeight - 200, top: headerHeight - 200,
height: 130, height: 130,
}} }}
className="absolute left-0 w-full z-40 px-4 flex justify-center items-center" className='absolute left-0 w-full z-40 px-4 flex justify-center items-center'
> >
{logo} {logo}
</View> </View>
@@ -95,7 +104,7 @@ export const ParallaxScrollView: React.FC<PropsWithChildren<Props>> = ({
style={{ style={{
top: -50, top: -50,
}} }}
className="relative flex-1 bg-transparent pb-24" className='relative flex-1 bg-transparent pb-24'
> >
<LinearGradient <LinearGradient
// Background Linear Gradient // Background Linear Gradient

View File

@@ -1,6 +1,6 @@
import { BlurView } from "expo-blur"; import { BlurView } from "expo-blur";
import React from "react"; import type React from "react";
import { Platform, View, ViewProps } from "react-native"; import { Platform, View, type ViewProps } from "react-native";
interface Props extends ViewProps { interface Props extends ViewProps {
blurAmount?: number; blurAmount?: number;
blurType?: "light" | "dark" | "xlight"; blurType?: "light" | "dark" | "xlight";

View File

@@ -1,18 +1,22 @@
import { Platform, Pressable } from "react-native"; import { useHaptic } from "@/hooks/useHaptic";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor"; import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl"; import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl"; import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { chromecast } from "@/utils/profiles/chromecast";
import { chromecasth265 } from "@/utils/profiles/chromecasth265";
import ios from "@/utils/profiles/ios"; import ios from "@/utils/profiles/ios";
import { runtimeTicksToMinutes } from "@/utils/time"; import { runtimeTicksToMinutes } from "@/utils/time";
import { useActionSheet } from "@expo/react-native-action-sheet"; import { useActionSheet } from "@expo/react-native-action-sheet";
import { Feather, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons"; import { Feather, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { useRouter } from "expo-router"; import { useRouter } from "expo-router";
import { useAtom, useAtomValue } from "jotai"; import { useAtom, useAtomValue } from "jotai";
import { useCallback, useEffect } from "react"; import { useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Platform, Pressable } from "react-native";
import { Alert, TouchableOpacity, View } from "react-native"; import { Alert, TouchableOpacity, View } from "react-native";
import CastContext, { import CastContext, {
CastButton, CastButton,
@@ -30,12 +34,8 @@ import Animated, {
useSharedValue, useSharedValue,
withTiming, withTiming,
} from "react-native-reanimated"; } from "react-native-reanimated";
import { Button } from "./Button"; import type { Button } from "./Button";
import { SelectedOptions } from "./ItemContent"; import type { SelectedOptions } from "./ItemContent";
import { chromecast } from "@/utils/profiles/chromecast";
import { chromecasth265 } from "@/utils/profiles/chromecasth265";
import { useTranslation } from "react-i18next";
import { useHaptic } from "@/hooks/useHaptic";
interface Props extends React.ComponentProps<typeof Button> { interface Props extends React.ComponentProps<typeof Button> {
item: BaseItemDto; item: BaseItemDto;
@@ -74,7 +74,7 @@ export const PlayButton: React.FC<Props> = ({
(q: string) => { (q: string) => {
router.push(`/player/direct-player?${q}`); router.push(`/player/direct-player?${q}`);
}, },
[router] [router],
); );
const onPress = useCallback(async () => { const onPress = useCallback(async () => {
@@ -140,7 +140,7 @@ export const PlayButton: React.FC<Props> = ({
console.warn("No URL returned from getStreamUrl", data); console.warn("No URL returned from getStreamUrl", data);
Alert.alert( Alert.alert(
t("player.client_error"), t("player.client_error"),
t("player.could_not_create_stream_for_chromecast") t("player.could_not_create_stream_for_chromecast"),
); );
return; return;
} }
@@ -170,36 +170,36 @@ export const PlayButton: React.FC<Props> = ({
], ],
} }
: item.Type === "Movie" : item.Type === "Movie"
? { ? {
type: "movie", type: "movie",
title: item.Name || "", title: item.Name || "",
subtitle: item.Overview || "", subtitle: item.Overview || "",
images: [ images: [
{ {
url: getPrimaryImageUrl({ url: getPrimaryImageUrl({
api, api,
item, item,
quality: 90, quality: 90,
width: 2000, width: 2000,
})!, })!,
}, },
], ],
} }
: { : {
type: "generic", type: "generic",
title: item.Name || "", title: item.Name || "",
subtitle: item.Overview || "", subtitle: item.Overview || "",
images: [ images: [
{ {
url: getPrimaryImageUrl({ url: getPrimaryImageUrl({
api, api,
item, item,
quality: 90, quality: 90,
width: 2000, width: 2000,
})!, })!,
}, },
], ],
}, },
}, },
startTime: 0, startTime: 0,
}) })
@@ -222,7 +222,7 @@ export const PlayButton: React.FC<Props> = ({
case cancelButtonIndex: case cancelButtonIndex:
break; break;
} }
} },
); );
}, [ }, [
item, item,
@@ -243,7 +243,7 @@ export const PlayButton: React.FC<Props> = ({
return userData.PlaybackPositionTicks > 0 return userData.PlaybackPositionTicks > 0
? Math.max( ? Math.max(
(userData.PlaybackPositionTicks / item.RunTimeTicks) * 100, (userData.PlaybackPositionTicks / item.RunTimeTicks) * 100,
MIN_PLAYBACK_WIDTH MIN_PLAYBACK_WIDTH,
) )
: 0; : 0;
} }
@@ -260,7 +260,7 @@ export const PlayButton: React.FC<Props> = ({
easing: Easing.bezier(0.7, 0, 0.3, 1.0), easing: Easing.bezier(0.7, 0, 0.3, 1.0),
}); });
}, },
[item] [item],
); );
useAnimatedReaction( useAnimatedReaction(
@@ -273,7 +273,7 @@ export const PlayButton: React.FC<Props> = ({
easing: Easing.bezier(0.9, 0, 0.31, 0.99), easing: Easing.bezier(0.9, 0, 0.31, 0.99),
}); });
}, },
[colorAtom] [colorAtom],
); );
useEffect(() => { useEffect(() => {
@@ -294,7 +294,7 @@ export const PlayButton: React.FC<Props> = ({
backgroundColor: interpolateColor( backgroundColor: interpolateColor(
colorChangeProgress.value, colorChangeProgress.value,
[0, 1], [0, 1],
[startColor.value.primary, endColor.value.primary] [startColor.value.primary, endColor.value.primary],
), ),
})); }));
@@ -302,7 +302,7 @@ export const PlayButton: React.FC<Props> = ({
backgroundColor: interpolateColor( backgroundColor: interpolateColor(
colorChangeProgress.value, colorChangeProgress.value,
[0, 1], [0, 1],
[startColor.value.primary, endColor.value.primary] [startColor.value.primary, endColor.value.primary],
), ),
})); }));
@@ -310,7 +310,7 @@ export const PlayButton: React.FC<Props> = ({
width: `${interpolate( width: `${interpolate(
widthProgress.value, widthProgress.value,
[0, 1], [0, 1],
[startWidth.value, targetWidth.value] [startWidth.value, targetWidth.value],
)}%`, )}%`,
})); }));
@@ -318,7 +318,7 @@ export const PlayButton: React.FC<Props> = ({
color: interpolateColor( color: interpolateColor(
colorChangeProgress.value, colorChangeProgress.value,
[0, 1], [0, 1],
[startColor.value.text, endColor.value.text] [startColor.value.text, endColor.value.text],
), ),
})); }));
/** /**
@@ -328,13 +328,13 @@ export const PlayButton: React.FC<Props> = ({
return ( return (
<TouchableOpacity <TouchableOpacity
disabled={!item} disabled={!item}
accessibilityLabel="Play button" accessibilityLabel='Play button'
accessibilityHint="Tap to play the media" accessibilityHint='Tap to play the media'
onPress={onPress} onPress={onPress}
className={`relative`} className={`relative`}
{...props} {...props}
> >
<View className="absolute w-full h-full top-0 left-0 rounded-xl z-10 overflow-hidden"> <View className='absolute w-full h-full top-0 left-0 rounded-xl z-10 overflow-hidden'>
<Animated.View <Animated.View
style={[ style={[
animatedPrimaryStyle, animatedPrimaryStyle,
@@ -348,7 +348,7 @@ export const PlayButton: React.FC<Props> = ({
<Animated.View <Animated.View
style={[animatedAverageStyle, { opacity: 0.5 }]} style={[animatedAverageStyle, { opacity: 0.5 }]}
className="absolute w-full h-full top-0 left-0 rounded-xl" className='absolute w-full h-full top-0 left-0 rounded-xl'
/> />
<View <View
style={{ style={{
@@ -356,25 +356,25 @@ export const PlayButton: React.FC<Props> = ({
borderColor: colorAtom.primary, borderColor: colorAtom.primary,
borderStyle: "solid", borderStyle: "solid",
}} }}
className="flex flex-row items-center justify-center bg-transparent rounded-xl z-20 h-12 w-full " className='flex flex-row items-center justify-center bg-transparent rounded-xl z-20 h-12 w-full '
> >
<View className="flex flex-row items-center space-x-2"> <View className='flex flex-row items-center space-x-2'>
<Animated.Text style={[animatedTextStyle, { fontWeight: "bold" }]}> <Animated.Text style={[animatedTextStyle, { fontWeight: "bold" }]}>
{runtimeTicksToMinutes(item?.RunTimeTicks)} {runtimeTicksToMinutes(item?.RunTimeTicks)}
</Animated.Text> </Animated.Text>
<Animated.Text style={animatedTextStyle}> <Animated.Text style={animatedTextStyle}>
<Ionicons name="play-circle" size={24} /> <Ionicons name='play-circle' size={24} />
</Animated.Text> </Animated.Text>
{client && ( {client && (
<Animated.Text style={animatedTextStyle}> <Animated.Text style={animatedTextStyle}>
<Feather name="cast" size={22} /> <Feather name='cast' size={22} />
<CastButton tintColor="transparent" /> <CastButton tintColor='transparent' />
</Animated.Text> </Animated.Text>
)} )}
{!client && settings?.openInVLC && ( {!client && settings?.openInVLC && (
<Animated.Text style={animatedTextStyle}> <Animated.Text style={animatedTextStyle}>
<MaterialCommunityIcons <MaterialCommunityIcons
name="vlc" name='vlc'
size={18} size={18}
color={animatedTextStyle.color} color={animatedTextStyle.color}
/> />

View File

@@ -1,14 +1,16 @@
import { Platform } from "react-native"; import { useHaptic } from "@/hooks/useHaptic";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor"; import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { runtimeTicksToMinutes } from "@/utils/time"; import { runtimeTicksToMinutes } from "@/utils/time";
import { useActionSheet } from "@expo/react-native-action-sheet"; import { useActionSheet } from "@expo/react-native-action-sheet";
import { Feather, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons"; import { Feather, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { useRouter } from "expo-router"; import { useRouter } from "expo-router";
import { useAtom, useAtomValue } from "jotai"; import { useAtom, useAtomValue } from "jotai";
import { useCallback, useEffect } from "react"; import { useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Platform } from "react-native";
import { Alert, TouchableOpacity, View } from "react-native"; import { Alert, TouchableOpacity, View } from "react-native";
import Animated, { import Animated, {
Easing, Easing,
@@ -20,10 +22,8 @@ import Animated, {
useSharedValue, useSharedValue,
withTiming, withTiming,
} from "react-native-reanimated"; } from "react-native-reanimated";
import { Button } from "./Button"; import type { Button } from "./Button";
import { SelectedOptions } from "./ItemContent"; import type { SelectedOptions } from "./ItemContent";
import { useTranslation } from "react-i18next";
import { useHaptic } from "@/hooks/useHaptic";
interface Props extends React.ComponentProps<typeof Button> { interface Props extends React.ComponentProps<typeof Button> {
item: BaseItemDto; item: BaseItemDto;
@@ -34,10 +34,10 @@ const ANIMATION_DURATION = 500;
const MIN_PLAYBACK_WIDTH = 15; const MIN_PLAYBACK_WIDTH = 15;
export const PlayButton: React.FC<Props> = ({ export const PlayButton: React.FC<Props> = ({
item, item,
selectedOptions, selectedOptions,
...props ...props
}: Props) => { }: Props) => {
const { showActionSheetWithOptions } = useActionSheet(); const { showActionSheetWithOptions } = useActionSheet();
const { t } = useTranslation(); const { t } = useTranslation();
@@ -60,7 +60,7 @@ export const PlayButton: React.FC<Props> = ({
(q: string) => { (q: string) => {
router.push(`/player/direct-player?${q}`); router.push(`/player/direct-player?${q}`);
}, },
[router] [router],
); );
const onPress = () => { const onPress = () => {
@@ -88,9 +88,9 @@ export const PlayButton: React.FC<Props> = ({
if (userData && userData.PlaybackPositionTicks) { if (userData && userData.PlaybackPositionTicks) {
return userData.PlaybackPositionTicks > 0 return userData.PlaybackPositionTicks > 0
? Math.max( ? Math.max(
(userData.PlaybackPositionTicks / item.RunTimeTicks) * 100, (userData.PlaybackPositionTicks / item.RunTimeTicks) * 100,
MIN_PLAYBACK_WIDTH MIN_PLAYBACK_WIDTH,
) )
: 0; : 0;
} }
return 0; return 0;
@@ -106,7 +106,7 @@ export const PlayButton: React.FC<Props> = ({
easing: Easing.bezier(0.7, 0, 0.3, 1.0), easing: Easing.bezier(0.7, 0, 0.3, 1.0),
}); });
}, },
[item] [item],
); );
useAnimatedReaction( useAnimatedReaction(
@@ -119,7 +119,7 @@ export const PlayButton: React.FC<Props> = ({
easing: Easing.bezier(0.9, 0, 0.31, 0.99), easing: Easing.bezier(0.9, 0, 0.31, 0.99),
}); });
}, },
[colorAtom] [colorAtom],
); );
useEffect(() => { useEffect(() => {
@@ -140,7 +140,7 @@ export const PlayButton: React.FC<Props> = ({
backgroundColor: interpolateColor( backgroundColor: interpolateColor(
colorChangeProgress.value, colorChangeProgress.value,
[0, 1], [0, 1],
[startColor.value.primary, endColor.value.primary] [startColor.value.primary, endColor.value.primary],
), ),
})); }));
@@ -148,7 +148,7 @@ export const PlayButton: React.FC<Props> = ({
backgroundColor: interpolateColor( backgroundColor: interpolateColor(
colorChangeProgress.value, colorChangeProgress.value,
[0, 1], [0, 1],
[startColor.value.primary, endColor.value.primary] [startColor.value.primary, endColor.value.primary],
), ),
})); }));
@@ -156,7 +156,7 @@ export const PlayButton: React.FC<Props> = ({
width: `${interpolate( width: `${interpolate(
widthProgress.value, widthProgress.value,
[0, 1], [0, 1],
[startWidth.value, targetWidth.value] [startWidth.value, targetWidth.value],
)}%`, )}%`,
})); }));
@@ -164,7 +164,7 @@ export const PlayButton: React.FC<Props> = ({
color: interpolateColor( color: interpolateColor(
colorChangeProgress.value, colorChangeProgress.value,
[0, 1], [0, 1],
[startColor.value.text, endColor.value.text] [startColor.value.text, endColor.value.text],
), ),
})); }));
/** /**
@@ -173,13 +173,13 @@ export const PlayButton: React.FC<Props> = ({
return ( return (
<TouchableOpacity <TouchableOpacity
accessibilityLabel="Play button" accessibilityLabel='Play button'
accessibilityHint="Tap to play the media" accessibilityHint='Tap to play the media'
onPress={onPress} onPress={onPress}
className={`relative`} className={`relative`}
{...props} {...props}
> >
<View className="absolute w-full h-full top-0 left-0 rounded-xl z-10 overflow-hidden"> <View className='absolute w-full h-full top-0 left-0 rounded-xl z-10 overflow-hidden'>
<Animated.View <Animated.View
style={[ style={[
animatedPrimaryStyle, animatedPrimaryStyle,
@@ -193,7 +193,7 @@ export const PlayButton: React.FC<Props> = ({
<Animated.View <Animated.View
style={[animatedAverageStyle, { opacity: 0.5 }]} style={[animatedAverageStyle, { opacity: 0.5 }]}
className="absolute w-full h-full top-0 left-0 rounded-xl" className='absolute w-full h-full top-0 left-0 rounded-xl'
/> />
<View <View
style={{ style={{
@@ -201,19 +201,19 @@ export const PlayButton: React.FC<Props> = ({
borderColor: colorAtom.primary, borderColor: colorAtom.primary,
borderStyle: "solid", borderStyle: "solid",
}} }}
className="flex flex-row items-center justify-center bg-transparent rounded-xl z-20 h-12 w-full " className='flex flex-row items-center justify-center bg-transparent rounded-xl z-20 h-12 w-full '
> >
<View className="flex flex-row items-center space-x-2"> <View className='flex flex-row items-center space-x-2'>
<Animated.Text style={[animatedTextStyle, { fontWeight: "bold" }]}> <Animated.Text style={[animatedTextStyle, { fontWeight: "bold" }]}>
{runtimeTicksToMinutes(item?.RunTimeTicks)} {runtimeTicksToMinutes(item?.RunTimeTicks)}
</Animated.Text> </Animated.Text>
<Animated.Text style={animatedTextStyle}> <Animated.Text style={animatedTextStyle}>
<Ionicons name="play-circle" size={24} /> <Ionicons name='play-circle' size={24} />
</Animated.Text> </Animated.Text>
{settings?.openInVLC && ( {settings?.openInVLC && (
<Animated.Text style={animatedTextStyle}> <Animated.Text style={animatedTextStyle}>
<MaterialCommunityIcons <MaterialCommunityIcons
name="vlc" name='vlc'
size={18} size={18}
color={animatedTextStyle.color} color={animatedTextStyle.color}
/> />

View File

@@ -1,8 +1,8 @@
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed"; import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import React from "react"; import type React from "react";
import { View, ViewProps } from "react-native"; import { View, type ViewProps } from "react-native";
import { RoundButton } from "./RoundButton"; import { RoundButton } from "./RoundButton";
interface Props extends ViewProps { interface Props extends ViewProps {
@@ -18,7 +18,7 @@ export const PlayedStatus: React.FC<Props> = ({ items, ...props }) => {
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ["item", item.Id], queryKey: ["item", item.Id],
}); });
}) });
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ["resumeItems"], queryKey: ["resumeItems"],
}); });
@@ -51,9 +51,9 @@ export const PlayedStatus: React.FC<Props> = ({ items, ...props }) => {
<RoundButton <RoundButton
fillColor={allPlayed ? "primary" : undefined} fillColor={allPlayed ? "primary" : undefined}
icon={allPlayed ? "checkmark" : "checkmark"} icon={allPlayed ? "checkmark" : "checkmark"}
onPress={async () => { onPress={async () => {
console.log(allPlayed); console.log(allPlayed);
await markAsPlayedStatus(!allPlayed) await markAsPlayedStatus(!allPlayed);
}} }}
size={props.size} size={props.size}
/> />

View File

@@ -1,9 +1,10 @@
import React, { useMemo } from "react"; import type React from "react";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { View } from "react-native"; import { View } from "react-native";
import { useMMKVString } from "react-native-mmkv"; import { useMMKVString } from "react-native-mmkv";
import { ListGroup } from "./list/ListGroup"; import { ListGroup } from "./list/ListGroup";
import { ListItem } from "./list/ListItem"; import { ListItem } from "./list/ListItem";
import { useTranslation } from "react-i18next";
interface Server { interface Server {
address: string; address: string;
@@ -29,7 +30,7 @@ export const PreviousServersList: React.FC<PreviousServersListProps> = ({
return ( return (
<View> <View>
<ListGroup title={t("server.previous_servers")} className="mt-4"> <ListGroup title={t("server.previous_servers")} className='mt-4'>
{previousServers.map((s) => ( {previousServers.map((s) => (
<ListItem <ListItem
key={s.address} key={s.address}
@@ -43,7 +44,7 @@ export const PreviousServersList: React.FC<PreviousServersListProps> = ({
setPreviousServers("[]"); setPreviousServers("[]");
}} }}
title={t("server.clear_button")} title={t("server.clear_button")}
textColor="red" textColor='red'
/> />
</ListGroup> </ListGroup>
</View> </View>

View File

@@ -1,5 +1,5 @@
import React from "react"; import type React from "react";
import { View, StyleSheet } from "react-native"; import { StyleSheet, View } from "react-native";
import { AnimatedCircularProgress } from "react-native-circular-progress"; import { AnimatedCircularProgress } from "react-native-circular-progress";
type ProgressCircleProps = { type ProgressCircleProps = {

View File

@@ -1,15 +1,18 @@
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { View, ViewProps } from "react-native";
import { Badge } from "./Badge";
import { Ionicons } from "@expo/vector-icons";
import { Image } from "expo-image";
import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search";
import { useJellyseerr } from "@/hooks/useJellyseerr"; import { useJellyseerr } from "@/hooks/useJellyseerr";
import { useQuery } from "@tanstack/react-query";
import { MediaType } from "@/utils/jellyseerr/server/constants/media"; import { MediaType } from "@/utils/jellyseerr/server/constants/media";
import {MovieDetails} from "@/utils/jellyseerr/server/models/Movie"; import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
import {TvDetails} from "@/utils/jellyseerr/server/models/Tv"; import type {
import {useMemo} from "react"; MovieResult,
TvResult,
} from "@/utils/jellyseerr/server/models/Search";
import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useMemo } from "react";
import { View, type ViewProps } from "react-native";
import { Badge } from "./Badge";
interface Props extends ViewProps { interface Props extends ViewProps {
item?: BaseItemDto | null; item?: BaseItemDto | null;
@@ -18,21 +21,21 @@ interface Props extends ViewProps {
export const Ratings: React.FC<Props> = ({ item, ...props }) => { export const Ratings: React.FC<Props> = ({ item, ...props }) => {
if (!item) return null; if (!item) return null;
return ( return (
<View className="flex flex-row items-center mt-2 space-x-2" {...props}> <View className='flex flex-row items-center mt-2 space-x-2' {...props}>
{item.OfficialRating && ( {item.OfficialRating && (
<Badge text={item.OfficialRating} variant="gray" /> <Badge text={item.OfficialRating} variant='gray' />
)} )}
{item.CommunityRating && ( {item.CommunityRating && (
<Badge <Badge
text={item.CommunityRating.toFixed(1)} text={item.CommunityRating.toFixed(1)}
variant="gray" variant='gray'
iconLeft={<Ionicons name="star" size={14} color="gold" />} iconLeft={<Ionicons name='star' size={14} color='gold' />}
/> />
)} )}
{item.CriticRating && ( {item.CriticRating && (
<Badge <Badge
text={item.CriticRating} text={item.CriticRating}
variant="gray" variant='gray'
iconLeft={ iconLeft={
<Image <Image
source={ source={
@@ -52,9 +55,9 @@ export const Ratings: React.FC<Props> = ({ item, ...props }) => {
); );
}; };
export const JellyserrRatings: React.FC<{ result: MovieResult | TvResult | TvDetails | MovieDetails }> = ({ export const JellyserrRatings: React.FC<{
result, result: MovieResult | TvResult | TvDetails | MovieDetails;
}) => { }> = ({ result }) => {
const { jellyseerrApi, getMediaType } = useJellyseerr(); const { jellyseerrApi, getMediaType } = useJellyseerr();
const mediaType = useMemo(() => getMediaType(result), [result]); const mediaType = useMemo(() => getMediaType(result), [result]);
@@ -76,14 +79,14 @@ export const JellyserrRatings: React.FC<{ result: MovieResult | TvResult | TvDet
!!result.voteCount || !!result.voteCount ||
(data?.criticsRating && !!data?.criticsScore) || (data?.criticsRating && !!data?.criticsScore) ||
(data?.audienceRating && !!data?.audienceScore)) && ( (data?.audienceRating && !!data?.audienceScore)) && (
<View className="flex flex-row flex-wrap space-x-1"> <View className='flex flex-row flex-wrap space-x-1'>
{data?.criticsRating && !!data?.criticsScore && ( {data?.criticsRating && !!data?.criticsScore && (
<Badge <Badge
text={`${data.criticsScore}%`} text={`${data.criticsScore}%`}
variant="gray" variant='gray'
iconLeft={ iconLeft={
<Image <Image
className="mr-1" className='mr-1'
source={ source={
data?.criticsRating === "Rotten" data?.criticsRating === "Rotten"
? require("@/utils/jellyseerr/src/assets/rt_rotten.svg") ? require("@/utils/jellyseerr/src/assets/rt_rotten.svg")
@@ -100,10 +103,10 @@ export const JellyserrRatings: React.FC<{ result: MovieResult | TvResult | TvDet
{data?.audienceRating && !!data?.audienceScore && ( {data?.audienceRating && !!data?.audienceScore && (
<Badge <Badge
text={`${data.audienceScore}%`} text={`${data.audienceScore}%`}
variant="gray" variant='gray'
iconLeft={ iconLeft={
<Image <Image
className="mr-1" className='mr-1'
source={ source={
data?.audienceRating === "Spilled" data?.audienceRating === "Spilled"
? require("@/utils/jellyseerr/src/assets/rt_aud_rotten.svg") ? require("@/utils/jellyseerr/src/assets/rt_aud_rotten.svg")
@@ -120,10 +123,10 @@ export const JellyserrRatings: React.FC<{ result: MovieResult | TvResult | TvDet
{!!result.voteCount && ( {!!result.voteCount && (
<Badge <Badge
text={`${Math.round(result.voteAverage * 10)}%`} text={`${Math.round(result.voteAverage * 10)}%`}
variant="gray" variant='gray'
iconLeft={ iconLeft={
<Image <Image
className="mr-1" className='mr-1'
source={require("@/utils/jellyseerr/src/assets/tmdb_logo.svg")} source={require("@/utils/jellyseerr/src/assets/tmdb_logo.svg")}
style={{ style={{
width: 14, width: 14,

View File

@@ -1,12 +1,12 @@
import { useHaptic } from "@/hooks/useHaptic";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { BlurView } from "expo-blur"; import { BlurView } from "expo-blur";
import { PropsWithChildren } from "react"; import type { PropsWithChildren } from "react";
import { import {
Platform, Platform,
TouchableOpacity, TouchableOpacity,
TouchableOpacityProps, type TouchableOpacityProps,
} from "react-native"; } from "react-native";
import { useHaptic } from "@/hooks/useHaptic";
interface Props extends TouchableOpacityProps { interface Props extends TouchableOpacityProps {
onPress?: () => void; onPress?: () => void;

View File

@@ -1,18 +1,23 @@
import MoviePoster from "@/components/posters/MoviePoster"; import MoviePoster from "@/components/posters/MoviePoster";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getLibraryApi } from "@jellyfin/sdk/lib/utils/api"; import { getLibraryApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { router } from "expo-router"; import { router } from "expo-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useMemo } from "react"; import { useMemo } from "react";
import { ScrollView, TouchableOpacity, View, ViewProps } from "react-native"; import { useTranslation } from "react-i18next";
import { Text } from "./common/Text"; import {
ScrollView,
TouchableOpacity,
View,
type ViewProps,
} from "react-native";
import { ItemCardText } from "./ItemCardText"; import { ItemCardText } from "./ItemCardText";
import { Loader } from "./Loader"; import { Loader } from "./Loader";
import { HorizontalScroll } from "./common/HorrizontalScroll"; import { HorizontalScroll } from "./common/HorrizontalScroll";
import { Text } from "./common/Text";
import { TouchableItemRouter } from "./common/TouchableItemRouter"; import { TouchableItemRouter } from "./common/TouchableItemRouter";
import { useTranslation } from "react-i18next";
interface SimilarItemsProps extends ViewProps { interface SimilarItemsProps extends ViewProps {
itemId?: string | null; itemId?: string | null;
@@ -39,17 +44,19 @@ export const SimilarItems: React.FC<SimilarItemsProps> = ({
return response.data.Items || []; return response.data.Items || [];
}, },
enabled: !!api && !!user?.Id, enabled: !!api && !!user?.Id,
staleTime: Infinity, staleTime: Number.POSITIVE_INFINITY,
}); });
const movies = useMemo( const movies = useMemo(
() => similarItems?.filter((i) => i.Type === "Movie") || [], () => similarItems?.filter((i) => i.Type === "Movie") || [],
[similarItems] [similarItems],
); );
return ( return (
<View {...props}> <View {...props}>
<Text className="px-4 text-lg font-bold mb-2">{t("item_card.similar_items")}</Text> <Text className='px-4 text-lg font-bold mb-2'>
{t("item_card.similar_items")}
</Text>
<HorizontalScroll <HorizontalScroll
data={movies} data={movies}
loading={isLoading} loading={isLoading}
@@ -59,7 +66,7 @@ export const SimilarItems: React.FC<SimilarItemsProps> = ({
<TouchableItemRouter <TouchableItemRouter
key={idx} key={idx}
item={item} item={item}
className="flex flex-col w-28" className='flex flex-col w-28'
> >
<View> <View>
<MoviePoster item={item} /> <MoviePoster item={item} />

View File

@@ -1,10 +1,10 @@
import { tc } from "@/utils/textTools"; import { tc } from "@/utils/textTools";
import { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models"; import type { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
import { useMemo } from "react"; import { useMemo } from "react";
import { Platform, TouchableOpacity, View } from "react-native"; import { Platform, TouchableOpacity, View } from "react-native";
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null; const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
import { Text } from "./common/Text";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Text } from "./common/Text";
interface Props extends React.ComponentProps<typeof View> { interface Props extends React.ComponentProps<typeof View> {
source?: MediaSourceInfo; source?: MediaSourceInfo;
@@ -25,7 +25,7 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
const selectedSubtitleSteam = useMemo( const selectedSubtitleSteam = useMemo(
() => subtitleStreams?.find((x) => x.Index === selected), () => subtitleStreams?.find((x) => x.Index === selected),
[subtitleStreams, selected] [subtitleStreams, selected],
); );
if (subtitleStreams?.length === 0) return null; if (subtitleStreams?.length === 0) return null;
@@ -34,7 +34,7 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
return ( return (
<View <View
className="flex col shrink justify-start place-self-start items-start" className='flex col shrink justify-start place-self-start items-start'
style={{ style={{
minWidth: 60, minWidth: 60,
maxWidth: 200, maxWidth: 200,
@@ -42,12 +42,12 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
> >
<DropdownMenu.Root> <DropdownMenu.Root>
<DropdownMenu.Trigger> <DropdownMenu.Trigger>
<View className="flex flex-col " {...props}> <View className='flex flex-col ' {...props}>
<Text numberOfLines={1} className="opacity-50 mb-1 text-xs"> <Text numberOfLines={1} className='opacity-50 mb-1 text-xs'>
{t("item_card.subtitles")} {t("item_card.subtitles")}
</Text> </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"> <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 className=" "> <Text className=' '>
{selectedSubtitleSteam {selectedSubtitleSteam
? tc(selectedSubtitleSteam?.DisplayTitle, 7) ? tc(selectedSubtitleSteam?.DisplayTitle, 7)
: t("item_card.none")} : t("item_card.none")}
@@ -57,8 +57,8 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
</DropdownMenu.Trigger> </DropdownMenu.Trigger>
<DropdownMenu.Content <DropdownMenu.Content
loop={true} loop={true}
side="bottom" side='bottom'
align="start" align='start'
alignOffset={0} alignOffset={0}
avoidCollisions={true} avoidCollisions={true}
collisionPadding={8} collisionPadding={8}

View File

@@ -1,4 +1,4 @@
import { Text, type TextProps, StyleSheet } from "react-native"; import { StyleSheet, Text, type TextProps } from "react-native";
export type ThemedTextProps = TextProps & { export type ThemedTextProps = TextProps & {
lightColor?: string; lightColor?: string;

View File

@@ -1,5 +1,5 @@
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import React from "react"; import type React from "react";
import { View } from "react-native"; import { View } from "react-native";
export const WatchedIndicator: React.FC<{ item: BaseItemDto }> = ({ item }) => { export const WatchedIndicator: React.FC<{ item: BaseItemDto }> = ({ item }) => {
@@ -7,7 +7,7 @@ export const WatchedIndicator: React.FC<{ item: BaseItemDto }> = ({ item }) => {
<> <>
{item.UserData?.Played === false && {item.UserData?.Played === false &&
(item.Type === "Movie" || item.Type === "Episode") && ( (item.Type === "Movie" || item.Type === "Episode") && (
<View className="bg-purple-600 w-8 h-8 absolute -top-4 -right-4 rotate-45"></View> <View className='bg-purple-600 w-8 h-8 absolute -top-4 -right-4 rotate-45'></View>
)} )}
</> </>
); );

View File

@@ -1,10 +1,12 @@
import * as React from 'react'; import * as React from "react";
import renderer from 'react-test-renderer'; import renderer from "react-test-renderer";
import { ThemedText } from '../ThemedText'; import { ThemedText } from "../ThemedText";
it(`renders correctly`, () => { it(`renders correctly`, () => {
const tree = renderer.create(<ThemedText>Snapshot test!</ThemedText>).toJSON(); const tree = renderer
.create(<ThemedText>Snapshot test!</ThemedText>)
.toJSON();
expect(tree).toMatchSnapshot(); expect(tree).toMatchSnapshot();
}); });

View File

@@ -1,5 +1,5 @@
import { View, ViewProps } from "react-native";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { View, type ViewProps } from "react-native";
interface Props extends ViewProps {} interface Props extends ViewProps {}

View File

@@ -1,5 +1,5 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { StyleSheet, View, ViewProps } from "react-native"; import { StyleSheet, View, type ViewProps } from "react-native";
const getItemStyle = (index: number, numColumns: number) => { const getItemStyle = (index: number, numColumns: number) => {
const alignItems = (() => { const alignItems = (() => {
@@ -29,7 +29,7 @@ export const ColumnItem = ({
...rest ...rest
}: ColumnItemProps) => { }: ColumnItemProps) => {
return ( return (
<View className="flex flex-col mb-2 p-4" style={{ width: "33.3%" }}> <View className='flex flex-col mb-2 p-4' style={{ width: "33.3%" }}>
<View <View
className={` className={`
`} `}

View File

@@ -1,13 +1,13 @@
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null; const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
import { Platform, TouchableOpacity, View, ViewProps } from "react-native";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import DisabledSetting from "@/components/settings/DisabledSetting";
import React, { import React, {
PropsWithChildren, type PropsWithChildren,
ReactNode, type ReactNode,
useEffect, useEffect,
useState, useState,
} from "react"; } from "react";
import DisabledSetting from "@/components/settings/DisabledSetting"; import { Platform, TouchableOpacity, View, type ViewProps } from "react-native";
interface Props<T> { interface Props<T> {
data: T[]; data: T[];
@@ -21,7 +21,7 @@ interface Props<T> {
multiple?: boolean; multiple?: boolean;
} }
const Dropdown = <T extends unknown>({ const Dropdown = <T,>({
data, data,
disabled, disabled,
placeholderText, placeholderText,
@@ -47,10 +47,10 @@ const Dropdown = <T extends unknown>({
<DropdownMenu.Root> <DropdownMenu.Root>
<DropdownMenu.Trigger> <DropdownMenu.Trigger>
{typeof title === "string" ? ( {typeof title === "string" ? (
<View className="flex flex-col"> <View className='flex flex-col'>
<Text className="opacity-50 mb-1 text-xs">{title}</Text> <Text className='opacity-50 mb-1 text-xs'>{title}</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"> <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}> <Text style={{}} className='' numberOfLines={1}>
{selected?.length !== undefined {selected?.length !== undefined
? selected.map(titleExtractor).join(",") ? selected.map(titleExtractor).join(",")
: placeholderText} : placeholderText}
@@ -63,8 +63,8 @@ const Dropdown = <T extends unknown>({
</DropdownMenu.Trigger> </DropdownMenu.Trigger>
<DropdownMenu.Content <DropdownMenu.Content
loop={false} loop={false}
side="bottom" side='bottom'
align="center" align='center'
alignOffset={0} alignOffset={0}
avoidCollisions={true} avoidCollisions={true}
collisionPadding={0} collisionPadding={0}
@@ -88,10 +88,10 @@ const Dropdown = <T extends unknown>({
} }
return [ return [
...prev.filter( ...prev.filter(
(p) => keyExtractor(p) !== keyExtractor(item) (p) => keyExtractor(p) !== keyExtractor(item),
), ),
]; ];
}) });
}} }}
> >
<DropdownMenu.ItemTitle> <DropdownMenu.ItemTitle>
@@ -107,7 +107,7 @@ const Dropdown = <T extends unknown>({
{titleExtractor(item)} {titleExtractor(item)}
</DropdownMenu.ItemTitle> </DropdownMenu.ItemTitle>
</DropdownMenu.Item> </DropdownMenu.Item>
) ),
)} )}
</DropdownMenu.Content> </DropdownMenu.Content>
</DropdownMenu.Root> </DropdownMenu.Root>

View File

@@ -1,14 +1,14 @@
import { Text } from "@/components/common/Text";
import { Ionicons } from "@expo/vector-icons";
import { BlurView, type BlurViewProps } from "expo-blur";
import { useRouter } from "expo-router";
import { import {
Platform, Platform,
TouchableOpacity, TouchableOpacity,
TouchableOpacityProps, type TouchableOpacityProps,
View, View,
ViewProps, ViewProps,
} from "react-native"; } from "react-native";
import { Text } from "@/components/common/Text";
import { useRouter } from "expo-router";
import { Ionicons } from "@expo/vector-icons";
import { BlurView, BlurViewProps } from "expo-blur";
interface Props extends BlurViewProps { interface Props extends BlurViewProps {
background?: "blur" | "transparent"; background?: "blur" | "transparent";
@@ -31,13 +31,13 @@ export const HeaderBackButton: React.FC<Props> = ({
<BlurView <BlurView
{...props} {...props}
intensity={100} intensity={100}
className="overflow-hidden rounded-full p-2" className='overflow-hidden rounded-full p-2'
> >
<Ionicons <Ionicons
className="drop-shadow-2xl" className='drop-shadow-2xl'
name="arrow-back" name='arrow-back'
size={24} size={24}
color="white" color='white'
/> />
</BlurView> </BlurView>
</TouchableOpacity> </TouchableOpacity>
@@ -46,14 +46,14 @@ export const HeaderBackButton: React.FC<Props> = ({
return ( return (
<TouchableOpacity <TouchableOpacity
onPress={() => router.back()} onPress={() => router.back()}
className=" bg-neutral-800/80 rounded-full p-2" className=' bg-neutral-800/80 rounded-full p-2'
{...touchableOpacityProps} {...touchableOpacityProps}
> >
<Ionicons <Ionicons
className="drop-shadow-2xl" className='drop-shadow-2xl'
name="arrow-back" name='arrow-back'
size={24} size={24}
color="white" color='white'
/> />
</TouchableOpacity> </TouchableOpacity>
); );

View File

@@ -1,6 +1,6 @@
import { FlashList, FlashListProps } from "@shopify/flash-list"; import { FlashList, type FlashListProps } from "@shopify/flash-list";
import React, { forwardRef, useImperativeHandle, useRef } from "react"; import React, { forwardRef, useImperativeHandle, useRef } from "react";
import { View, ViewStyle } from "react-native"; import { View, type ViewStyle } from "react-native";
import { Text } from "./Text"; import { Text } from "./Text";
type PartialExcept<T, K extends keyof T> = Partial<T> & Pick<T, K>; type PartialExcept<T, K extends keyof T> = Partial<T> & Pick<T, K>;
@@ -44,7 +44,7 @@ export const HorizontalScroll = forwardRef<
noItemsText, noItemsText,
...props ...props
}: HorizontalScrollProps<T>, }: HorizontalScrollProps<T>,
ref: React.ForwardedRef<HorizontalScrollRef> ref: React.ForwardedRef<HorizontalScrollRef>,
) => { ) => {
const flashListRef = useRef<FlashList<T>>(null); const flashListRef = useRef<FlashList<T>>(null);
@@ -66,16 +66,16 @@ export const HorizontalScroll = forwardRef<
item: T; item: T;
index: number; index: number;
}) => ( }) => (
<View className="mr-2"> <View className='mr-2'>
<React.Fragment>{renderItem(item, index)}</React.Fragment> <React.Fragment>{renderItem(item, index)}</React.Fragment>
</View> </View>
); );
if (!data || loading) { if (!data || loading) {
return ( return (
<View className="px-4 mb-2"> <View className='px-4 mb-2'>
<View className="bg-neutral-950 h-24 w-full rounded-md mb-2"></View> <View className='bg-neutral-950 h-24 w-full rounded-md mb-2'></View>
<View className="bg-neutral-950 h-10 w-full rounded-md mb-1"></View> <View className='bg-neutral-950 h-10 w-full rounded-md mb-1'></View>
</View> </View>
); );
} }
@@ -95,8 +95,8 @@ export const HorizontalScroll = forwardRef<
}} }}
keyExtractor={keyExtractor} keyExtractor={keyExtractor}
ListEmptyComponent={() => ( ListEmptyComponent={() => (
<View className="flex-1 justify-center items-center"> <View className='flex-1 justify-center items-center'>
<Text className="text-center text-gray-500"> <Text className='text-center text-gray-500'>
{noItemsText || "No data available"} {noItemsText || "No data available"}
</Text> </Text>
</View> </View>
@@ -104,5 +104,5 @@ export const HorizontalScroll = forwardRef<
{...props} {...props}
/> />
); );
} },
); );

View File

@@ -1,13 +1,14 @@
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { import type {
BaseItemDto, BaseItemDto,
BaseItemDtoQueryResult, BaseItemDtoQueryResult,
} from "@jellyfin/sdk/lib/generated-client/models"; } from "@jellyfin/sdk/lib/generated-client/models";
import { FlashList, FlashListProps } from "@shopify/flash-list"; import { FlashList, type FlashListProps } from "@shopify/flash-list";
import { useInfiniteQuery } from "@tanstack/react-query"; import { useInfiniteQuery } from "@tanstack/react-query";
import { t } from "i18next";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import React, { useEffect, useMemo } from "react"; import React, { useEffect, useMemo } from "react";
import { View, ViewStyle } from "react-native"; import { View, type ViewStyle } from "react-native";
import Animated, { import Animated, {
useAnimatedStyle, useAnimatedStyle,
useSharedValue, useSharedValue,
@@ -15,7 +16,6 @@ import Animated, {
} from "react-native-reanimated"; } from "react-native-reanimated";
import { Loader } from "../Loader"; import { Loader } from "../Loader";
import { Text } from "./Text"; import { Text } from "./Text";
import { t } from "i18next";
interface HorizontalScrollProps interface HorizontalScrollProps
extends Omit<FlashListProps<BaseItemDto>, "renderItem" | "data" | "style"> { extends Omit<FlashListProps<BaseItemDto>, "renderItem" | "data" | "style"> {
@@ -70,7 +70,7 @@ export function InfiniteHorizontalScroll({
const totalItems = lastPage.TotalRecordCount; const totalItems = lastPage.TotalRecordCount;
const accumulatedItems = pages.reduce( const accumulatedItems = pages.reduce(
(acc, curr) => acc + (curr?.Items?.length || 0), (acc, curr) => acc + (curr?.Items?.length || 0),
0 0,
); );
if (accumulatedItems < totalItems) { if (accumulatedItems < totalItems) {
@@ -118,7 +118,7 @@ export function InfiniteHorizontalScroll({
<FlashList <FlashList
data={flatData} data={flatData}
renderItem={({ item, index }) => ( renderItem={({ item, index }) => (
<View className="mr-2"> <View className='mr-2'>
<React.Fragment>{renderItem(item, index)}</React.Fragment> <React.Fragment>{renderItem(item, index)}</React.Fragment>
</View> </View>
)} )}
@@ -136,8 +136,10 @@ export function InfiniteHorizontalScroll({
}} }}
showsHorizontalScrollIndicator={false} showsHorizontalScrollIndicator={false}
ListEmptyComponent={ ListEmptyComponent={
<View className="flex-1 justify-center items-center"> <View className='flex-1 justify-center items-center'>
<Text className="text-center text-gray-500">{t("item_card.no_data_available")}</Text> <Text className='text-center text-gray-500'>
{t("item_card.no_data_available")}
</Text>
</View> </View>
} }
{...props} {...props}

View File

@@ -1,32 +1,35 @@
import React from "react"; import React from "react";
import {Platform, TextInput, TextInputProps, TouchableOpacity} from "react-native"; import {
Platform,
TextInput,
type TextInputProps,
TouchableOpacity,
} from "react-native";
export function Input(props: TextInputProps) { export function Input(props: TextInputProps) {
const { style, ...otherProps } = props; const { style, ...otherProps } = props;
const inputRef = React.useRef<TextInput>(null); const inputRef = React.useRef<TextInput>(null);
return Platform.isTV ? ( return Platform.isTV ? (
<TouchableOpacity <TouchableOpacity onFocus={() => inputRef?.current?.focus?.()}>
onFocus={() => inputRef?.current?.focus?.()} <TextInput
> ref={inputRef}
<TextInput className='p-4 rounded-xl bg-neutral-900'
ref={inputRef} allowFontScaling={false}
className="p-4 rounded-xl bg-neutral-900" style={[{ color: "white" }, style]}
allowFontScaling={false} placeholderTextColor={"#9CA3AF"}
style={[{ color: "white" }, style]} clearButtonMode='while-editing'
placeholderTextColor={"#9CA3AF"} {...otherProps}
clearButtonMode="while-editing" />
{...otherProps} </TouchableOpacity>
/>
</TouchableOpacity>
) : ( ) : (
<TextInput <TextInput
ref={inputRef} ref={inputRef}
className="p-4 rounded-xl bg-neutral-900" className='p-4 rounded-xl bg-neutral-900'
allowFontScaling={false} allowFontScaling={false}
style={[{ color: "white" }, style]} style={[{ color: "white" }, style]}
placeholderTextColor={"#9CA3AF"} placeholderTextColor={"#9CA3AF"}
clearButtonMode="while-editing" clearButtonMode='while-editing'
{...otherProps} {...otherProps}
/> />
) );
} }

View File

@@ -1,11 +1,11 @@
import { apiAtom } from "@/providers/JellyfinProvider"; import { apiAtom } from "@/providers/JellyfinProvider";
import { getItemImage } from "@/utils/getItemImage"; import { getItemImage } from "@/utils/getItemImage";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { Image, ImageProps } from "expo-image"; import { Image, type ImageProps } from "expo-image";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import {FC, useMemo} from "react"; import { type FC, useMemo } from "react";
import { View, ViewProps } from "react-native"; import { View, type ViewProps } from "react-native";
interface Props extends ImageProps { interface Props extends ImageProps {
item: BaseItemDto; item: BaseItemDto;
@@ -52,13 +52,13 @@ export const ItemImage: FC<Props> = ({
if (!source?.uri) if (!source?.uri)
return ( return (
<View <View
{...props as ViewProps} {...(props as ViewProps)}
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 <Ionicons
name="image-outline" name='image-outline'
size={24} size={24}
color="white" color='white'
style={{ opacity: 0.4 }} style={{ opacity: 0.4 }}
/> />
</View> </View>

View File

@@ -1,16 +1,20 @@
import { useRouter, useSegments } from "expo-router";
import React, { PropsWithChildren, useCallback, useMemo } from "react";
import { TouchableOpacity, TouchableOpacityProps } from "react-native";
import * as ContextMenu from "@/components/ContextMenu"; import * as ContextMenu from "@/components/ContextMenu";
import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search";
import { useJellyseerr } from "@/hooks/useJellyseerr"; import { useJellyseerr } from "@/hooks/useJellyseerr";
import {
hasPermission,
Permission,
} from "@/utils/jellyseerr/server/lib/permissions";
import { MediaType } from "@/utils/jellyseerr/server/constants/media"; import { MediaType } from "@/utils/jellyseerr/server/constants/media";
import {TvDetails} from "@/utils/jellyseerr/server/models/Tv"; import {
import {MovieDetails} from "@/utils/jellyseerr/server/models/Movie"; Permission,
hasPermission,
} from "@/utils/jellyseerr/server/lib/permissions";
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
import type {
MovieResult,
TvResult,
} from "@/utils/jellyseerr/server/models/Search";
import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
import { useRouter, useSegments } from "expo-router";
import type React from "react";
import { type PropsWithChildren, useCallback, useMemo } from "react";
import { TouchableOpacity, type TouchableOpacityProps } from "react-native";
interface Props extends TouchableOpacityProps { interface Props extends TouchableOpacityProps {
result?: MovieResult | TvResult | MovieDetails | TvDetails; result?: MovieResult | TvResult | MovieDetails | TvDetails;
@@ -46,16 +50,13 @@ export const TouchableJellyseerrRouter: React.FC<PropsWithChildren<Props>> = ({
); );
}, [jellyseerrApi, jellyseerrUser]); }, [jellyseerrApi, jellyseerrUser]);
const request = useCallback( const request = useCallback(() => {
() => { if (!result) return;
if (!result) return; requestMedia(mediaTitle, {
requestMedia(mediaTitle, { mediaId: result.id,
mediaId: result.id, mediaType,
mediaType, });
}) }, [jellyseerrApi, result]);
},
[jellyseerrApi, result]
);
if (from === "(home)" || from === "(search)" || from === "(libraries)") if (from === "(home)" || from === "(search)" || from === "(libraries)")
return ( return (
@@ -75,7 +76,7 @@ export const TouchableJellyseerrRouter: React.FC<PropsWithChildren<Props>> = ({
releaseYear, releaseYear,
canRequest, canRequest,
posterSrc, posterSrc,
mediaType mediaType,
}, },
}); });
}} }}
@@ -91,10 +92,10 @@ export const TouchableJellyseerrRouter: React.FC<PropsWithChildren<Props>> = ({
loop={false} loop={false}
key={"content"} key={"content"}
> >
<ContextMenu.Label key="label-1">Actions</ContextMenu.Label> <ContextMenu.Label key='label-1'>Actions</ContextMenu.Label>
{canRequest && mediaType === MediaType.MOVIE && ( {canRequest && mediaType === MediaType.MOVIE && (
<ContextMenu.Item <ContextMenu.Item
key="item-1" key='item-1'
onSelect={() => { onSelect={() => {
if (autoApprove) { if (autoApprove) {
request(); request();
@@ -102,7 +103,7 @@ export const TouchableJellyseerrRouter: React.FC<PropsWithChildren<Props>> = ({
}} }}
shouldDismissMenuOnSelect shouldDismissMenuOnSelect
> >
<ContextMenu.ItemTitle key="item-1-title"> <ContextMenu.ItemTitle key='item-1-title'>
Request Request
</ContextMenu.ItemTitle> </ContextMenu.ItemTitle>
<ContextMenu.ItemIcon <ContextMenu.ItemIcon
@@ -116,7 +117,7 @@ export const TouchableJellyseerrRouter: React.FC<PropsWithChildren<Props>> = ({
light: "purple", light: "purple",
}, },
}} }}
androidIconName="download" androidIconName='download'
/> />
</ContextMenu.Item> </ContextMenu.Item>
)} )}

View File

@@ -4,16 +4,16 @@ import { View } from "react-native";
export const LargePoster: React.FC<{ url?: string | null }> = ({ url }) => { export const LargePoster: React.FC<{ url?: string | null }> = ({ url }) => {
if (!url) if (!url)
return ( return (
<View className="p-4 rounded-xl overflow-hidden "> <View className='p-4 rounded-xl overflow-hidden '>
<View className="w-full aspect-video rounded-xl overflow-hidden border border-neutral-800"></View> <View className='w-full aspect-video rounded-xl overflow-hidden border border-neutral-800'></View>
</View> </View>
); );
return ( return (
<View className="p-4 rounded-xl overflow-hidden "> <View className='p-4 rounded-xl overflow-hidden '>
<Image <Image
source={{ uri: url }} source={{ uri: url }}
className="w-full aspect-video rounded-xl overflow-hidden border border-neutral-800" className='w-full aspect-video rounded-xl overflow-hidden border border-neutral-800'
/> />
</View> </View>
); );

View File

@@ -1,11 +1,11 @@
import React from "react"; import React from "react";
import { Platform, TextProps } from "react-native"; import { Platform, type TextProps } from "react-native";
import { UITextView } from "react-native-uitextview";
import { Text as RNText } from "react-native"; import { Text as RNText } from "react-native";
import { UITextView } from "react-native-uitextview";
export function Text( export function Text(
props: TextProps & { props: TextProps & {
uiTextView?: boolean; uiTextView?: boolean;
} },
) { ) {
const { style, ...otherProps } = props; const { style, ...otherProps } = props;
if (Platform.isTV) if (Platform.isTV)

View File

@@ -1,13 +1,13 @@
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
import { useFavorite } from "@/hooks/useFavorite"; import { useFavorite } from "@/hooks/useFavorite";
import { import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
import { useActionSheet } from "@expo/react-native-action-sheet";
import type {
BaseItemDto, BaseItemDto,
BaseItemPerson, BaseItemPerson,
} from "@jellyfin/sdk/lib/generated-client/models"; } from "@jellyfin/sdk/lib/generated-client/models";
import { useRouter, useSegments } from "expo-router"; import { useRouter, useSegments } from "expo-router";
import { PropsWithChildren, useCallback } from "react"; import { type PropsWithChildren, useCallback } from "react";
import { TouchableOpacity, TouchableOpacityProps } from "react-native"; import { TouchableOpacity, type TouchableOpacityProps } from "react-native";
import { useActionSheet } from "@expo/react-native-action-sheet";
interface Props extends TouchableOpacityProps { interface Props extends TouchableOpacityProps {
item: BaseItemDto; item: BaseItemDto;
@@ -15,7 +15,7 @@ interface Props extends TouchableOpacityProps {
export const itemRouter = ( export const itemRouter = (
item: BaseItemDto | BaseItemPerson, item: BaseItemDto | BaseItemPerson,
from: string from: string,
) => { ) => {
if ("CollectionType" in item && item.CollectionType === "livetv") { if ("CollectionType" in item && item.CollectionType === "livetv") {
return `/(auth)/(tabs)/${from}/livetv`; return `/(auth)/(tabs)/${from}/livetv`;
@@ -58,12 +58,24 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
const { showActionSheetWithOptions } = useActionSheet(); const { showActionSheetWithOptions } = useActionSheet();
const markAsPlayedStatus = useMarkAsPlayed([item]); const markAsPlayedStatus = useMarkAsPlayed([item]);
const { isFavorite, toggleFavorite } = useFavorite(item); const { isFavorite, toggleFavorite } = useFavorite(item);
const from = segments[2]; const from = segments[2];
const showActionSheet = useCallback(() => { const showActionSheet = useCallback(() => {
if (!(item.Type === "Movie" || item.Type === "Episode" || item.Type === "Series")) return; if (
const options = ["Mark as Played", "Mark as Not Played", isFavorite ? "Unmark as Favorite" : "Mark as Favorite", "Cancel"]; !(
item.Type === "Movie" ||
item.Type === "Episode" ||
item.Type === "Series"
)
)
return;
const options = [
"Mark as Played",
"Mark as Not Played",
isFavorite ? "Unmark as Favorite" : "Mark as Favorite",
"Cancel",
];
const cancelButtonIndex = 3; const cancelButtonIndex = 3;
showActionSheetWithOptions( showActionSheetWithOptions(
@@ -77,9 +89,9 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
} else if (selectedIndex === 1) { } else if (selectedIndex === 1) {
await markAsPlayedStatus(false); await markAsPlayedStatus(false);
} else if (selectedIndex === 2) { } else if (selectedIndex === 2) {
toggleFavorite() toggleFavorite();
} }
} },
); );
}, [showActionSheetWithOptions, isFavorite, markAsPlayedStatus]); }, [showActionSheetWithOptions, isFavorite, markAsPlayedStatus]);

View File

@@ -1,5 +1,5 @@
import { View, ViewProps } from "react-native";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { View, type ViewProps } from "react-native";
interface Props extends ViewProps { interface Props extends ViewProps {
index: number; index: number;
@@ -12,18 +12,18 @@ export const VerticalSkeleton: React.FC<Props> = ({ index, ...props }) => {
style={{ style={{
width: "32%", width: "32%",
}} }}
className="flex flex-col" className='flex flex-col'
{...props} {...props}
> >
<View <View
style={{ style={{
aspectRatio: "10/15", aspectRatio: "10/15",
}} }}
className="w-full bg-neutral-800 mb-2 rounded-lg" className='w-full bg-neutral-800 mb-2 rounded-lg'
></View> ></View>
<View className="h-2 bg-neutral-800 rounded-full mb-1"></View> <View className='h-2 bg-neutral-800 rounded-full mb-1'></View>
<View className="h-2 bg-neutral-800 rounded-full mb-1"></View> <View className='h-2 bg-neutral-800 rounded-full mb-1'></View>
<View className="h-2 bg-neutral-800 rounded-full mb-2 w-1/2"></View> <View className='h-2 bg-neutral-800 rounded-full mb-2 w-1/2'></View>
</View> </View>
); );
}; };

View File

@@ -2,7 +2,7 @@ import { Text } from "@/components/common/Text";
import { useDownload } from "@/providers/DownloadProvider"; import { useDownload } from "@/providers/DownloadProvider";
import { DownloadMethod, useSettings } from "@/utils/atoms/settings"; import { DownloadMethod, useSettings } from "@/utils/atoms/settings";
import { storage } from "@/utils/mmkv"; import { storage } from "@/utils/mmkv";
import { JobStatus } from "@/utils/optimize-server"; import type { JobStatus } from "@/utils/optimize-server";
import { formatTimeString } from "@/utils/time"; import { formatTimeString } from "@/utils/time";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
@@ -14,9 +14,9 @@ import {
ActivityIndicator, ActivityIndicator,
Platform, Platform,
TouchableOpacity, TouchableOpacity,
TouchableOpacityProps, type TouchableOpacityProps,
View, View,
ViewProps, type ViewProps,
} from "react-native"; } from "react-native";
import { toast } from "sonner-native"; import { toast } from "sonner-native";
import { Button } from "../Button"; import { Button } from "../Button";
@@ -33,22 +33,22 @@ export const ActiveDownloads: React.FC<Props> = ({ ...props }) => {
const { processes } = useDownload(); const { processes } = useDownload();
if (processes?.length === 0) if (processes?.length === 0)
return ( return (
<View {...props} className="bg-neutral-900 p-4 rounded-2xl"> <View {...props} className='bg-neutral-900 p-4 rounded-2xl'>
<Text className="text-lg font-bold"> <Text className='text-lg font-bold'>
{t("home.downloads.active_download")} {t("home.downloads.active_download")}
</Text> </Text>
<Text className="opacity-50"> <Text className='opacity-50'>
{t("home.downloads.no_active_downloads")} {t("home.downloads.no_active_downloads")}
</Text> </Text>
</View> </View>
); );
return ( return (
<View {...props} className="bg-neutral-900 p-4 rounded-2xl"> <View {...props} className='bg-neutral-900 p-4 rounded-2xl'>
<Text className="text-lg font-bold mb-2"> <Text className='text-lg font-bold mb-2'>
{t("home.downloads.active_downloads")} {t("home.downloads.active_downloads")}
</Text> </Text>
<View className="space-y-2"> <View className='space-y-2'>
{processes?.map((p: JobStatus) => ( {processes?.map((p: JobStatus) => (
<DownloadCard key={p.item.Id} process={p} /> <DownloadCard key={p.item.Id} process={p} />
))} ))}
@@ -89,7 +89,7 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
} else { } else {
FFmpegKitProvider.FFmpegKit.cancel(Number(id)); FFmpegKitProvider.FFmpegKit.cancel(Number(id));
setProcesses((prev: any[]) => setProcesses((prev: any[]) =>
prev.filter((p: { id: string }) => p.id !== id) prev.filter((p: { id: string }) => p.id !== id),
); );
} }
}, },
@@ -117,7 +117,7 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
return ( return (
<TouchableOpacity <TouchableOpacity
onPress={() => router.push(`/(auth)/items/page?id=${process.item.Id}`)} onPress={() => router.push(`/(auth)/items/page?id=${process.item.Id}`)}
className="relative bg-neutral-900 border border-neutral-800 rounded-2xl overflow-hidden" className='relative bg-neutral-900 border border-neutral-800 rounded-2xl overflow-hidden'
{...props} {...props}
> >
{(process.status === "optimizing" || {(process.status === "optimizing" ||
@@ -133,10 +133,10 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
}} }}
></View> ></View>
)} )}
<View className="px-3 py-1.5 flex flex-col w-full"> <View className='px-3 py-1.5 flex flex-col w-full'>
<View className="flex flex-row items-center w-full"> <View className='flex flex-row items-center w-full'>
{base64Image && ( {base64Image && (
<View className="w-14 aspect-[10/15] rounded-lg overflow-hidden mr-4"> <View className='w-14 aspect-[10/15] rounded-lg overflow-hidden mr-4'>
<Image <Image
source={{ source={{
uri: `data:image/jpeg;base64,${base64Image}`, uri: `data:image/jpeg;base64,${base64Image}`,
@@ -149,51 +149,51 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
/> />
</View> </View>
)} )}
<View className="shrink mb-1"> <View className='shrink mb-1'>
<Text className="text-xs opacity-50">{process.item.Type}</Text> <Text className='text-xs opacity-50'>{process.item.Type}</Text>
<Text className="font-semibold shrink">{process.item.Name}</Text> <Text className='font-semibold shrink'>{process.item.Name}</Text>
<Text className="text-xs opacity-50"> <Text className='text-xs opacity-50'>
{process.item.ProductionYear} {process.item.ProductionYear}
</Text> </Text>
<View className="flex flex-row items-center space-x-2 mt-1 text-purple-600"> <View className='flex flex-row items-center space-x-2 mt-1 text-purple-600'>
{process.progress === 0 ? ( {process.progress === 0 ? (
<ActivityIndicator size={"small"} color={"white"} /> <ActivityIndicator size={"small"} color={"white"} />
) : ( ) : (
<Text className="text-xs">{process.progress.toFixed(0)}%</Text> <Text className='text-xs'>{process.progress.toFixed(0)}%</Text>
)} )}
{process.speed && ( {process.speed && (
<Text className="text-xs">{process.speed?.toFixed(2)}x</Text> <Text className='text-xs'>{process.speed?.toFixed(2)}x</Text>
)} )}
{eta(process) && ( {eta(process) && (
<Text className="text-xs"> <Text className='text-xs'>
{t("home.downloads.eta", { eta: eta(process) })} {t("home.downloads.eta", { eta: eta(process) })}
</Text> </Text>
)} )}
</View> </View>
<View className="flex flex-row items-center space-x-2 mt-1 text-purple-600"> <View className='flex flex-row items-center space-x-2 mt-1 text-purple-600'>
<Text className="text-xs capitalize">{process.status}</Text> <Text className='text-xs capitalize'>{process.status}</Text>
</View> </View>
</View> </View>
<TouchableOpacity <TouchableOpacity
disabled={cancelJobMutation.isPending} disabled={cancelJobMutation.isPending}
onPress={() => cancelJobMutation.mutate(process.id)} onPress={() => cancelJobMutation.mutate(process.id)}
className="ml-auto" className='ml-auto'
> >
{cancelJobMutation.isPending ? ( {cancelJobMutation.isPending ? (
<ActivityIndicator size="small" color="white" /> <ActivityIndicator size='small' color='white' />
) : ( ) : (
<Ionicons name="close" size={24} color="red" /> <Ionicons name='close' size={24} color='red' />
)} )}
</TouchableOpacity> </TouchableOpacity>
</View> </View>
{process.status === "completed" && ( {process.status === "completed" && (
<View className="flex flex-row mt-4 space-x-4"> <View className='flex flex-row mt-4 space-x-4'>
<Button <Button
onPress={() => { onPress={() => {
startDownload(process); startDownload(process);
}} }}
className="w-full" className='w-full'
> >
Download now Download now
</Button> </Button>

View File

@@ -1,8 +1,9 @@
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { useDownload } from "@/providers/DownloadProvider"; import { useDownload } from "@/providers/DownloadProvider";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import React, { useEffect, useMemo, useState } from "react"; import type React from "react";
import { TextProps } from "react-native"; import { useEffect, useMemo, useState } from "react";
import type { TextProps } from "react-native";
interface DownloadSizeProps extends TextProps { interface DownloadSizeProps extends TextProps {
items: BaseItemDto[]; items: BaseItemDto[];
@@ -39,7 +40,7 @@ export const DownloadSize: React.FC<DownloadSizeProps> = ({
return ( return (
<> <>
<Text className="text-xs text-neutral-500" {...props}> <Text className='text-xs text-neutral-500' {...props}>
{sizeText} {sizeText}
</Text> </Text>
</> </>

View File

@@ -1,22 +1,27 @@
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useHaptic } from "@/hooks/useHaptic"; import { useHaptic } from "@/hooks/useHaptic";
import React, { useCallback, useMemo } from "react";
import { TouchableOpacity, TouchableOpacityProps, View } from "react-native";
import { import {
ActionSheetProvider, ActionSheetProvider,
useActionSheet, useActionSheet,
} from "@expo/react-native-action-sheet"; } from "@expo/react-native-action-sheet";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import type React from "react";
import { useCallback, useMemo } from "react";
import {
TouchableOpacity,
type TouchableOpacityProps,
View,
} from "react-native";
import { Text } from "@/components/common/Text";
import { DownloadSize } from "@/components/downloads/DownloadSize";
import { useDownloadedFileOpener } from "@/hooks/useDownloadedFileOpener"; import { useDownloadedFileOpener } from "@/hooks/useDownloadedFileOpener";
import { useDownload } from "@/providers/DownloadProvider"; import { useDownload } from "@/providers/DownloadProvider";
import { storage } from "@/utils/mmkv"; import { storage } from "@/utils/mmkv";
import { Image } from "expo-image";
import { Ionicons } from "@expo/vector-icons";
import { Text } from "@/components/common/Text";
import { runtimeTicksToSeconds } from "@/utils/time"; import { runtimeTicksToSeconds } from "@/utils/time";
import { DownloadSize } from "@/components/downloads/DownloadSize"; import { Ionicons } from "@expo/vector-icons";
import { TouchableItemRouter } from "../common/TouchableItemRouter"; import { Image } from "expo-image";
import ContinueWatchingPoster from "../ContinueWatchingPoster"; import ContinueWatchingPoster from "../ContinueWatchingPoster";
import { TouchableItemRouter } from "../common/TouchableItemRouter";
interface EpisodeCardProps extends TouchableOpacityProps { interface EpisodeCardProps extends TouchableOpacityProps {
item: BaseItemDto; item: BaseItemDto;
@@ -67,7 +72,7 @@ export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item, ...props }) => {
// Cancelled // Cancelled
break; break;
} }
} },
); );
}, [showActionSheetWithOptions, handleDeleteFile]); }, [showActionSheetWithOptions, handleDeleteFile]);
@@ -76,27 +81,27 @@ export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item, ...props }) => {
onPress={handleOpenFile} onPress={handleOpenFile}
onLongPress={showActionSheet} onLongPress={showActionSheet}
key={item.Id} key={item.Id}
className="flex flex-col mb-4" className='flex flex-col mb-4'
> >
<View className="flex flex-row items-start mb-2"> <View className='flex flex-row items-start mb-2'>
<View className="mr-2"> <View className='mr-2'>
<ContinueWatchingPoster size="small" item={item} useEpisodePoster /> <ContinueWatchingPoster size='small' item={item} useEpisodePoster />
</View> </View>
<View className="shrink"> <View className='shrink'>
<Text numberOfLines={2} className=""> <Text numberOfLines={2} className=''>
{item.Name} {item.Name}
</Text> </Text>
<Text numberOfLines={1} className="text-xs text-neutral-500"> <Text numberOfLines={1} className='text-xs text-neutral-500'>
{`S${item.ParentIndexNumber?.toString()}:E${item.IndexNumber?.toString()}`} {`S${item.ParentIndexNumber?.toString()}:E${item.IndexNumber?.toString()}`}
</Text> </Text>
<Text className="text-xs text-neutral-500"> <Text className='text-xs text-neutral-500'>
{runtimeTicksToSeconds(item.RunTimeTicks)} {runtimeTicksToSeconds(item.RunTimeTicks)}
</Text> </Text>
<DownloadSize items={[item]} /> <DownloadSize items={[item]} />
</View> </View>
</View> </View>
<Text numberOfLines={3} className="text-xs text-neutral-500 shrink"> <Text numberOfLines={3} className='text-xs text-neutral-500 shrink'>
{item.Overview} {item.Overview}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
@@ -105,7 +110,7 @@ export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item, ...props }) => {
// Wrap the parent component with ActionSheetProvider // Wrap the parent component with ActionSheetProvider
export const EpisodeCardWithActionSheet: React.FC<EpisodeCardProps> = ( export const EpisodeCardWithActionSheet: React.FC<EpisodeCardProps> = (
props props,
) => ( ) => (
<ActionSheetProvider> <ActionSheetProvider>
<EpisodeCard {...props} /> <EpisodeCard {...props} />

View File

@@ -1,10 +1,11 @@
import { useHaptic } from "@/hooks/useHaptic";
import { import {
ActionSheetProvider, ActionSheetProvider,
useActionSheet, useActionSheet,
} from "@expo/react-native-action-sheet"; } from "@expo/react-native-action-sheet";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useHaptic } from "@/hooks/useHaptic"; import type React from "react";
import React, { useCallback, useMemo } from "react"; import { useCallback, useMemo } from "react";
import { TouchableOpacity, View } from "react-native"; import { TouchableOpacity, View } from "react-native";
import { DownloadSize } from "@/components/downloads/DownloadSize"; import { DownloadSize } from "@/components/downloads/DownloadSize";
@@ -69,14 +70,14 @@ export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
// Cancelled // Cancelled
break; break;
} }
} },
); );
}, [showActionSheetWithOptions, handleDeleteFile]); }, [showActionSheetWithOptions, handleDeleteFile]);
return ( return (
<TouchableOpacity onPress={handleOpenFile} onLongPress={showActionSheet}> <TouchableOpacity onPress={handleOpenFile} onLongPress={showActionSheet}>
{base64Image ? ( {base64Image ? (
<View className="w-28 aspect-[10/15] rounded-lg overflow-hidden mr-2 border border-neutral-900"> <View className='w-28 aspect-[10/15] rounded-lg overflow-hidden mr-2 border border-neutral-900'>
<Image <Image
source={{ source={{
uri: `data:image/jpeg;base64,${base64Image}`, uri: `data:image/jpeg;base64,${base64Image}`,
@@ -89,16 +90,16 @@ export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
/> />
</View> </View>
) : ( ) : (
<View className="w-28 aspect-[10/15] rounded-lg bg-neutral-900 mr-2 flex items-center justify-center"> <View className='w-28 aspect-[10/15] rounded-lg bg-neutral-900 mr-2 flex items-center justify-center'>
<Ionicons <Ionicons
name="image-outline" name='image-outline'
size={24} size={24}
color="gray" color='gray'
className="self-center mt-16" className='self-center mt-16'
/> />
</View> </View>
)} )}
<View className="w-28"> <View className='w-28'>
<ItemCardText item={item} /> <ItemCardText item={item} />
</View> </View>
<DownloadSize items={[item]} /> <DownloadSize items={[item]} />

View File

@@ -1,16 +1,17 @@
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { DownloadSize } from "@/components/downloads/DownloadSize";
import {TouchableOpacity, View} from "react-native"; import { useDownload } from "@/providers/DownloadProvider";
import { storage } from "@/utils/mmkv";
import { useActionSheet } from "@expo/react-native-action-sheet";
import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { Image } from "expo-image";
import { router } from "expo-router";
import type React from "react";
import { useCallback, useMemo } from "react";
import { TouchableOpacity, View } from "react-native";
import { Text } from "../common/Text"; import { Text } from "../common/Text";
import React, {useCallback, useMemo} from "react";
import {storage} from "@/utils/mmkv";
import {Image} from "expo-image";
import {Ionicons} from "@expo/vector-icons";
import {router} from "expo-router";
import {DownloadSize} from "@/components/downloads/DownloadSize";
import {useDownload} from "@/providers/DownloadProvider";
import {useActionSheet} from "@expo/react-native-action-sheet";
export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({items}) => { export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({ items }) => {
const { deleteItems } = useDownload(); const { deleteItems } = useDownload();
const { showActionSheetWithOptions } = useActionSheet(); const { showActionSheetWithOptions } = useActionSheet();
@@ -18,16 +19,14 @@ export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({items}) => {
return storage.getString(items[0].SeriesId!); return storage.getString(items[0].SeriesId!);
}, []); }, []);
const deleteSeries = useCallback( const deleteSeries = useCallback(async () => deleteItems(items), [items]);
async () => deleteItems(items),
[items]
);
const showActionSheet = useCallback(() => { const showActionSheet = useCallback(() => {
const options = ["Delete", "Cancel"]; const options = ["Delete", "Cancel"];
const destructiveButtonIndex = 0; const destructiveButtonIndex = 0;
showActionSheetWithOptions({ showActionSheetWithOptions(
{
options, options,
destructiveButtonIndex, destructiveButtonIndex,
}, },
@@ -35,7 +34,7 @@ export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({items}) => {
if (selectedIndex == destructiveButtonIndex) { if (selectedIndex == destructiveButtonIndex) {
deleteSeries(); deleteSeries();
} }
} },
); );
}, [showActionSheetWithOptions, deleteSeries]); }, [showActionSheetWithOptions, deleteSeries]);
@@ -45,7 +44,7 @@ export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({items}) => {
onLongPress={showActionSheet} onLongPress={showActionSheet}
> >
{base64Image ? ( {base64Image ? (
<View className="w-28 aspect-[10/15] rounded-lg overflow-hidden mr-2 border border-neutral-900"> <View className='w-28 aspect-[10/15] rounded-lg overflow-hidden mr-2 border border-neutral-900'>
<Image <Image
source={{ source={{
uri: `data:image/jpeg;base64,${base64Image}`, uri: `data:image/jpeg;base64,${base64Image}`,
@@ -56,25 +55,26 @@ export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({items}) => {
resizeMode: "cover", resizeMode: "cover",
}} }}
/> />
<View <View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center absolute bottom-1 right-1'>
className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center absolute bottom-1 right-1"> <Text className='text-xs font-bold'>{items.length}</Text>
<Text className="text-xs font-bold">{items.length}</Text>
</View> </View>
</View> </View>
) : ( ) : (
<View className="w-28 aspect-[10/15] rounded-lg bg-neutral-900 mr-2 flex items-center justify-center"> <View className='w-28 aspect-[10/15] rounded-lg bg-neutral-900 mr-2 flex items-center justify-center'>
<Ionicons <Ionicons
name="image-outline" name='image-outline'
size={24} size={24}
color="gray" color='gray'
className="self-center mt-16" className='self-center mt-16'
/> />
</View> </View>
)} )}
<View className="w-28 mt-2 flex flex-col"> <View className='w-28 mt-2 flex flex-col'>
<Text numberOfLines={2} className="">{items[0].SeriesName}</Text> <Text numberOfLines={2} className=''>
<Text className="text-xs opacity-50">{items[0].ProductionYear}</Text> {items[0].SeriesName}
</Text>
<Text className='text-xs opacity-50'>{items[0].ProductionYear}</Text>
<DownloadSize items={items} /> <DownloadSize items={items} />
</View> </View>
</TouchableOpacity> </TouchableOpacity>

View File

@@ -2,7 +2,7 @@ import { Text } from "@/components/common/Text";
import { FontAwesome, Ionicons } from "@expo/vector-icons"; import { FontAwesome, Ionicons } from "@expo/vector-icons";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useState } from "react"; import { useState } from "react";
import { TouchableOpacity, View, ViewProps } from "react-native"; import { TouchableOpacity, View, type ViewProps } from "react-native";
import { FilterSheet } from "./FilterSheet"; import { FilterSheet } from "./FilterSheet";
interface FilterButtonProps<T> extends ViewProps { interface FilterButtonProps<T> extends ViewProps {
@@ -68,16 +68,16 @@ export const FilterButton = <T,>({
</Text> </Text>
{icon === "filter" ? ( {icon === "filter" ? (
<Ionicons <Ionicons
name="filter" name='filter'
size={14} size={14}
color="white" color='white'
style={{ opacity: 0.5 }} style={{ opacity: 0.5 }}
/> />
) : ( ) : (
<FontAwesome <FontAwesome
name="sort" name='sort'
size={14} size={14}
color="white" color='white'
style={{ opacity: 0.5 }} style={{ opacity: 0.5 }}
/> />
)} )}

View File

@@ -1,25 +1,25 @@
import { import {
BottomSheetBackdrop, BottomSheetBackdrop,
BottomSheetBackdropProps, type BottomSheetBackdropProps,
BottomSheetFlatList, BottomSheetFlatList,
BottomSheetModal, BottomSheetModal,
BottomSheetScrollView, BottomSheetScrollView,
BottomSheetView, BottomSheetView,
} from "@gorhom/bottom-sheet"; } from "@gorhom/bottom-sheet";
import React, { import type React from "react";
useCallback, import { useCallback, useEffect, useMemo, useRef, useState } from "react";
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { StyleSheet, TouchableOpacity, View, ViewProps } from "react-native";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { useTranslation } from "react-i18next";
import {
StyleSheet,
TouchableOpacity,
View,
type ViewProps,
} from "react-native";
import { Button } from "../Button"; import { Button } from "../Button";
import { Input } from "../common/Input"; import { Input } from "../common/Input";
import { useTranslation } from "react-i18next";
interface Props<T> extends ViewProps { interface Props<T> extends ViewProps {
open: boolean; open: boolean;
@@ -130,7 +130,7 @@ export const FilterSheet = <T,>({
appearsOnIndex={0} appearsOnIndex={0}
/> />
), ),
[] [],
); );
return ( return (
@@ -153,18 +153,20 @@ export const FilterSheet = <T,>({
flex: 1, flex: 1,
}} }}
> >
<View className="px-4 mt-2 mb-8"> <View className='px-4 mt-2 mb-8'>
<Text className="font-bold text-2xl">{title}</Text> <Text className='font-bold text-2xl'>{title}</Text>
<Text className="mb-2 text-neutral-500">{t("search.x_items", {count: _data?.length})}</Text> <Text className='mb-2 text-neutral-500'>
{t("search.x_items", { count: _data?.length })}
</Text>
{showSearch && ( {showSearch && (
<Input <Input
placeholder={t("search.search")} placeholder={t("search.search")}
className="my-2" className='my-2'
value={search} value={search}
onChangeText={(text) => { onChangeText={(text) => {
setSearch(text); setSearch(text);
}} }}
returnKeyType="done" returnKeyType='done'
/> />
)} )}
<View <View
@@ -172,7 +174,7 @@ export const FilterSheet = <T,>({
borderRadius: 20, borderRadius: 20,
overflow: "hidden", overflow: "hidden",
}} }}
className="mb-4 flex flex-col rounded-xl overflow-hidden" className='mb-4 flex flex-col rounded-xl overflow-hidden'
> >
{renderData?.map((item, index) => ( {renderData?.map((item, index) => (
<View key={index}> <View key={index}>
@@ -185,20 +187,20 @@ export const FilterSheet = <T,>({
}, 250); }, 250);
} }
}} }}
className=" bg-neutral-800 px-4 py-3 flex flex-row items-center justify-between" className=' bg-neutral-800 px-4 py-3 flex flex-row items-center justify-between'
> >
<Text>{renderItemLabel(item)}</Text> <Text>{renderItemLabel(item)}</Text>
{values.some((i) => i === item) ? ( {values.some((i) => i === item) ? (
<Ionicons name="radio-button-on" size={24} color="white" /> <Ionicons name='radio-button-on' size={24} color='white' />
) : ( ) : (
<Ionicons name="radio-button-off" size={24} color="white" /> <Ionicons name='radio-button-off' size={24} color='white' />
)} )}
</TouchableOpacity> </TouchableOpacity>
<View <View
style={{ style={{
height: StyleSheet.hairlineWidth, height: StyleSheet.hairlineWidth,
}} }}
className="h-1 divide-neutral-700 " className='h-1 divide-neutral-700 '
></View> ></View>
</View> </View>
))} ))}

Some files were not shown because too many files have changed in this diff Show More