forked from Ninjalama/streamyfin_mirror
Compare commits
5 Commits
fix/search
...
feature/li
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89e02f315a | ||
|
|
87880f1989 | ||
|
|
192bc9d05a | ||
|
|
9df7ffccc5 | ||
|
|
3fa2747593 |
1
.husky/pre-commit
Normal file
1
.husky/pre-commit
Normal file
@@ -0,0 +1 @@
|
||||
lint-staged
|
||||
44
.vscode/settings.json
vendored
44
.vscode/settings.json
vendored
@@ -1,24 +1,24 @@
|
||||
{
|
||||
"[javascript]": {
|
||||
"editor.defaultFormatter": "biomejs.biome",
|
||||
"editor.formatOnSave": true
|
||||
},
|
||||
"[typescriptreact]": {
|
||||
"editor.defaultFormatter": "biomejs.biome",
|
||||
"editor.formatOnSave": true
|
||||
},
|
||||
"prettier.printWidth": 120,
|
||||
"[swift]": {
|
||||
"editor.defaultFormatter": "sswg.swift-lang"
|
||||
},
|
||||
"editor.formatOnSave": true,
|
||||
"editor.defaultFormatter": "biomejs.biome",
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "biomejs.biome",
|
||||
"editor.formatOnSave": true
|
||||
},
|
||||
"[javascriptreact]": {
|
||||
"editor.defaultFormatter": "biomejs.biome",
|
||||
"editor.formatOnSave": true
|
||||
}
|
||||
"[javascript]": {
|
||||
"editor.defaultFormatter": "biomejs.biome",
|
||||
"editor.formatOnSave": true
|
||||
},
|
||||
"[typescriptreact]": {
|
||||
"editor.defaultFormatter": "biomejs.biome",
|
||||
"editor.formatOnSave": true
|
||||
},
|
||||
"prettier.printWidth": 120,
|
||||
"[swift]": {
|
||||
"editor.defaultFormatter": "sswg.swift-lang"
|
||||
},
|
||||
"editor.formatOnSave": true,
|
||||
"editor.defaultFormatter": "biomejs.biome",
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "biomejs.biome",
|
||||
"editor.formatOnSave": true
|
||||
},
|
||||
"[javascriptreact]": {
|
||||
"editor.defaultFormatter": "biomejs.biome",
|
||||
"editor.formatOnSave": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import {Stack} from "expo-router";
|
||||
import { Platform } from "react-native";
|
||||
import { Stack } from "expo-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform } from "react-native";
|
||||
|
||||
export default function CustomMenuLayout() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Stack>
|
||||
<Stack.Screen
|
||||
name="index"
|
||||
name='index'
|
||||
options={{
|
||||
headerShown: true,
|
||||
headerLargeTitle: true,
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { ListItem } from "@/components/list/ListItem";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import Ionicons from "@expo/vector-icons/Ionicons";
|
||||
import { useAtom } from "jotai/index";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform } from "react-native";
|
||||
import { FlatList, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { useAtom } from "jotai/index";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { ListItem } from "@/components/list/ListItem";
|
||||
import Ionicons from "@expo/vector-icons/Ionicons";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const WebBrowser = !Platform.isTV ? require("expo-web-browser") : null;
|
||||
|
||||
@@ -26,7 +26,7 @@ export default function menuLinks() {
|
||||
const getMenuLinks = useCallback(async () => {
|
||||
try {
|
||||
const response = await api?.axiosInstance.get(
|
||||
api?.basePath + "/web/config.json"
|
||||
api?.basePath + "/web/config.json",
|
||||
);
|
||||
const config = response?.data;
|
||||
|
||||
@@ -46,7 +46,7 @@ export default function menuLinks() {
|
||||
}, []);
|
||||
return (
|
||||
<FlatList
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
contentContainerStyle={{
|
||||
paddingTop: 10,
|
||||
paddingLeft: insets.left,
|
||||
@@ -63,7 +63,7 @@ export default function menuLinks() {
|
||||
>
|
||||
<ListItem
|
||||
title={item.name}
|
||||
iconAfter={<Ionicons name="link" size={24} color="white" />}
|
||||
iconAfter={<Ionicons name='link' size={24} color='white' />}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
@@ -76,8 +76,10 @@ export default function menuLinks() {
|
||||
/>
|
||||
)}
|
||||
ListEmptyComponent={
|
||||
<View className="flex flex-col items-center justify-center h-full">
|
||||
<Text className="font-bold text-xl text-neutral-500">{t("custom_links.no_links")}</Text>
|
||||
<View className='flex flex-col items-center justify-center h-full'>
|
||||
<Text className='font-bold text-xl text-neutral-500'>
|
||||
{t("custom_links.no_links")}
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
||||
import { Stack } from "expo-router";
|
||||
import { Platform } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform } from "react-native";
|
||||
|
||||
export default function SearchLayout() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Stack>
|
||||
<Stack.Screen
|
||||
name="index"
|
||||
name='index'
|
||||
options={{
|
||||
headerShown: true,
|
||||
headerLargeTitle: true,
|
||||
|
||||
@@ -18,7 +18,7 @@ export default function favorites() {
|
||||
return (
|
||||
<ScrollView
|
||||
nestedScrollEnabled
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={loading} onRefresh={refetch} />
|
||||
}
|
||||
@@ -28,7 +28,7 @@ export default function favorites() {
|
||||
paddingBottom: 16,
|
||||
}}
|
||||
>
|
||||
<View className="my-4">
|
||||
<View className='my-4'>
|
||||
<Favorites />
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
||||
import { Ionicons, Feather } from "@expo/vector-icons";
|
||||
import { Feather, Ionicons } from "@expo/vector-icons";
|
||||
import { Stack, useRouter } from "expo-router";
|
||||
import { Platform, TouchableOpacity, View } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, TouchableOpacity, View } from "react-native";
|
||||
const Chromecast = Platform.isTV ? null : require("@/components/Chromecast");
|
||||
import { useAtom } from "jotai";
|
||||
import { useSessions, type useSessionsProps } from "@/hooks/useSessions";
|
||||
import { userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSessions, useSessionsProps } from "@/hooks/useSessions";
|
||||
import { useAtom } from "jotai";
|
||||
|
||||
export default function IndexLayout() {
|
||||
const router = useRouter();
|
||||
@@ -16,7 +16,7 @@ export default function IndexLayout() {
|
||||
return (
|
||||
<Stack>
|
||||
<Stack.Screen
|
||||
name="index"
|
||||
name='index'
|
||||
options={{
|
||||
headerShown: true,
|
||||
headerLargeTitle: true,
|
||||
@@ -28,13 +28,11 @@ export default function IndexLayout() {
|
||||
headerTransparent: Platform.OS === "ios",
|
||||
headerShadowVisible: false,
|
||||
headerRight: () => (
|
||||
<View className="flex flex-row items-center space-x-2">
|
||||
<View className='flex flex-row items-center space-x-2'>
|
||||
{!Platform.isTV && (
|
||||
<>
|
||||
<Chromecast.Chromecast />
|
||||
{user && user.Policy?.IsAdministrator && (
|
||||
<SessionsButton />
|
||||
)}
|
||||
{user && user.Policy?.IsAdministrator && <SessionsButton />}
|
||||
<SettingsButton />
|
||||
</>
|
||||
)}
|
||||
@@ -43,61 +41,61 @@ export default function IndexLayout() {
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="downloads/index"
|
||||
name='downloads/index'
|
||||
options={{
|
||||
title: t("home.downloads.downloads_title"),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="downloads/[seriesId]"
|
||||
name='downloads/[seriesId]'
|
||||
options={{
|
||||
title: t("home.downloads.tvseries"),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="sessions/index"
|
||||
name='sessions/index'
|
||||
options={{
|
||||
title: t("home.sessions.title"),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="settings"
|
||||
name='settings'
|
||||
options={{
|
||||
title: t("home.settings.settings_title"),
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="settings/optimized-server/page"
|
||||
name='settings/optimized-server/page'
|
||||
options={{
|
||||
title: "",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="settings/marlin-search/page"
|
||||
name='settings/marlin-search/page'
|
||||
options={{
|
||||
title: "",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="settings/jellyseerr/page"
|
||||
name='settings/jellyseerr/page'
|
||||
options={{
|
||||
title: "",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="settings/hide-libraries/page"
|
||||
name='settings/hide-libraries/page'
|
||||
options={{
|
||||
title: "",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="settings/logs/page"
|
||||
name='settings/logs/page'
|
||||
options={{
|
||||
title: "",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="intro/page"
|
||||
name='intro/page'
|
||||
options={{
|
||||
headerShown: false,
|
||||
title: "",
|
||||
@@ -108,7 +106,7 @@ export default function IndexLayout() {
|
||||
<Stack.Screen key={name} name={name} options={options} />
|
||||
))}
|
||||
<Stack.Screen
|
||||
name="collections/[collectionId]"
|
||||
name='collections/[collectionId]'
|
||||
options={{
|
||||
title: "",
|
||||
headerShown: true,
|
||||
@@ -130,7 +128,7 @@ const SettingsButton = () => {
|
||||
router.push("/(auth)/settings");
|
||||
}}
|
||||
>
|
||||
<Feather name="settings" color={"white"} size={22} />
|
||||
<Feather name='settings' color={"white"} size={22} />
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
@@ -145,9 +143,9 @@ const SessionsButton = () => {
|
||||
router.push("/(auth)/sessions");
|
||||
}}
|
||||
>
|
||||
<View className="mr-4">
|
||||
<View className='mr-4'>
|
||||
<Ionicons
|
||||
name="play-circle"
|
||||
name='play-circle'
|
||||
color={sessions.length === 0 ? "white" : "#9333ea"}
|
||||
size={25}
|
||||
/>
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { router, useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { ScrollView, TouchableOpacity, View, Alert } from "react-native";
|
||||
import { EpisodeCard } from "@/components/downloads/EpisodeCard";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import {
|
||||
SeasonDropdown,
|
||||
SeasonIndexState,
|
||||
type SeasonIndexState,
|
||||
} from "@/components/series/SeasonDropdown";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { router, useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Alert, ScrollView, TouchableOpacity, View } from "react-native";
|
||||
|
||||
export default function page() {
|
||||
const navigation = useNavigation();
|
||||
@@ -21,7 +21,7 @@ export default function page() {
|
||||
};
|
||||
|
||||
const [seasonIndexState, setSeasonIndexState] = useState<SeasonIndexState>(
|
||||
{}
|
||||
{},
|
||||
);
|
||||
const { downloadedFiles, deleteItems } = useDownload();
|
||||
|
||||
@@ -31,7 +31,7 @@ export default function page() {
|
||||
downloadedFiles
|
||||
?.filter((f) => f.item.SeriesId == seriesId)
|
||||
?.sort(
|
||||
(a, b) => a?.item.ParentIndexNumber! - b.item.ParentIndexNumber!
|
||||
(a, b) => a?.item.ParentIndexNumber! - b.item.ParentIndexNumber!,
|
||||
) || []
|
||||
);
|
||||
} catch {
|
||||
@@ -64,7 +64,7 @@ export default function page() {
|
||||
() =>
|
||||
Object.values(groupBySeason)?.[0]?.ParentIndexNumber ??
|
||||
series?.[0]?.item?.ParentIndexNumber,
|
||||
[groupBySeason]
|
||||
[groupBySeason],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -92,14 +92,14 @@ export default function page() {
|
||||
onPress: () => deleteItems(groupBySeason),
|
||||
style: "destructive",
|
||||
},
|
||||
]
|
||||
],
|
||||
);
|
||||
}, [groupBySeason]);
|
||||
|
||||
return (
|
||||
<View className="flex-1">
|
||||
<View className='flex-1'>
|
||||
{series.length > 0 && (
|
||||
<View className="flex flex-row items-center justify-start my-2 px-4">
|
||||
<View className='flex flex-row items-center justify-start my-2 px-4'>
|
||||
<SeasonDropdown
|
||||
item={series[0].item}
|
||||
seasons={series.map((s) => s.item)}
|
||||
@@ -112,17 +112,17 @@ export default function page() {
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center ml-2">
|
||||
<Text className="text-xs font-bold">{groupBySeason.length}</Text>
|
||||
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center ml-2'>
|
||||
<Text className='text-xs font-bold'>{groupBySeason.length}</Text>
|
||||
</View>
|
||||
<View className="bg-neutral-800/80 rounded-full h-9 w-9 flex items-center justify-center ml-auto">
|
||||
<View className='bg-neutral-800/80 rounded-full h-9 w-9 flex items-center justify-center ml-auto'>
|
||||
<TouchableOpacity onPress={deleteSeries}>
|
||||
<Ionicons name="trash" size={20} color="white" />
|
||||
<Ionicons name='trash' size={20} color='white' />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
<ScrollView key={seasonIndex} className="px-4">
|
||||
<ScrollView key={seasonIndex} className='px-4'>
|
||||
{groupBySeason.map((episode, index) => (
|
||||
<EpisodeCard key={index} item={episode} />
|
||||
))}
|
||||
|
||||
@@ -1,28 +1,28 @@
|
||||
import { Button } from "@/components/Button";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { ActiveDownloads } from "@/components/downloads/ActiveDownloads";
|
||||
import { DownloadSize } from "@/components/downloads/DownloadSize";
|
||||
import { MovieCard } from "@/components/downloads/MovieCard";
|
||||
import { SeriesCard } from "@/components/downloads/SeriesCard";
|
||||
import { DownloadedItem, useDownload } from "@/providers/DownloadProvider";
|
||||
import { type DownloadedItem, useDownload } from "@/providers/DownloadProvider";
|
||||
import { queueAtom } from "@/utils/atoms/queue";
|
||||
import {DownloadMethod, useSettings} from "@/utils/atoms/settings";
|
||||
import { DownloadMethod, useSettings } from "@/utils/atoms/settings";
|
||||
import { writeToLog } from "@/utils/log";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useNavigation, useRouter } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useEffect, useMemo, useRef } from "react";
|
||||
import { Alert, ScrollView, TouchableOpacity, View } from "react-native";
|
||||
import { Button } from "@/components/Button";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { t } from 'i18next';
|
||||
import { DownloadSize } from "@/components/downloads/DownloadSize";
|
||||
import {
|
||||
BottomSheetBackdrop,
|
||||
BottomSheetBackdropProps,
|
||||
type BottomSheetBackdropProps,
|
||||
BottomSheetModal,
|
||||
BottomSheetView,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import { useNavigation, useRouter } from "expo-router";
|
||||
import { t } from "i18next";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useEffect, useMemo, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Alert, ScrollView, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { toast } from "sonner-native";
|
||||
import { writeToLog } from "@/utils/log";
|
||||
|
||||
export default function page() {
|
||||
const navigation = useNavigation();
|
||||
@@ -45,7 +45,7 @@ export default function page() {
|
||||
const groupedBySeries = useMemo(() => {
|
||||
try {
|
||||
const episodes = downloadedFiles?.filter(
|
||||
(f) => f.item.Type === "Episode"
|
||||
(f) => f.item.Type === "Episode",
|
||||
);
|
||||
const series: { [key: string]: DownloadedItem[] } = {};
|
||||
episodes?.forEach((e) => {
|
||||
@@ -73,14 +73,22 @@ export default function page() {
|
||||
|
||||
const deleteMovies = () =>
|
||||
deleteFileByType("Movie")
|
||||
.then(() => toast.success(t("home.downloads.toasts.deleted_all_movies_successfully")))
|
||||
.then(() =>
|
||||
toast.success(
|
||||
t("home.downloads.toasts.deleted_all_movies_successfully"),
|
||||
),
|
||||
)
|
||||
.catch((reason) => {
|
||||
writeToLog("ERROR", reason);
|
||||
toast.error(t("home.downloads.toasts.failed_to_delete_all_movies"));
|
||||
});
|
||||
const deleteShows = () =>
|
||||
deleteFileByType("Episode")
|
||||
.then(() => toast.success(t("home.downloads.toasts.deleted_all_tvseries_successfully")))
|
||||
.then(() =>
|
||||
toast.success(
|
||||
t("home.downloads.toasts.deleted_all_tvseries_successfully"),
|
||||
),
|
||||
)
|
||||
.catch((reason) => {
|
||||
writeToLog("ERROR", reason);
|
||||
toast.error(t("home.downloads.toasts.failed_to_delete_all_tvseries"));
|
||||
@@ -97,26 +105,28 @@ export default function page() {
|
||||
paddingBottom: 100,
|
||||
}}
|
||||
>
|
||||
<View className="py-4">
|
||||
<View className="mb-4 flex flex-col space-y-4 px-4">
|
||||
<View className='py-4'>
|
||||
<View className='mb-4 flex flex-col space-y-4 px-4'>
|
||||
{settings?.downloadMethod === DownloadMethod.Remux && (
|
||||
<View className="bg-neutral-900 p-4 rounded-2xl">
|
||||
<Text className="text-lg font-bold">{t("home.downloads.queue")}</Text>
|
||||
<Text className="text-xs opacity-70 text-red-600">
|
||||
<View className='bg-neutral-900 p-4 rounded-2xl'>
|
||||
<Text className='text-lg font-bold'>
|
||||
{t("home.downloads.queue")}
|
||||
</Text>
|
||||
<Text className='text-xs opacity-70 text-red-600'>
|
||||
{t("home.downloads.queue_hint")}
|
||||
</Text>
|
||||
<View className="flex flex-col space-y-2 mt-2">
|
||||
<View className='flex flex-col space-y-2 mt-2'>
|
||||
{queue.map((q, index) => (
|
||||
<TouchableOpacity
|
||||
onPress={() =>
|
||||
router.push(`/(auth)/items/page?id=${q.item.Id}`)
|
||||
}
|
||||
className="relative bg-neutral-900 border border-neutral-800 p-4 rounded-2xl overflow-hidden flex flex-row items-center justify-between"
|
||||
className='relative bg-neutral-900 border border-neutral-800 p-4 rounded-2xl overflow-hidden flex flex-row items-center justify-between'
|
||||
key={index}
|
||||
>
|
||||
<View>
|
||||
<Text className="font-semibold">{q.item.Name}</Text>
|
||||
<Text className="text-xs opacity-50">
|
||||
<Text className='font-semibold'>{q.item.Name}</Text>
|
||||
<Text className='text-xs opacity-50'>
|
||||
{q.item.Type}
|
||||
</Text>
|
||||
</View>
|
||||
@@ -129,14 +139,16 @@ export default function page() {
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Ionicons name="close" size={24} color="red" />
|
||||
<Ionicons name='close' size={24} color='red' />
|
||||
</TouchableOpacity>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{queue.length === 0 && (
|
||||
<Text className="opacity-50">{t("home.downloads.no_items_in_queue")}</Text>
|
||||
<Text className='opacity-50'>
|
||||
{t("home.downloads.no_items_in_queue")}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
@@ -145,17 +157,19 @@ export default function page() {
|
||||
</View>
|
||||
|
||||
{movies.length > 0 && (
|
||||
<View className="mb-4">
|
||||
<View className="flex flex-row items-center justify-between mb-2 px-4">
|
||||
<Text className="text-lg font-bold">{t("home.downloads.movies")}</Text>
|
||||
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center">
|
||||
<Text className="text-xs font-bold">{movies?.length}</Text>
|
||||
<View className='mb-4'>
|
||||
<View className='flex flex-row items-center justify-between mb-2 px-4'>
|
||||
<Text className='text-lg font-bold'>
|
||||
{t("home.downloads.movies")}
|
||||
</Text>
|
||||
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
|
||||
<Text className='text-xs font-bold'>{movies?.length}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||
<View className="px-4 flex flex-row">
|
||||
<View className='px-4 flex flex-row'>
|
||||
{movies?.map((item) => (
|
||||
<View className="mb-2 last:mb-0" key={item.item.Id}>
|
||||
<View className='mb-2 last:mb-0' key={item.item.Id}>
|
||||
<MovieCard item={item.item} />
|
||||
</View>
|
||||
))}
|
||||
@@ -164,20 +178,22 @@ export default function page() {
|
||||
</View>
|
||||
)}
|
||||
{groupedBySeries.length > 0 && (
|
||||
<View className="mb-4">
|
||||
<View className="flex flex-row items-center justify-between mb-2 px-4">
|
||||
<Text className="text-lg font-bold">{t("home.downloads.tvseries")}</Text>
|
||||
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center">
|
||||
<Text className="text-xs font-bold">
|
||||
<View className='mb-4'>
|
||||
<View className='flex flex-row items-center justify-between mb-2 px-4'>
|
||||
<Text className='text-lg font-bold'>
|
||||
{t("home.downloads.tvseries")}
|
||||
</Text>
|
||||
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
|
||||
<Text className='text-xs font-bold'>
|
||||
{groupedBySeries?.length}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||
<View className="px-4 flex flex-row">
|
||||
<View className='px-4 flex flex-row'>
|
||||
{groupedBySeries?.map((items) => (
|
||||
<View
|
||||
className="mb-2 last:mb-0"
|
||||
className='mb-2 last:mb-0'
|
||||
key={items[0].item.SeriesId}
|
||||
>
|
||||
<SeriesCard
|
||||
@@ -191,8 +207,10 @@ export default function page() {
|
||||
</View>
|
||||
)}
|
||||
{downloadedFiles?.length === 0 && (
|
||||
<View className="flex px-4">
|
||||
<Text className="opacity-50">{t("home.downloads.no_downloaded_items")}</Text>
|
||||
<View className='flex px-4'>
|
||||
<Text className='opacity-50'>
|
||||
{t("home.downloads.no_downloaded_items")}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
@@ -215,14 +233,14 @@ export default function page() {
|
||||
)}
|
||||
>
|
||||
<BottomSheetView>
|
||||
<View className="p-4 space-y-4 mb-4">
|
||||
<Button color="purple" onPress={deleteMovies}>
|
||||
<View className='p-4 space-y-4 mb-4'>
|
||||
<Button color='purple' onPress={deleteMovies}>
|
||||
{t("home.downloads.delete_all_movies_button")}
|
||||
</Button>
|
||||
<Button color="purple" onPress={deleteShows}>
|
||||
<Button color='purple' onPress={deleteShows}>
|
||||
{t("home.downloads.delete_all_tvseries_button")}
|
||||
</Button>
|
||||
<Button color="red" onPress={deleteAllMedia}>
|
||||
<Button color='red' onPress={deleteAllMedia}>
|
||||
{t("home.downloads.delete_all_button")}
|
||||
</Button>
|
||||
</View>
|
||||
@@ -248,6 +266,6 @@ function migration_20241124() {
|
||||
style: "destructive",
|
||||
onPress: async () => await deleteAllFiles(),
|
||||
},
|
||||
]
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,26 +15,26 @@ export default function page() {
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
storage.set("hasShownIntro", true);
|
||||
}, [])
|
||||
}, []),
|
||||
);
|
||||
|
||||
return (
|
||||
<View className="bg-neutral-900 h-full py-16 px-4 space-y-8">
|
||||
<View className='bg-neutral-900 h-full py-16 px-4 space-y-8'>
|
||||
<View>
|
||||
<Text className="text-3xl font-bold text-center mb-2">
|
||||
<Text className='text-3xl font-bold text-center mb-2'>
|
||||
{t("home.intro.welcome_to_streamyfin")}
|
||||
</Text>
|
||||
<Text className="text-center">
|
||||
<Text className='text-center'>
|
||||
{t("home.intro.a_free_and_open_source_client_for_jellyfin")}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View>
|
||||
<Text className="text-lg font-bold">
|
||||
<Text className='text-lg font-bold'>
|
||||
{t("home.intro.features_title")}
|
||||
</Text>
|
||||
<Text className="text-xs">{t("home.intro.features_description")}</Text>
|
||||
<View className="flex flex-row items-center mt-4">
|
||||
<Text className='text-xs'>{t("home.intro.features_description")}</Text>
|
||||
<View className='flex flex-row items-center mt-4'>
|
||||
<Image
|
||||
source={require("@/assets/icons/jellyseerr-logo.svg")}
|
||||
style={{
|
||||
@@ -42,70 +42,70 @@ export default function page() {
|
||||
height: 50,
|
||||
}}
|
||||
/>
|
||||
<View className="shrink ml-2">
|
||||
<Text className="font-bold mb-1">Jellyseerr</Text>
|
||||
<Text className="shrink text-xs">
|
||||
<View className='shrink ml-2'>
|
||||
<Text className='font-bold mb-1'>Jellyseerr</Text>
|
||||
<Text className='shrink text-xs'>
|
||||
{t("home.intro.jellyseerr_feature_description")}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View className="flex flex-row items-center mt-4">
|
||||
<View className='flex flex-row items-center mt-4'>
|
||||
<View
|
||||
style={{
|
||||
width: 50,
|
||||
height: 50,
|
||||
}}
|
||||
className="flex items-center justify-center"
|
||||
className='flex items-center justify-center'
|
||||
>
|
||||
<Ionicons name="cloud-download-outline" size={32} color="white" />
|
||||
<Ionicons name='cloud-download-outline' size={32} color='white' />
|
||||
</View>
|
||||
<View className="shrink ml-2">
|
||||
<Text className="font-bold mb-1">
|
||||
<View className='shrink ml-2'>
|
||||
<Text className='font-bold mb-1'>
|
||||
{t("home.intro.downloads_feature_title")}
|
||||
</Text>
|
||||
<Text className="shrink text-xs">
|
||||
<Text className='shrink text-xs'>
|
||||
{t("home.intro.downloads_feature_description")}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View className="flex flex-row items-center mt-4">
|
||||
<View className='flex flex-row items-center mt-4'>
|
||||
<View
|
||||
style={{
|
||||
width: 50,
|
||||
height: 50,
|
||||
}}
|
||||
className="flex items-center justify-center"
|
||||
className='flex items-center justify-center'
|
||||
>
|
||||
<Feather name="cast" size={28} color={"white"} />
|
||||
<Feather name='cast' size={28} color={"white"} />
|
||||
</View>
|
||||
<View className="shrink ml-2">
|
||||
<Text className="font-bold mb-1">Chromecast</Text>
|
||||
<Text className="shrink text-xs">
|
||||
<View className='shrink ml-2'>
|
||||
<Text className='font-bold mb-1'>Chromecast</Text>
|
||||
<Text className='shrink text-xs'>
|
||||
{t("home.intro.chromecast_feature_description")}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View className="flex flex-row items-center mt-4">
|
||||
<View className='flex flex-row items-center mt-4'>
|
||||
<View
|
||||
style={{
|
||||
width: 50,
|
||||
height: 50,
|
||||
}}
|
||||
className="flex items-center justify-center"
|
||||
className='flex items-center justify-center'
|
||||
>
|
||||
<Feather name="settings" size={28} color={"white"} />
|
||||
<Feather name='settings' size={28} color={"white"} />
|
||||
</View>
|
||||
<View className="shrink ml-2">
|
||||
<Text className="font-bold mb-1">
|
||||
<View className='shrink ml-2'>
|
||||
<Text className='font-bold mb-1'>
|
||||
{t("home.intro.centralised_settings_plugin_title")}
|
||||
</Text>
|
||||
<Text className="shrink text-xs">
|
||||
<Text className='shrink text-xs'>
|
||||
{t("home.intro.centralised_settings_plugin_description")}{" "}
|
||||
<Text
|
||||
className="text-purple-600"
|
||||
className='text-purple-600'
|
||||
onPress={() => {
|
||||
Linking.openURL(
|
||||
"https://github.com/streamyfin/jellyfin-plugin-streamyfin"
|
||||
"https://github.com/streamyfin/jellyfin-plugin-streamyfin",
|
||||
);
|
||||
}}
|
||||
>
|
||||
@@ -120,7 +120,7 @@ export default function page() {
|
||||
onPress={() => {
|
||||
router.back();
|
||||
}}
|
||||
className="mt-4"
|
||||
className='mt-4'
|
||||
>
|
||||
{t("home.intro.done_button")}
|
||||
</Button>
|
||||
@@ -129,9 +129,9 @@ export default function page() {
|
||||
router.back();
|
||||
router.push("/settings");
|
||||
}}
|
||||
className="mt-4"
|
||||
className='mt-4'
|
||||
>
|
||||
<Text className="text-purple-600 text-center">
|
||||
<Text className='text-purple-600 text-center'>
|
||||
{t("home.intro.go_to_settings_button")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
@@ -1,21 +1,29 @@
|
||||
import { Badge } from "@/components/Badge";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useSessions, useSessionsProps } from "@/hooks/useSessions";
|
||||
import Poster from "@/components/posters/Poster";
|
||||
import { useInterval } from "@/hooks/useInterval";
|
||||
import { useSessions, type useSessionsProps } from "@/hooks/useSessions";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { formatBitrate } from "@/utils/bitrate";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
import { formatTimeString } from "@/utils/time";
|
||||
import {
|
||||
AntDesign,
|
||||
Entypo,
|
||||
Ionicons,
|
||||
MaterialCommunityIcons,
|
||||
} from "@expo/vector-icons";
|
||||
import {
|
||||
HardwareAccelerationType,
|
||||
type SessionInfoDto,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAtomValue } from "jotai";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View } from "react-native";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { HardwareAccelerationType, SessionInfoDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import Poster from "@/components/posters/Poster";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
import { useInterval } from "@/hooks/useInterval";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { formatTimeString } from "@/utils/time";
|
||||
import { formatBitrate } from "@/utils/bitrate";
|
||||
import { Ionicons, Entypo, AntDesign, MaterialCommunityIcons } from "@expo/vector-icons";
|
||||
import { Badge } from "@/components/Badge";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
export default function page() {
|
||||
const { sessions, isLoading } = useSessions({} as useSessionsProps);
|
||||
@@ -23,21 +31,23 @@ export default function page() {
|
||||
|
||||
if (isLoading)
|
||||
return (
|
||||
<View className="justify-center items-center h-full">
|
||||
<View className='justify-center items-center h-full'>
|
||||
<Loader />
|
||||
</View>
|
||||
);
|
||||
|
||||
if (!sessions || sessions.length == 0)
|
||||
return (
|
||||
<View className="h-full w-full flex justify-center items-center">
|
||||
<Text className="text-lg text-neutral-500">{t("home.sessions.no_active_sessions")}</Text>
|
||||
<View className='h-full w-full flex justify-center items-center'>
|
||||
<Text className='text-lg text-neutral-500'>
|
||||
{t("home.sessions.no_active_sessions")}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<FlashList
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
contentContainerStyle={{
|
||||
paddingTop: 17,
|
||||
paddingHorizontal: 17,
|
||||
@@ -70,14 +80,20 @@ const SessionCard = ({ session }: SessionCardProps) => {
|
||||
}
|
||||
|
||||
return Math.round(
|
||||
(100 / session.NowPlayingItem?.RunTimeTicks) * (session.NowPlayingItem?.RunTimeTicks - remainingTicks)
|
||||
(100 / session.NowPlayingItem?.RunTimeTicks) *
|
||||
(session.NowPlayingItem?.RunTimeTicks - remainingTicks),
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const currentTime = session.PlayState?.PositionTicks;
|
||||
const duration = session.NowPlayingItem?.RunTimeTicks;
|
||||
if (duration !== null && duration !== undefined && currentTime !== null && currentTime !== undefined) {
|
||||
if (
|
||||
duration !== null &&
|
||||
duration !== undefined &&
|
||||
currentTime !== null &&
|
||||
currentTime !== undefined
|
||||
) {
|
||||
const remainingTimeTicks = duration - currentTime;
|
||||
setRemainingTicks(remainingTimeTicks);
|
||||
}
|
||||
@@ -85,9 +101,11 @@ const SessionCard = ({ session }: SessionCardProps) => {
|
||||
|
||||
const { data: ipInfo } = useQuery({
|
||||
queryKey: ["ipinfo", session.RemoteEndPoint],
|
||||
cacheTime: Infinity,
|
||||
cacheTime: Number.POSITIVE_INFINITY,
|
||||
queryFn: async () => {
|
||||
const resp = await api.axiosInstance.get(`https://freeipapi.com/api/json/${session.RemoteEndPoint}`);
|
||||
const resp = await api.axiosInstance.get(
|
||||
`https://freeipapi.com/api/json/${session.RemoteEndPoint}`,
|
||||
);
|
||||
return resp.data;
|
||||
},
|
||||
});
|
||||
@@ -95,18 +113,23 @@ const SessionCard = ({ session }: SessionCardProps) => {
|
||||
useInterval(tick, 1000);
|
||||
|
||||
return (
|
||||
<View className="flex flex-col shadow-md bg-neutral-900 rounded-2xl mb-4">
|
||||
<View className="flex flex-row p-4">
|
||||
<View className="w-20 pr-4">
|
||||
<Poster id={session.NowPlayingItem?.Id} url={getPrimaryImageUrl({ api, item: session.NowPlayingItem })} />
|
||||
<View className='flex flex-col shadow-md bg-neutral-900 rounded-2xl mb-4'>
|
||||
<View className='flex flex-row p-4'>
|
||||
<View className='w-20 pr-4'>
|
||||
<Poster
|
||||
id={session.NowPlayingItem?.Id}
|
||||
url={getPrimaryImageUrl({ api, item: session.NowPlayingItem })}
|
||||
/>
|
||||
</View>
|
||||
<View className="w-full flex-1">
|
||||
<View className="flex flex-row justify-between">
|
||||
<View className="flex-1 pr-4">
|
||||
<View className='w-full flex-1'>
|
||||
<View className='flex flex-row justify-between'>
|
||||
<View className='flex-1 pr-4'>
|
||||
{session.NowPlayingItem?.Type === "Episode" ? (
|
||||
<>
|
||||
<Text className="font-bold">{session.NowPlayingItem?.Name}</Text>
|
||||
<Text numberOfLines={1} className="text-xs opacity-50">
|
||||
<Text className='font-bold'>
|
||||
{session.NowPlayingItem?.Name}
|
||||
</Text>
|
||||
<Text numberOfLines={1} className='text-xs opacity-50'>
|
||||
{`S${session.NowPlayingItem.ParentIndexNumber?.toString()}:E${session.NowPlayingItem.IndexNumber?.toString()}`}
|
||||
{" - "}
|
||||
{session.NowPlayingItem.SeriesName}
|
||||
@@ -114,13 +137,19 @@ const SessionCard = ({ session }: SessionCardProps) => {
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text className="font-bold">{session.NowPlayingItem?.Name}</Text>
|
||||
<Text className="text-xs opacity-50">{session.NowPlayingItem?.ProductionYear}</Text>
|
||||
<Text className="text-xs opacity-50">{session.NowPlayingItem?.SeriesName}</Text>
|
||||
<Text className='font-bold'>
|
||||
{session.NowPlayingItem?.Name}
|
||||
</Text>
|
||||
<Text className='text-xs opacity-50'>
|
||||
{session.NowPlayingItem?.ProductionYear}
|
||||
</Text>
|
||||
<Text className='text-xs opacity-50'>
|
||||
{session.NowPlayingItem?.SeriesName}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
<Text className="text-xs opacity-50 align-right text-right">
|
||||
<Text className='text-xs opacity-50 align-right text-right'>
|
||||
{session.UserName}
|
||||
{"\n"}
|
||||
{session.Client}
|
||||
@@ -130,21 +159,21 @@ const SessionCard = ({ session }: SessionCardProps) => {
|
||||
{ipInfo?.cityName} {ipInfo?.countryCode}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="flex-1" />
|
||||
<View className="flex flex-col align-bottom">
|
||||
<View className="flex flex-row justify-between align-bottom mb-1">
|
||||
<Text className="-ml-0.5 text-xs opacity-50 align-left text-left">
|
||||
<View className='flex-1' />
|
||||
<View className='flex flex-col align-bottom'>
|
||||
<View className='flex flex-row justify-between align-bottom mb-1'>
|
||||
<Text className='-ml-0.5 text-xs opacity-50 align-left text-left'>
|
||||
{!session.PlayState?.IsPaused ? (
|
||||
<Ionicons name="play" size={14} color="white" />
|
||||
<Ionicons name='play' size={14} color='white' />
|
||||
) : (
|
||||
<Ionicons name="pause" size={14} color="white" />
|
||||
<Ionicons name='pause' size={14} color='white' />
|
||||
)}
|
||||
</Text>
|
||||
<Text className="text-xs opacity-50 align-right text-right">
|
||||
<Text className='text-xs opacity-50 align-right text-right'>
|
||||
{formatTimeString(remainingTicks, "tick")} left
|
||||
</Text>
|
||||
</View>
|
||||
<View className="align-bottom bg-gray-800 h-1">
|
||||
<View className='align-bottom bg-gray-800 h-1'>
|
||||
<View
|
||||
className={`bg-purple-600 h-full`}
|
||||
style={{
|
||||
@@ -166,17 +195,23 @@ interface TranscodingBadgesProps {
|
||||
|
||||
const TranscodingBadges = ({ properties }: TranscodingBadgesProps) => {
|
||||
const iconMap = {
|
||||
bitrate: <Ionicons name="speedometer-outline" size={12} color="white" />,
|
||||
codec: <Ionicons name="layers-outline" size={12} color="white" />,
|
||||
videoRange: <Ionicons name="color-palette-outline" size={12} color="white" />,
|
||||
resolution: <Ionicons name="film-outline" size={12} color="white" />,
|
||||
language: <Ionicons name="language-outline" size={12} color="white" />,
|
||||
audioChannels: <Ionicons name="mic-outline" size={12} color="white" />,
|
||||
hwType: <Ionicons name="hardware-chip-outline" size={12} color="white" />,
|
||||
bitrate: <Ionicons name='speedometer-outline' size={12} color='white' />,
|
||||
codec: <Ionicons name='layers-outline' size={12} color='white' />,
|
||||
videoRange: (
|
||||
<Ionicons name='color-palette-outline' size={12} color='white' />
|
||||
),
|
||||
resolution: <Ionicons name='film-outline' size={12} color='white' />,
|
||||
language: <Ionicons name='language-outline' size={12} color='white' />,
|
||||
audioChannels: <Ionicons name='mic-outline' size={12} color='white' />,
|
||||
hwType: <Ionicons name='hardware-chip-outline' size={12} color='white' />,
|
||||
} as const;
|
||||
|
||||
const icon = (val: string) => {
|
||||
return iconMap[val as keyof typeof iconMap] ?? <Ionicons name="layers-outline" size={12} color="white" />;
|
||||
return (
|
||||
iconMap[val as keyof typeof iconMap] ?? (
|
||||
<Ionicons name='layers-outline' size={12} color='white' />
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const formatVal = (key: string, val: any) => {
|
||||
@@ -195,8 +230,8 @@ const TranscodingBadges = ({ properties }: TranscodingBadgesProps) => {
|
||||
.map(([key]) => (
|
||||
<Badge
|
||||
key={key}
|
||||
variant="gray"
|
||||
className="m-0 p-0 pt-0.5 mr-1"
|
||||
variant='gray'
|
||||
className='m-0 p-0 pt-0.5 mr-1'
|
||||
text={formatVal(key, properties[key as keyof StreamProps])}
|
||||
iconLeft={icon(key)}
|
||||
/>
|
||||
@@ -216,7 +251,7 @@ interface StreamProps {
|
||||
interface TranscodingStreamViewProps {
|
||||
title: string | undefined;
|
||||
value?: string;
|
||||
isTranscoding: Boolean;
|
||||
isTranscoding: boolean;
|
||||
transcodeValue?: string | undefined | null;
|
||||
properties: StreamProps;
|
||||
transcodeProperties?: StreamProps;
|
||||
@@ -231,20 +266,26 @@ const TranscodingStreamView = ({
|
||||
transcodeValue,
|
||||
}: TranscodingStreamViewProps) => {
|
||||
return (
|
||||
<View className="flex flex-col pt-2 first:pt-0">
|
||||
<View className="flex flex-row">
|
||||
<Text className="text-xs opacity-50 w-20 font-bold text-right pr-4">{title}</Text>
|
||||
<Text className="flex-1">
|
||||
<View className='flex flex-col pt-2 first:pt-0'>
|
||||
<View className='flex flex-row'>
|
||||
<Text className='text-xs opacity-50 w-20 font-bold text-right pr-4'>
|
||||
{title}
|
||||
</Text>
|
||||
<Text className='flex-1'>
|
||||
<TranscodingBadges properties={properties} />
|
||||
</Text>
|
||||
</View>
|
||||
{isTranscoding && transcodeProperties ? (
|
||||
<>
|
||||
<View className="flex flex-row">
|
||||
<Text className="-mt-0 text-xs opacity-50 w-20 font-bold text-right pr-4">
|
||||
<MaterialCommunityIcons name="arrow-right-bottom" size={14} color="white" />
|
||||
<View className='flex flex-row'>
|
||||
<Text className='-mt-0 text-xs opacity-50 w-20 font-bold text-right pr-4'>
|
||||
<MaterialCommunityIcons
|
||||
name='arrow-right-bottom'
|
||||
size={14}
|
||||
color='white'
|
||||
/>
|
||||
</Text>
|
||||
<Text className="flex-1 text-sm mt-1">
|
||||
<Text className='flex-1 text-sm mt-1'>
|
||||
<TranscodingBadges properties={transcodeProperties} />
|
||||
</Text>
|
||||
</View>
|
||||
@@ -256,21 +297,29 @@ const TranscodingStreamView = ({
|
||||
|
||||
const TranscodingView = ({ session }: SessionCardProps) => {
|
||||
const videoStream = useMemo(() => {
|
||||
return session.NowPlayingItem?.MediaStreams?.filter((s) => s.Type == "Video")[0];
|
||||
return session.NowPlayingItem?.MediaStreams?.filter(
|
||||
(s) => s.Type == "Video",
|
||||
)[0];
|
||||
}, [session]);
|
||||
|
||||
const audioStream = useMemo(() => {
|
||||
const index = session.PlayState?.AudioStreamIndex;
|
||||
return index !== null && index !== undefined ? session.NowPlayingItem?.MediaStreams?.[index] : undefined;
|
||||
return index !== null && index !== undefined
|
||||
? session.NowPlayingItem?.MediaStreams?.[index]
|
||||
: undefined;
|
||||
}, [session.PlayState?.AudioStreamIndex]);
|
||||
|
||||
const subtitleStream = useMemo(() => {
|
||||
const index = session.PlayState?.SubtitleStreamIndex;
|
||||
return index !== null && index !== undefined ? session.NowPlayingItem?.MediaStreams?.[index] : undefined;
|
||||
return index !== null && index !== undefined
|
||||
? session.NowPlayingItem?.MediaStreams?.[index]
|
||||
: undefined;
|
||||
}, [session.PlayState?.SubtitleStreamIndex]);
|
||||
|
||||
const isTranscoding = useMemo(() => {
|
||||
return session.PlayState?.PlayMethod == "Transcode" && session.TranscodingInfo;
|
||||
return (
|
||||
session.PlayState?.PlayMethod == "Transcode" && session.TranscodingInfo
|
||||
);
|
||||
}, [session.PlayState?.PlayMethod, session.TranscodingInfo]);
|
||||
|
||||
const videoStreamTitle = () => {
|
||||
@@ -278,9 +327,9 @@ const TranscodingView = ({ session }: SessionCardProps) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<View className="flex flex-col bg-neutral-800 rounded-b-2xl p-4 pt-2">
|
||||
<View className='flex flex-col bg-neutral-800 rounded-b-2xl p-4 pt-2'>
|
||||
<TranscodingStreamView
|
||||
title="Video"
|
||||
title='Video'
|
||||
properties={{
|
||||
resolution: videoStreamTitle(),
|
||||
bitrate: videoStream?.BitRate,
|
||||
@@ -291,11 +340,15 @@ const TranscodingView = ({ session }: SessionCardProps) => {
|
||||
bitrate: session.TranscodingInfo?.Bitrate,
|
||||
codec: session.TranscodingInfo?.VideoCodec,
|
||||
}}
|
||||
isTranscoding={isTranscoding && !session.TranscodingInfo?.IsVideoDirect ? true : false}
|
||||
isTranscoding={
|
||||
isTranscoding && !session.TranscodingInfo?.IsVideoDirect
|
||||
? true
|
||||
: false
|
||||
}
|
||||
/>
|
||||
|
||||
<TranscodingStreamView
|
||||
title="Audio"
|
||||
title='Audio'
|
||||
properties={{
|
||||
language: audioStream?.Language,
|
||||
bitrate: audioStream?.BitRate,
|
||||
@@ -306,13 +359,17 @@ const TranscodingView = ({ session }: SessionCardProps) => {
|
||||
codec: session.TranscodingInfo?.AudioCodec,
|
||||
audioChannels: session.TranscodingInfo?.AudioChannels?.toString(),
|
||||
}}
|
||||
isTranscoding={isTranscoding && !session.TranscodingInfo?.IsVideoDirect ? true : false}
|
||||
isTranscoding={
|
||||
isTranscoding && !session.TranscodingInfo?.IsVideoDirect
|
||||
? true
|
||||
: false
|
||||
}
|
||||
/>
|
||||
|
||||
{subtitleStream && (
|
||||
<>
|
||||
<TranscodingStreamView
|
||||
title="Subtitle"
|
||||
title='Subtitle'
|
||||
isTranscoding={false}
|
||||
properties={{
|
||||
language: subtitleStream?.Language,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ListGroup } from "@/components/list/ListGroup";
|
||||
import { ListItem } from "@/components/list/ListItem";
|
||||
import { AppLanguageSelector } from "@/components/settings/AppLanguageSelector";
|
||||
import { AudioToggles } from "@/components/settings/AudioToggles";
|
||||
import { ChromecastSettings } from "@/components/settings/ChromecastSettings";
|
||||
import DownloadSettings from "@/components/settings/DownloadSettings";
|
||||
import { MediaProvider } from "@/components/settings/MediaContext";
|
||||
import { MediaToggles } from "@/components/settings/MediaToggles";
|
||||
@@ -14,16 +15,15 @@ import { SubtitleToggles } from "@/components/settings/SubtitleToggles";
|
||||
import { UserInfo } from "@/components/settings/UserInfo";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import { useJellyfin } from "@/providers/JellyfinProvider";
|
||||
import { userAtom } from "@/providers/JellyfinProvider";
|
||||
import { clearLogs } from "@/utils/log";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
import { useNavigation, useRouter } from "expo-router";
|
||||
import { t } from "i18next";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useEffect } from "react";
|
||||
import { ScrollView, Switch, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useAtom } from "jotai";
|
||||
import { userAtom } from "@/providers/JellyfinProvider";
|
||||
import { ChromecastSettings } from "@/components/settings/ChromecastSettings";
|
||||
|
||||
export default function settings() {
|
||||
const router = useRouter();
|
||||
@@ -46,7 +46,7 @@ export default function settings() {
|
||||
logout();
|
||||
}}
|
||||
>
|
||||
<Text className="text-red-600">
|
||||
<Text className='text-red-600'>
|
||||
{t("home.settings.log_out_button")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
@@ -61,15 +61,15 @@ export default function settings() {
|
||||
paddingRight: insets.right,
|
||||
}}
|
||||
>
|
||||
<View className="p-4 flex flex-col gap-y-4">
|
||||
<View className='p-4 flex flex-col gap-y-4'>
|
||||
<UserInfo />
|
||||
|
||||
<QuickConnect className="mb-4" />
|
||||
<QuickConnect className='mb-4' />
|
||||
|
||||
<MediaProvider>
|
||||
<MediaToggles className="mb-4" />
|
||||
<AudioToggles className="mb-4" />
|
||||
<SubtitleToggles className="mb-4" />
|
||||
<MediaToggles className='mb-4' />
|
||||
<AudioToggles className='mb-4' />
|
||||
<SubtitleToggles className='mb-4' />
|
||||
</MediaProvider>
|
||||
|
||||
<OtherSettings />
|
||||
@@ -90,7 +90,7 @@ export default function settings() {
|
||||
title={t("home.settings.intro.show_intro")}
|
||||
/>
|
||||
<ListItem
|
||||
textColor="red"
|
||||
textColor='red'
|
||||
onPress={() => {
|
||||
storage.set("hasShownIntro", false);
|
||||
}}
|
||||
@@ -98,7 +98,7 @@ export default function settings() {
|
||||
/>
|
||||
</ListGroup>
|
||||
|
||||
<View className="mb-4">
|
||||
<View className='mb-4'>
|
||||
<ListGroup title={t("home.settings.logs.logs_title")}>
|
||||
<ListItem
|
||||
onPress={() => router.push("/settings/logs/page")}
|
||||
@@ -106,7 +106,7 @@ export default function settings() {
|
||||
title={t("home.settings.logs.logs_title")}
|
||||
/>
|
||||
<ListItem
|
||||
textColor="red"
|
||||
textColor='red'
|
||||
onPress={onClearLogsClicked}
|
||||
title={t("home.settings.logs.delete_all_logs")}
|
||||
/>
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { ListGroup } from "@/components/list/ListGroup";
|
||||
import { ListItem } from "@/components/list/ListItem";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getUserViewsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Switch, View } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||
|
||||
export default function page() {
|
||||
const [settings, updateSettings, pluginSettings] = useSettings();
|
||||
@@ -18,7 +18,7 @@ export default function page() {
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { data, isLoading: isLoading } = useQuery({
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["user-views", user?.Id],
|
||||
queryFn: async () => {
|
||||
const response = await getUserViewsApi(api!).getUserViews({
|
||||
@@ -33,7 +33,7 @@ export default function page() {
|
||||
|
||||
if (isLoading)
|
||||
return (
|
||||
<View className="mt-4">
|
||||
<View className='mt-4'>
|
||||
<Loader />
|
||||
</View>
|
||||
);
|
||||
@@ -41,7 +41,7 @@ export default function page() {
|
||||
return (
|
||||
<DisabledSetting
|
||||
disabled={pluginSettings?.hiddenLibraries?.locked === true}
|
||||
className="px-4"
|
||||
className='px-4'
|
||||
>
|
||||
<ListGroup>
|
||||
{data?.map((view) => (
|
||||
@@ -59,8 +59,8 @@ export default function page() {
|
||||
</ListItem>
|
||||
))}
|
||||
</ListGroup>
|
||||
<Text className="px-4 text-xs text-neutral-500 mt-1">
|
||||
{t("home.settings.other.select_liraries_you_want_to_hide")}
|
||||
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
||||
{t("home.settings.other.select_liraries_you_want_to_hide")}
|
||||
</Text>
|
||||
</DisabledSetting>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||
import { JellyseerrSettings } from "@/components/settings/Jellyseerr";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||
|
||||
export default function page() {
|
||||
const [settings, updateSettings, pluginSettings] = useSettings();
|
||||
@@ -8,7 +8,7 @@ export default function page() {
|
||||
return (
|
||||
<DisabledSetting
|
||||
disabled={pluginSettings?.jellyseerrServerUrl?.locked === true}
|
||||
className="p-4"
|
||||
className='p-4'
|
||||
>
|
||||
<JellyseerrSettings />
|
||||
</DisabledSetting>
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useLog } from "@/utils/log";
|
||||
import { ScrollView, View } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ScrollView, View } from "react-native";
|
||||
|
||||
export default function page() {
|
||||
const { logs } = useLog();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<ScrollView className="p-4">
|
||||
<View className="flex flex-col space-y-2">
|
||||
<ScrollView className='p-4'>
|
||||
<View className='flex flex-col space-y-2'>
|
||||
{logs?.map((log, index) => (
|
||||
<View key={index} className="bg-neutral-900 rounded-xl p-3">
|
||||
<View key={index} className='bg-neutral-900 rounded-xl p-3'>
|
||||
<Text
|
||||
className={`
|
||||
mb-1
|
||||
@@ -21,13 +21,15 @@ export default function page() {
|
||||
>
|
||||
{log.level}
|
||||
</Text>
|
||||
<Text uiTextView selectable className="text-xs">
|
||||
<Text uiTextView selectable className='text-xs'>
|
||||
{log.message}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
{logs?.length === 0 && (
|
||||
<Text className="opacity-50">{t("home.settings.logs.no_logs_available")}</Text>
|
||||
<Text className='opacity-50'>
|
||||
{t("home.settings.logs.no_logs_available")}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
@@ -6,7 +6,8 @@ import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useNavigation } from "expo-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import React, {useEffect, useMemo, useState} from "react";
|
||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Linking,
|
||||
Switch,
|
||||
@@ -15,7 +16,6 @@ import {
|
||||
View,
|
||||
} from "react-native";
|
||||
import { toast } from "sonner-native";
|
||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||
|
||||
export default function page() {
|
||||
const navigation = useNavigation();
|
||||
@@ -39,7 +39,10 @@ export default function page() {
|
||||
};
|
||||
|
||||
const disabled = useMemo(() => {
|
||||
return pluginSettings?.searchEngine?.locked === true && pluginSettings?.marlinServerUrl?.locked === true
|
||||
return (
|
||||
pluginSettings?.searchEngine?.locked === true &&
|
||||
pluginSettings?.marlinServerUrl?.locked === true
|
||||
);
|
||||
}, [pluginSettings]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -47,7 +50,9 @@ export default function page() {
|
||||
navigation.setOptions({
|
||||
headerRight: () => (
|
||||
<TouchableOpacity onPress={() => onSave(value)}>
|
||||
<Text className="text-blue-500">{t("home.settings.plugins.marlin_search.save_button")}</Text>
|
||||
<Text className='text-blue-500'>
|
||||
{t("home.settings.plugins.marlin_search.save_button")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
),
|
||||
});
|
||||
@@ -57,17 +62,16 @@ export default function page() {
|
||||
if (!settings) return null;
|
||||
|
||||
return (
|
||||
<DisabledSetting
|
||||
disabled={disabled}
|
||||
className="px-4"
|
||||
>
|
||||
<DisabledSetting disabled={disabled} className='px-4'>
|
||||
<ListGroup>
|
||||
<DisabledSetting
|
||||
disabled={pluginSettings?.searchEngine?.locked === true}
|
||||
showText={!pluginSettings?.marlinServerUrl?.locked}
|
||||
>
|
||||
<ListItem
|
||||
title={t("home.settings.plugins.marlin_search.enable_marlin_search")}
|
||||
title={t(
|
||||
"home.settings.plugins.marlin_search.enable_marlin_search",
|
||||
)}
|
||||
onPress={() => {
|
||||
updateSettings({ searchEngine: "Jellyfin" });
|
||||
queryClient.invalidateQueries({ queryKey: ["search"] });
|
||||
@@ -87,28 +91,30 @@ export default function page() {
|
||||
<DisabledSetting
|
||||
disabled={pluginSettings?.marlinServerUrl?.locked === true}
|
||||
showText={!pluginSettings?.searchEngine?.locked}
|
||||
className="mt-2 flex flex-col rounded-xl overflow-hidden pl-4 bg-neutral-900 px-4"
|
||||
className='mt-2 flex flex-col rounded-xl overflow-hidden pl-4 bg-neutral-900 px-4'
|
||||
>
|
||||
<View
|
||||
className={`flex flex-row items-center bg-neutral-900 h-11 pr-4`}
|
||||
>
|
||||
<Text className="mr-4">{t("home.settings.plugins.marlin_search.url")}</Text>
|
||||
<View className={`flex flex-row items-center bg-neutral-900 h-11 pr-4`}>
|
||||
<Text className='mr-4'>
|
||||
{t("home.settings.plugins.marlin_search.url")}
|
||||
</Text>
|
||||
<TextInput
|
||||
editable={settings.searchEngine === "Marlin"}
|
||||
className="text-white"
|
||||
placeholder={t("home.settings.plugins.marlin_search.server_url_placeholder")}
|
||||
className='text-white'
|
||||
placeholder={t(
|
||||
"home.settings.plugins.marlin_search.server_url_placeholder",
|
||||
)}
|
||||
value={value}
|
||||
keyboardType="url"
|
||||
returnKeyType="done"
|
||||
autoCapitalize="none"
|
||||
textContentType="URL"
|
||||
keyboardType='url'
|
||||
returnKeyType='done'
|
||||
autoCapitalize='none'
|
||||
textContentType='URL'
|
||||
onChangeText={(text) => setValue(text)}
|
||||
/>
|
||||
</View>
|
||||
</DisabledSetting>
|
||||
<Text className="px-4 text-xs text-neutral-500 mt-1">
|
||||
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
||||
{t("home.settings.plugins.marlin_search.marlin_search_hint")}{" "}
|
||||
<Text className="text-blue-500" onPress={handleOpenLink}>
|
||||
<Text className='text-blue-500' onPress={handleOpenLink}>
|
||||
{t("home.settings.plugins.marlin_search.read_more_about_marlin")}
|
||||
</Text>
|
||||
</Text>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Text } from "@/components/common/Text";
|
||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||
import { OptimizedServerForm } from "@/components/settings/OptimizedServerForm";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
@@ -8,10 +9,9 @@ import { useMutation } from "@tanstack/react-query";
|
||||
import { useNavigation } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
|
||||
import { toast } from "sonner-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||
|
||||
export default function page() {
|
||||
const navigation = useNavigation();
|
||||
@@ -67,8 +67,12 @@ export default function page() {
|
||||
saveMutation.isPending ? (
|
||||
<ActivityIndicator size={"small"} color={"white"} />
|
||||
) : (
|
||||
<TouchableOpacity onPress={() => onSave(optimizedVersionsServerUrl)}>
|
||||
<Text className="text-blue-500">{t("home.settings.downloads.save_button")}</Text>
|
||||
<TouchableOpacity
|
||||
onPress={() => onSave(optimizedVersionsServerUrl)}
|
||||
>
|
||||
<Text className='text-blue-500'>
|
||||
{t("home.settings.downloads.save_button")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
),
|
||||
});
|
||||
@@ -78,7 +82,7 @@ export default function page() {
|
||||
return (
|
||||
<DisabledSetting
|
||||
disabled={pluginSettings?.optimizedVersionsServerUrl?.locked === true}
|
||||
className="p-4"
|
||||
className='p-4'
|
||||
>
|
||||
<OptimizedServerForm
|
||||
value={optimizedVersionsServerUrl}
|
||||
|
||||
@@ -10,15 +10,15 @@ import MoviePoster from "@/components/posters/MoviePoster";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||
import { BaseItemDtoQueryResult } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import type { BaseItemDtoQueryResult } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Image } from "expo-image";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { View } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View } from "react-native";
|
||||
|
||||
const page: React.FC = () => {
|
||||
const local = useLocalSearchParams();
|
||||
@@ -68,7 +68,7 @@ const page: React.FC = () => {
|
||||
|
||||
return response.data;
|
||||
},
|
||||
[api, user?.Id, actorId]
|
||||
[api, user?.Id, actorId],
|
||||
);
|
||||
|
||||
const backdropUrl = useMemo(
|
||||
@@ -79,12 +79,12 @@ const page: React.FC = () => {
|
||||
quality: 90,
|
||||
width: 1000,
|
||||
}),
|
||||
[item]
|
||||
[item],
|
||||
);
|
||||
|
||||
if (l1)
|
||||
return (
|
||||
<View className="justify-center items-center h-full">
|
||||
<View className='justify-center items-center h-full'>
|
||||
<Loader />
|
||||
</View>
|
||||
);
|
||||
@@ -105,13 +105,13 @@ const page: React.FC = () => {
|
||||
/>
|
||||
}
|
||||
>
|
||||
<View className="flex flex-col space-y-4 my-4">
|
||||
<View className="px-4 mb-4">
|
||||
<MoviesTitleHeader item={item} className="mb-4" />
|
||||
<View className='flex flex-col space-y-4 my-4'>
|
||||
<View className='px-4 mb-4'>
|
||||
<MoviesTitleHeader item={item} className='mb-4' />
|
||||
<OverviewText text={item.Overview} />
|
||||
</View>
|
||||
|
||||
<Text className="px-4 text-2xl font-bold mb-2 text-neutral-100">
|
||||
<Text className='px-4 text-2xl font-bold mb-2 text-neutral-100'>
|
||||
{t("item_card.appeared_in")}
|
||||
</Text>
|
||||
<InfiniteHorizontalScroll
|
||||
@@ -133,7 +133,7 @@ const page: React.FC = () => {
|
||||
queryFn={fetchItems}
|
||||
queryKey={["actor", "movies", actorId]}
|
||||
/>
|
||||
<View className="h-12"></View>
|
||||
<View className='h-12'></View>
|
||||
</View>
|
||||
</ParallaxScrollView>
|
||||
);
|
||||
|
||||
@@ -1,22 +1,23 @@
|
||||
import { ItemCardText } from "@/components/ItemCardText";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||
import { FilterButton } from "@/components/filters/FilterButton";
|
||||
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
|
||||
import { ItemCardText } from "@/components/ItemCardText";
|
||||
import { ItemPoster } from "@/components/posters/ItemPoster";
|
||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import {
|
||||
SortByOption,
|
||||
SortOrderOption,
|
||||
genreFilterAtom,
|
||||
sortByAtom,
|
||||
SortByOption,
|
||||
sortOptions,
|
||||
sortOrderAtom,
|
||||
SortOrderOption,
|
||||
sortOrderOptions,
|
||||
tagsFilterAtom,
|
||||
yearFilterAtom,
|
||||
} from "@/utils/atoms/filters";
|
||||
import {
|
||||
import type {
|
||||
BaseItemDto,
|
||||
BaseItemDtoQueryResult,
|
||||
ItemSortBy,
|
||||
@@ -29,11 +30,11 @@ import {
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { FlatList, View } from "react-native";
|
||||
import type React from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FlatList, View } from "react-native";
|
||||
|
||||
const page: React.FC = () => {
|
||||
const searchParams = useLocalSearchParams();
|
||||
@@ -43,7 +44,7 @@ const page: React.FC = () => {
|
||||
const [user] = useAtom(userAtom);
|
||||
const navigation = useNavigation();
|
||||
const [orientation, setOrientation] = useState(
|
||||
ScreenOrientation.Orientation.PORTRAIT_UP
|
||||
ScreenOrientation.Orientation.PORTRAIT_UP,
|
||||
);
|
||||
|
||||
const { t } = useTranslation();
|
||||
@@ -111,7 +112,7 @@ const page: React.FC = () => {
|
||||
recursive: true,
|
||||
genres: selectedGenres,
|
||||
tags: selectedTags,
|
||||
years: selectedYears.map((year) => parseInt(year)),
|
||||
years: selectedYears.map((year) => Number.parseInt(year)),
|
||||
includeItemTypes: ["Movie", "Series"],
|
||||
});
|
||||
|
||||
@@ -126,7 +127,7 @@ const page: React.FC = () => {
|
||||
selectedTags,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
]
|
||||
],
|
||||
);
|
||||
|
||||
const { data, isFetching, fetchNextPage, hasNextPage } = useInfiniteQuery({
|
||||
@@ -151,7 +152,7 @@ const page: React.FC = () => {
|
||||
const totalItems = lastPage.TotalRecordCount;
|
||||
const accumulatedItems = pages.reduce(
|
||||
(acc, curr) => acc + (curr?.Items?.length || 0),
|
||||
0
|
||||
0,
|
||||
);
|
||||
|
||||
if (accumulatedItems < totalItems) {
|
||||
@@ -188,8 +189,8 @@ const page: React.FC = () => {
|
||||
index % 3 === 0
|
||||
? "flex-end"
|
||||
: (index + 1) % 3 === 0
|
||||
? "flex-start"
|
||||
: "center",
|
||||
? "flex-start"
|
||||
: "center",
|
||||
width: "89%",
|
||||
}}
|
||||
>
|
||||
@@ -199,14 +200,14 @@ const page: React.FC = () => {
|
||||
</View>
|
||||
</TouchableItemRouter>
|
||||
),
|
||||
[orientation]
|
||||
[orientation],
|
||||
);
|
||||
|
||||
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
|
||||
|
||||
const ListHeaderComponent = useCallback(
|
||||
() => (
|
||||
<View className="">
|
||||
<View className=''>
|
||||
<FlatList
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
@@ -232,13 +233,13 @@ const page: React.FC = () => {
|
||||
key: "genre",
|
||||
component: (
|
||||
<FilterButton
|
||||
className="mr-1"
|
||||
className='mr-1'
|
||||
collectionId={collectionId}
|
||||
queryKey="genreFilter"
|
||||
queryKey='genreFilter'
|
||||
queryFn={async () => {
|
||||
if (!api) return null;
|
||||
const response = await getFilterApi(
|
||||
api
|
||||
api,
|
||||
).getQueryFiltersLegacy({
|
||||
userId: user?.Id,
|
||||
parentId: collectionId,
|
||||
@@ -259,13 +260,13 @@ const page: React.FC = () => {
|
||||
key: "year",
|
||||
component: (
|
||||
<FilterButton
|
||||
className="mr-1"
|
||||
className='mr-1'
|
||||
collectionId={collectionId}
|
||||
queryKey="yearFilter"
|
||||
queryKey='yearFilter'
|
||||
queryFn={async () => {
|
||||
if (!api) return null;
|
||||
const response = await getFilterApi(
|
||||
api
|
||||
api,
|
||||
).getQueryFiltersLegacy({
|
||||
userId: user?.Id,
|
||||
parentId: collectionId,
|
||||
@@ -284,13 +285,13 @@ const page: React.FC = () => {
|
||||
key: "tags",
|
||||
component: (
|
||||
<FilterButton
|
||||
className="mr-1"
|
||||
className='mr-1'
|
||||
collectionId={collectionId}
|
||||
queryKey="tagsFilter"
|
||||
queryKey='tagsFilter'
|
||||
queryFn={async () => {
|
||||
if (!api) return null;
|
||||
const response = await getFilterApi(
|
||||
api
|
||||
api,
|
||||
).getQueryFiltersLegacy({
|
||||
userId: user?.Id,
|
||||
parentId: collectionId,
|
||||
@@ -311,9 +312,9 @@ const page: React.FC = () => {
|
||||
key: "sortBy",
|
||||
component: (
|
||||
<FilterButton
|
||||
className="mr-1"
|
||||
className='mr-1'
|
||||
collectionId={collectionId}
|
||||
queryKey="sortBy"
|
||||
queryKey='sortBy'
|
||||
queryFn={async () => sortOptions.map((s) => s.key)}
|
||||
set={setSortBy}
|
||||
values={sortBy}
|
||||
@@ -331,9 +332,9 @@ const page: React.FC = () => {
|
||||
key: "sortOrder",
|
||||
component: (
|
||||
<FilterButton
|
||||
className="mr-1"
|
||||
className='mr-1'
|
||||
collectionId={collectionId}
|
||||
queryKey="sortOrder"
|
||||
queryKey='sortOrder'
|
||||
queryFn={async () => sortOrderOptions.map((s) => s.key)}
|
||||
set={setSortOrder}
|
||||
values={sortOrder}
|
||||
@@ -368,7 +369,7 @@ const page: React.FC = () => {
|
||||
sortOrder,
|
||||
setSortOrder,
|
||||
isFetching,
|
||||
]
|
||||
],
|
||||
);
|
||||
|
||||
if (!collection) return null;
|
||||
@@ -376,8 +377,10 @@ const page: React.FC = () => {
|
||||
return (
|
||||
<FlashList
|
||||
ListEmptyComponent={
|
||||
<View className="flex flex-col items-center justify-center h-full">
|
||||
<Text className="font-bold text-xl text-neutral-500">{t("search.no_results")}</Text>
|
||||
<View className='flex flex-col items-center justify-center h-full'>
|
||||
<Text className='font-bold text-xl text-neutral-500'>
|
||||
{t("search.no_results")}
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
extraData={[
|
||||
@@ -387,7 +390,7 @@ const page: React.FC = () => {
|
||||
sortBy,
|
||||
sortOrder,
|
||||
]}
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
data={flatData}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={keyExtractor}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { ItemContent } from "@/components/ItemContent";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useEffect } from "react";
|
||||
import type React from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View } from "react-native";
|
||||
import Animated, {
|
||||
runOnJS,
|
||||
@@ -13,7 +15,6 @@ import Animated, {
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const Page: React.FC = () => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
@@ -75,36 +76,36 @@ const Page: React.FC = () => {
|
||||
|
||||
if (isError)
|
||||
return (
|
||||
<View className="flex flex-col items-center justify-center h-screen w-screen">
|
||||
<View className='flex flex-col items-center justify-center h-screen w-screen'>
|
||||
<Text>{t("item_card.could_not_load_item")}</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<View className="flex flex-1 relative">
|
||||
<View className='flex flex-1 relative'>
|
||||
<Animated.View
|
||||
pointerEvents={"none"}
|
||||
style={[animatedStyle]}
|
||||
className="absolute top-0 left-0 flex flex-col items-start h-screen w-screen px-4 z-50 bg-black"
|
||||
className='absolute top-0 left-0 flex flex-col items-start h-screen w-screen px-4 z-50 bg-black'
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
height: item?.Type === "Episode" ? 300 : 450,
|
||||
}}
|
||||
className="bg-transparent rounded-lg mb-4 w-full"
|
||||
className='bg-transparent rounded-lg mb-4 w-full'
|
||||
></View>
|
||||
<View className="h-6 bg-neutral-900 rounded mb-4 w-14"></View>
|
||||
<View className="h-10 bg-neutral-900 rounded-lg mb-2 w-1/2"></View>
|
||||
<View className="h-3 bg-neutral-900 rounded mb-3 w-8"></View>
|
||||
<View className="flex flex-row space-x-1 mb-8">
|
||||
<View className="h-6 bg-neutral-900 rounded mb-3 w-14"></View>
|
||||
<View className="h-6 bg-neutral-900 rounded mb-3 w-14"></View>
|
||||
<View className="h-6 bg-neutral-900 rounded mb-3 w-14"></View>
|
||||
<View className='h-6 bg-neutral-900 rounded mb-4 w-14'></View>
|
||||
<View className='h-10 bg-neutral-900 rounded-lg mb-2 w-1/2'></View>
|
||||
<View className='h-3 bg-neutral-900 rounded mb-3 w-8'></View>
|
||||
<View className='flex flex-row space-x-1 mb-8'>
|
||||
<View className='h-6 bg-neutral-900 rounded mb-3 w-14'></View>
|
||||
<View className='h-6 bg-neutral-900 rounded mb-3 w-14'></View>
|
||||
<View className='h-6 bg-neutral-900 rounded mb-3 w-14'></View>
|
||||
</View>
|
||||
<View className="h-3 bg-neutral-900 rounded w-2/3 mb-1"></View>
|
||||
<View className="h-10 bg-neutral-900 rounded-lg w-full mb-2"></View>
|
||||
<View className="h-12 bg-neutral-900 rounded-lg w-full mb-2"></View>
|
||||
<View className="h-24 bg-neutral-900 rounded-lg mb-1 w-full"></View>
|
||||
<View className='h-3 bg-neutral-900 rounded w-2/3 mb-1'></View>
|
||||
<View className='h-10 bg-neutral-900 rounded-lg w-full mb-2'></View>
|
||||
<View className='h-12 bg-neutral-900 rounded-lg w-full mb-2'></View>
|
||||
<View className='h-24 bg-neutral-900 rounded-lg mb-1 w-full'></View>
|
||||
</Animated.View>
|
||||
{item && <ItemContent item={item} />}
|
||||
</View>
|
||||
|
||||
@@ -1,41 +1,43 @@
|
||||
import {useLocalSearchParams} from "expo-router";
|
||||
import React, {useMemo,} from "react";
|
||||
import {useInfiniteQuery} from "@tanstack/react-query";
|
||||
import {Endpoints, useJellyseerr} from "@/hooks/useJellyseerr";
|
||||
import {Image} from "expo-image";
|
||||
import {DiscoverSliderType} from "@/utils/jellyseerr/server/constants/discover";
|
||||
import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow";
|
||||
import {MovieResult, Results, TvResult} from "@/utils/jellyseerr/server/models/Search";
|
||||
import {COMPANY_LOGO_IMAGE_FILTER} from "@/utils/jellyseerr/src/components/Discover/NetworkSlider";
|
||||
import {uniqBy} from "lodash";
|
||||
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
|
||||
import { Endpoints, useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
|
||||
import {
|
||||
type MovieResult,
|
||||
Results,
|
||||
type TvResult,
|
||||
} from "@/utils/jellyseerr/server/models/Search";
|
||||
import { COMPANY_LOGO_IMAGE_FILTER } from "@/utils/jellyseerr/src/components/Discover/NetworkSlider";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { Image } from "expo-image";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { uniqBy } from "lodash";
|
||||
import React, { useMemo } from "react";
|
||||
|
||||
export default function page() {
|
||||
const local = useLocalSearchParams();
|
||||
const {jellyseerrApi} = useJellyseerr();
|
||||
const { jellyseerrApi } = useJellyseerr();
|
||||
|
||||
const {companyId, name, image, type} = local as unknown as {
|
||||
companyId: string,
|
||||
name: string,
|
||||
image: string,
|
||||
type: DiscoverSliderType
|
||||
const { companyId, name, image, type } = local as unknown as {
|
||||
companyId: string;
|
||||
name: string;
|
||||
image: string;
|
||||
type: DiscoverSliderType;
|
||||
};
|
||||
|
||||
const {data, fetchNextPage, hasNextPage} = useInfiniteQuery({
|
||||
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
|
||||
queryKey: ["jellyseerr", "company", type, companyId],
|
||||
queryFn: async ({pageParam}) => {
|
||||
let params: any = {
|
||||
queryFn: async ({ pageParam }) => {
|
||||
const params: any = {
|
||||
page: Number(pageParam),
|
||||
};
|
||||
|
||||
return jellyseerrApi?.discover(
|
||||
(
|
||||
type == DiscoverSliderType.NETWORKS
|
||||
? Endpoints.DISCOVER_TV_NETWORK
|
||||
: Endpoints.DISCOVER_MOVIES_STUDIO
|
||||
) + `/${companyId}`,
|
||||
params
|
||||
)
|
||||
(type == DiscoverSliderType.NETWORKS
|
||||
? Endpoints.DISCOVER_TV_NETWORK
|
||||
: Endpoints.DISCOVER_MOVIES_STUDIO) + `/${companyId}`,
|
||||
params,
|
||||
);
|
||||
},
|
||||
enabled: !!jellyseerrApi && !!companyId,
|
||||
initialPageParam: 1,
|
||||
@@ -46,46 +48,58 @@ export default function page() {
|
||||
});
|
||||
|
||||
const flatData = useMemo(
|
||||
() => uniqBy(data?.pages?.filter((p) => p?.results.length).flatMap((p) => p?.results ?? []), "id")?? [],
|
||||
[data]
|
||||
() =>
|
||||
uniqBy(
|
||||
data?.pages
|
||||
?.filter((p) => p?.results.length)
|
||||
.flatMap((p) => p?.results ?? []),
|
||||
"id",
|
||||
) ?? [],
|
||||
[data],
|
||||
);
|
||||
|
||||
const backdrops = useMemo(
|
||||
() => jellyseerrApi
|
||||
? flatData.map((r) => jellyseerrApi.imageProxy((r as TvResult | MovieResult).backdropPath, "w1920_and_h800_multi_faces"))
|
||||
: [],
|
||||
[jellyseerrApi, flatData]
|
||||
() =>
|
||||
jellyseerrApi
|
||||
? flatData.map((r) =>
|
||||
jellyseerrApi.imageProxy(
|
||||
(r as TvResult | MovieResult).backdropPath,
|
||||
"w1920_and_h800_multi_faces",
|
||||
),
|
||||
)
|
||||
: [],
|
||||
[jellyseerrApi, flatData],
|
||||
);
|
||||
|
||||
return (
|
||||
<ParallaxSlideShow
|
||||
data={flatData}
|
||||
images={backdrops}
|
||||
listHeader=""
|
||||
listHeader=''
|
||||
keyExtractor={(item) => item.id.toString()}
|
||||
onEndReached={() => {
|
||||
if (hasNextPage) {
|
||||
fetchNextPage()
|
||||
fetchNextPage();
|
||||
}
|
||||
}}
|
||||
logo={
|
||||
<Image
|
||||
id={companyId}
|
||||
key={companyId}
|
||||
className="bottom-1 w-1/2"
|
||||
className='bottom-1 w-1/2'
|
||||
source={{
|
||||
uri: jellyseerrApi?.imageProxy(image, COMPANY_LOGO_IMAGE_FILTER),
|
||||
}}
|
||||
cachePolicy={"memory-disk"}
|
||||
contentFit="contain"
|
||||
contentFit='contain'
|
||||
style={{
|
||||
aspectRatio: "4/3",
|
||||
}}
|
||||
/>
|
||||
}
|
||||
renderItem={(item, index) =>
|
||||
renderItem={(item, index) => (
|
||||
<JellyseerrPoster item={item as MovieResult | TvResult} />
|
||||
}
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,42 +1,46 @@
|
||||
import {router, useLocalSearchParams, useSegments,} from "expo-router";
|
||||
import React, {useMemo,} from "react";
|
||||
import {TouchableOpacity} from "react-native";
|
||||
import {useInfiniteQuery} from "@tanstack/react-query";
|
||||
import {Endpoints, useJellyseerr} from "@/hooks/useJellyseerr";
|
||||
import {Text} from "@/components/common/Text";
|
||||
import Poster from "@/components/posters/Poster";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import JellyseerrMediaIcon from "@/components/jellyseerr/JellyseerrMediaIcon";
|
||||
import {DiscoverSliderType} from "@/utils/jellyseerr/server/constants/discover";
|
||||
import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow";
|
||||
import {MovieResult, Results, TvResult} from "@/utils/jellyseerr/server/models/Search";
|
||||
import {uniqBy} from "lodash";
|
||||
import {textShadowStyle} from "@/components/jellyseerr/discover/GenericSlideCard";
|
||||
import { textShadowStyle } from "@/components/jellyseerr/discover/GenericSlideCard";
|
||||
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
|
||||
import Poster from "@/components/posters/Poster";
|
||||
import { Endpoints, useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
|
||||
import {
|
||||
type MovieResult,
|
||||
Results,
|
||||
type TvResult,
|
||||
} from "@/utils/jellyseerr/server/models/Search";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { router, useLocalSearchParams, useSegments } from "expo-router";
|
||||
import { uniqBy } from "lodash";
|
||||
import React, { useMemo } from "react";
|
||||
import { TouchableOpacity } from "react-native";
|
||||
|
||||
export default function page() {
|
||||
const local = useLocalSearchParams();
|
||||
const {jellyseerrApi} = useJellyseerr();
|
||||
const { jellyseerrApi } = useJellyseerr();
|
||||
|
||||
const {genreId, name, type} = local as unknown as {
|
||||
genreId: string,
|
||||
name: string,
|
||||
type: DiscoverSliderType
|
||||
const { genreId, name, type } = local as unknown as {
|
||||
genreId: string;
|
||||
name: string;
|
||||
type: DiscoverSliderType;
|
||||
};
|
||||
|
||||
const {data, fetchNextPage, hasNextPage} = useInfiniteQuery({
|
||||
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
|
||||
queryKey: ["jellyseerr", "company", type, genreId],
|
||||
queryFn: async ({pageParam}) => {
|
||||
let params: any = {
|
||||
queryFn: async ({ pageParam }) => {
|
||||
const params: any = {
|
||||
page: Number(pageParam),
|
||||
genre: genreId
|
||||
genre: genreId,
|
||||
};
|
||||
|
||||
return jellyseerrApi?.discover(
|
||||
type == DiscoverSliderType.MOVIE_GENRES
|
||||
? Endpoints.DISCOVER_MOVIES
|
||||
: Endpoints.DISCOVER_TV,
|
||||
params
|
||||
)
|
||||
type == DiscoverSliderType.MOVIE_GENRES
|
||||
? Endpoints.DISCOVER_MOVIES
|
||||
: Endpoints.DISCOVER_TV,
|
||||
params,
|
||||
);
|
||||
},
|
||||
enabled: !!jellyseerrApi && !!genreId,
|
||||
initialPageParam: 1,
|
||||
@@ -47,41 +51,54 @@ export default function page() {
|
||||
});
|
||||
|
||||
const flatData = useMemo(
|
||||
() => uniqBy(data?.pages?.filter((p) => p?.results.length).flatMap((p) => p?.results ?? []), "id")?? [],
|
||||
[data]
|
||||
() =>
|
||||
uniqBy(
|
||||
data?.pages
|
||||
?.filter((p) => p?.results.length)
|
||||
.flatMap((p) => p?.results ?? []),
|
||||
"id",
|
||||
) ?? [],
|
||||
[data],
|
||||
);
|
||||
|
||||
const backdrops = useMemo(
|
||||
() => jellyseerrApi
|
||||
? flatData.map((r) => jellyseerrApi.imageProxy((r as TvResult | MovieResult).backdropPath, "w1920_and_h800_multi_faces"))
|
||||
: [],
|
||||
[jellyseerrApi, flatData]
|
||||
() =>
|
||||
jellyseerrApi
|
||||
? flatData.map((r) =>
|
||||
jellyseerrApi.imageProxy(
|
||||
(r as TvResult | MovieResult).backdropPath,
|
||||
"w1920_and_h800_multi_faces",
|
||||
),
|
||||
)
|
||||
: [],
|
||||
[jellyseerrApi, flatData],
|
||||
);
|
||||
|
||||
return (
|
||||
<ParallaxSlideShow
|
||||
data={flatData}
|
||||
images={backdrops}
|
||||
listHeader=""
|
||||
listHeader=''
|
||||
keyExtractor={(item) => item.id.toString()}
|
||||
onEndReached={() => {
|
||||
if (hasNextPage) {
|
||||
fetchNextPage()
|
||||
fetchNextPage();
|
||||
}
|
||||
}}
|
||||
logo={
|
||||
<Text
|
||||
className="text-4xl font-bold text-center bottom-1"
|
||||
className='text-4xl font-bold text-center bottom-1'
|
||||
style={{
|
||||
...textShadowStyle.shadow,
|
||||
shadowRadius: 10
|
||||
}}>
|
||||
shadowRadius: 10,
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
</Text>
|
||||
}
|
||||
renderItem={(item, index) =>
|
||||
renderItem={(item, index) => (
|
||||
<JellyseerrPoster item={item as MovieResult | TvResult} />
|
||||
}
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,27 +1,29 @@
|
||||
import { Button } from "@/components/Button";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { GenreTags } from "@/components/GenreTags";
|
||||
import Cast from "@/components/jellyseerr/Cast";
|
||||
import DetailFacts from "@/components/jellyseerr/DetailFacts";
|
||||
import { OverviewText } from "@/components/OverviewText";
|
||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||
import { JellyserrRatings } from "@/components/Ratings";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import Cast from "@/components/jellyseerr/Cast";
|
||||
import DetailFacts from "@/components/jellyseerr/DetailFacts";
|
||||
import JellyseerrSeasons from "@/components/series/JellyseerrSeasons";
|
||||
import { ItemActions } from "@/components/series/SeriesActions";
|
||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import { useJellyseerrCanRequest } from "@/utils/_jellyseerr/useJellyseerrCanRequest";
|
||||
import {
|
||||
IssueType,
|
||||
type IssueType,
|
||||
IssueTypeName,
|
||||
} from "@/utils/jellyseerr/server/constants/issue";
|
||||
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
||||
import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search";
|
||||
import { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type {
|
||||
MovieResult,
|
||||
TvResult,
|
||||
} from "@/utils/jellyseerr/server/models/Search";
|
||||
import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import {
|
||||
BottomSheetBackdrop,
|
||||
BottomSheetBackdropProps,
|
||||
type BottomSheetBackdropProps,
|
||||
BottomSheetModal,
|
||||
BottomSheetTextInput,
|
||||
BottomSheetView,
|
||||
@@ -29,20 +31,16 @@ import {
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Image } from "expo-image";
|
||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import type React from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||
import RequestModal from "@/components/jellyseerr/RequestModal";
|
||||
import { ANIME_KEYWORD_ID } from "@/utils/jellyseerr/server/api/themoviedb/constants";
|
||||
import { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
|
||||
import {MovieDetails} from "@/utils/jellyseerr/server/models/Movie";
|
||||
import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
|
||||
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
|
||||
|
||||
const Page: React.FC = () => {
|
||||
const insets = useSafeAreaInsets();
|
||||
@@ -83,8 +81,8 @@ const Page: React.FC = () => {
|
||||
refetchInterval: 0,
|
||||
queryFn: async () => {
|
||||
return mediaType === MediaType.MOVIE
|
||||
? jellyseerrApi?.movieDetails(result.id!!)
|
||||
: jellyseerrApi?.tvDetails(result.id!!);
|
||||
? jellyseerrApi?.movieDetails(result.id!)
|
||||
: jellyseerrApi?.tvDetails(result.id!);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -99,7 +97,7 @@ const Page: React.FC = () => {
|
||||
appearsOnIndex={0}
|
||||
/>
|
||||
),
|
||||
[]
|
||||
[],
|
||||
);
|
||||
|
||||
const submitIssue = useCallback(() => {
|
||||
@@ -114,15 +112,18 @@ const Page: React.FC = () => {
|
||||
}
|
||||
}, [jellyseerrApi, details, result, issueType, issueMessage]);
|
||||
|
||||
const setRequestBody = useCallback((body: MediaRequestBody) => {
|
||||
_setRequestBody(body)
|
||||
advancedReqModalRef?.current?.present?.();
|
||||
}, [requestBody, _setRequestBody, advancedReqModalRef])
|
||||
const setRequestBody = useCallback(
|
||||
(body: MediaRequestBody) => {
|
||||
_setRequestBody(body);
|
||||
advancedReqModalRef?.current?.present?.();
|
||||
},
|
||||
[requestBody, _setRequestBody, advancedReqModalRef],
|
||||
);
|
||||
|
||||
const request = useCallback(async () => {
|
||||
const body: MediaRequestBody = {
|
||||
mediaId: Number(result.id!!),
|
||||
mediaType: mediaType!!,
|
||||
mediaId: Number(result.id!),
|
||||
mediaType: mediaType!,
|
||||
tvdbId: details?.externalIds?.tvdbId,
|
||||
seasons: (details as TvDetails)?.seasons
|
||||
?.filter?.((s) => s.seasonNumber !== 0)
|
||||
@@ -130,7 +131,7 @@ const Page: React.FC = () => {
|
||||
};
|
||||
|
||||
if (hasAdvancedRequestPermission) {
|
||||
setRequestBody(body)
|
||||
setRequestBody(body);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -141,14 +142,14 @@ const Page: React.FC = () => {
|
||||
() =>
|
||||
(details?.keywords.some((k) => k.id === ANIME_KEYWORD_ID) || false) &&
|
||||
mediaType === MediaType.TV,
|
||||
[details]
|
||||
[details],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (details) {
|
||||
navigation.setOptions({
|
||||
headerRight: () => (
|
||||
<TouchableOpacity className="rounded-full p-2 bg-neutral-800/80">
|
||||
<TouchableOpacity className='rounded-full p-2 bg-neutral-800/80'>
|
||||
<ItemActions item={details} />
|
||||
</TouchableOpacity>
|
||||
),
|
||||
@@ -158,14 +159,14 @@ const Page: React.FC = () => {
|
||||
|
||||
return (
|
||||
<View
|
||||
className="flex-1 relative"
|
||||
className='flex-1 relative'
|
||||
style={{
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
}}
|
||||
>
|
||||
<ParallaxScrollView
|
||||
className="flex-1 opacity-100"
|
||||
className='flex-1 opacity-100'
|
||||
headerHeight={300}
|
||||
headerImage={
|
||||
<View>
|
||||
@@ -180,7 +181,7 @@ const Page: React.FC = () => {
|
||||
source={{
|
||||
uri: jellyseerrApi?.imageProxy(
|
||||
result.backdropPath,
|
||||
"w1920_and_h800_multi_faces"
|
||||
"w1920_and_h800_multi_faces",
|
||||
),
|
||||
}}
|
||||
/>
|
||||
@@ -190,12 +191,12 @@ const Page: React.FC = () => {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
className="flex flex-col items-center justify-center border border-neutral-800 bg-neutral-900"
|
||||
className='flex flex-col items-center justify-center border border-neutral-800 bg-neutral-900'
|
||||
>
|
||||
<Ionicons
|
||||
name="image-outline"
|
||||
name='image-outline'
|
||||
size={24}
|
||||
color="white"
|
||||
color='white'
|
||||
style={{ opacity: 0.4 }}
|
||||
/>
|
||||
</View>
|
||||
@@ -203,23 +204,31 @@ const Page: React.FC = () => {
|
||||
</View>
|
||||
}
|
||||
>
|
||||
<View className="flex flex-col">
|
||||
<View className="space-y-4">
|
||||
<View className="px-4">
|
||||
<View className="flex flex-row justify-between w-full">
|
||||
<View className="flex flex-col w-56">
|
||||
<JellyserrRatings result={result as MovieResult | TvResult | MovieDetails | TvDetails} />
|
||||
<View className='flex flex-col'>
|
||||
<View className='space-y-4'>
|
||||
<View className='px-4'>
|
||||
<View className='flex flex-row justify-between w-full'>
|
||||
<View className='flex flex-col w-56'>
|
||||
<JellyserrRatings
|
||||
result={
|
||||
result as
|
||||
| MovieResult
|
||||
| TvResult
|
||||
| MovieDetails
|
||||
| TvDetails
|
||||
}
|
||||
/>
|
||||
<Text
|
||||
uiTextView
|
||||
selectable
|
||||
className="font-bold text-2xl mb-1"
|
||||
className='font-bold text-2xl mb-1'
|
||||
>
|
||||
{mediaTitle}
|
||||
</Text>
|
||||
<Text className="opacity-50">{releaseYear}</Text>
|
||||
<Text className='opacity-50'>{releaseYear}</Text>
|
||||
</View>
|
||||
<Image
|
||||
className="absolute bottom-1 right-1 rounded-lg w-28 aspect-[10/15] border-2 border-neutral-800/50 drop-shadow-2xl"
|
||||
className='absolute bottom-1 right-1 rounded-lg w-28 aspect-[10/15] border-2 border-neutral-800/50 drop-shadow-2xl'
|
||||
cachePolicy={"memory-disk"}
|
||||
transition={300}
|
||||
source={{
|
||||
@@ -227,22 +236,22 @@ const Page: React.FC = () => {
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
<View className="mb-4">
|
||||
<View className='mb-4'>
|
||||
<GenreTags genres={details?.genres?.map((g) => g.name) || []} />
|
||||
</View>
|
||||
{isLoading || isFetching ? (
|
||||
<Button loading={true} disabled={true} color="purple"></Button>
|
||||
<Button loading={true} disabled={true} color='purple'></Button>
|
||||
) : canRequest ? (
|
||||
<Button color="purple" onPress={request}>
|
||||
<Button color='purple' onPress={request}>
|
||||
{t("jellyseerr.request_button")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
className="bg-yellow-500/50 border-yellow-400 ring-yellow-400 text-yellow-100"
|
||||
color="transparent"
|
||||
className='bg-yellow-500/50 border-yellow-400 ring-yellow-400 text-yellow-100'
|
||||
color='transparent'
|
||||
onPress={() => bottomSheetModalRef?.current?.present()}
|
||||
iconLeft={
|
||||
<Ionicons name="warning-outline" size={24} color="white" />
|
||||
<Ionicons name='warning-outline' size={24} color='white' />
|
||||
}
|
||||
style={{
|
||||
borderWidth: 1,
|
||||
@@ -252,7 +261,7 @@ const Page: React.FC = () => {
|
||||
{t("jellyseerr.report_issue_button")}
|
||||
</Button>
|
||||
)}
|
||||
<OverviewText text={result.overview} className="mt-4" />
|
||||
<OverviewText text={result.overview} className='mt-4' />
|
||||
</View>
|
||||
|
||||
{mediaType === MediaType.TV && (
|
||||
@@ -261,13 +270,11 @@ const Page: React.FC = () => {
|
||||
details={details as TvDetails}
|
||||
refetch={refetch}
|
||||
hasAdvancedRequest={hasAdvancedRequestPermission}
|
||||
onAdvancedRequest={(data) =>
|
||||
setRequestBody(data)
|
||||
}
|
||||
onAdvancedRequest={(data) => setRequestBody(data)}
|
||||
/>
|
||||
)}
|
||||
<DetailFacts
|
||||
className="p-2 border border-neutral-800 bg-neutral-900 rounded-xl"
|
||||
className='p-2 border border-neutral-800 bg-neutral-900 rounded-xl'
|
||||
details={details}
|
||||
/>
|
||||
<Cast details={details} />
|
||||
@@ -278,11 +285,11 @@ const Page: React.FC = () => {
|
||||
ref={advancedReqModalRef}
|
||||
requestBody={requestBody}
|
||||
title={mediaTitle}
|
||||
id={result.id!!}
|
||||
id={result.id!}
|
||||
type={mediaType}
|
||||
isAnime={isAnime}
|
||||
onRequested={() => {
|
||||
_setRequestBody(undefined)
|
||||
_setRequestBody(undefined);
|
||||
advancedReqModalRef?.current?.close();
|
||||
refetch();
|
||||
}}
|
||||
@@ -300,22 +307,22 @@ const Page: React.FC = () => {
|
||||
backdropComponent={renderBackdrop}
|
||||
>
|
||||
<BottomSheetView>
|
||||
<View className="flex flex-col space-y-4 px-4 pb-8 pt-2">
|
||||
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>
|
||||
<View>
|
||||
<Text className="font-bold text-2xl text-neutral-100">
|
||||
<Text className='font-bold text-2xl text-neutral-100'>
|
||||
{t("jellyseerr.whats_wrong")}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="flex flex-col space-y-2 items-start">
|
||||
<View className="flex flex-col">
|
||||
<View className='flex flex-col space-y-2 items-start'>
|
||||
<View className='flex flex-col'>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<View className="flex flex-col">
|
||||
<Text className="opacity-50 mb-1 text-xs">
|
||||
<View className='flex flex-col'>
|
||||
<Text className='opacity-50 mb-1 text-xs'>
|
||||
{t("jellyseerr.issue_type")}
|
||||
</Text>
|
||||
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between">
|
||||
<Text style={{}} className="" numberOfLines={1}>
|
||||
<TouchableOpacity className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
|
||||
<Text style={{}} className='' numberOfLines={1}>
|
||||
{issueType
|
||||
? IssueTypeName[issueType]
|
||||
: t("jellyseerr.select_an_issue")}
|
||||
@@ -325,8 +332,8 @@ const Page: React.FC = () => {
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
loop={false}
|
||||
side="bottom"
|
||||
align="center"
|
||||
side='bottom'
|
||||
align='center'
|
||||
alignOffset={0}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={0}
|
||||
@@ -353,14 +360,14 @@ const Page: React.FC = () => {
|
||||
</DropdownMenu.Root>
|
||||
</View>
|
||||
|
||||
<View className="p-4 border border-neutral-800 rounded-xl bg-neutral-900 w-full">
|
||||
<View className='p-4 border border-neutral-800 rounded-xl bg-neutral-900 w-full'>
|
||||
<BottomSheetTextInput
|
||||
multiline
|
||||
maxLength={254}
|
||||
style={{ color: "white" }}
|
||||
clearButtonMode="always"
|
||||
clearButtonMode='always'
|
||||
placeholder={t("jellyseerr.describe_the_issue")}
|
||||
placeholderTextColor="#9CA3AF"
|
||||
placeholderTextColor='#9CA3AF'
|
||||
// Issue with multiline + Textinput inside a portal
|
||||
// https://github.com/callstack/react-native-paper/issues/1668
|
||||
defaultValue={issueMessage}
|
||||
@@ -368,7 +375,7 @@ const Page: React.FC = () => {
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
<Button className="mt-auto" onPress={submitIssue} color="purple">
|
||||
<Button className='mt-auto' onPress={submitIssue} color='purple'>
|
||||
{t("jellyseerr.submit_button")}
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
@@ -1,25 +1,30 @@
|
||||
import {
|
||||
useLocalSearchParams,
|
||||
useSegments,
|
||||
} from "expo-router";
|
||||
import React, { useMemo } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Image } from "expo-image";
|
||||
import { OverviewText } from "@/components/OverviewText";
|
||||
import {orderBy, uniqBy} from "lodash";
|
||||
import { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow";
|
||||
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
|
||||
import {MovieResult, TvResult} from "@/utils/jellyseerr/server/models/Search";
|
||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import type { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person";
|
||||
import type {
|
||||
MovieResult,
|
||||
TvResult,
|
||||
} from "@/utils/jellyseerr/server/models/Search";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Image } from "expo-image";
|
||||
import { useLocalSearchParams, useSegments } from "expo-router";
|
||||
import { orderBy, uniqBy } from "lodash";
|
||||
import React, { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export default function page() {
|
||||
const local = useLocalSearchParams();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { jellyseerrApi, jellyseerrUser, jellyseerrRegion: region, jellyseerrLocale: locale } = useJellyseerr();
|
||||
const {
|
||||
jellyseerrApi,
|
||||
jellyseerrUser,
|
||||
jellyseerrRegion: region,
|
||||
jellyseerrLocale: locale,
|
||||
} = useJellyseerr();
|
||||
|
||||
const { personId } = local as { personId: string };
|
||||
|
||||
@@ -34,18 +39,27 @@ export default function page() {
|
||||
|
||||
const castedRoles: PersonCreditCast[] = useMemo(
|
||||
() =>
|
||||
uniqBy(orderBy(
|
||||
data?.combinedCredits?.cast,
|
||||
["voteCount", "voteAverage"],
|
||||
"desc"
|
||||
), 'id'),
|
||||
[data?.combinedCredits]
|
||||
uniqBy(
|
||||
orderBy(
|
||||
data?.combinedCredits?.cast,
|
||||
["voteCount", "voteAverage"],
|
||||
"desc",
|
||||
),
|
||||
"id",
|
||||
),
|
||||
[data?.combinedCredits],
|
||||
);
|
||||
const backdrops = useMemo(
|
||||
() => jellyseerrApi
|
||||
? castedRoles.map((c) => jellyseerrApi.imageProxy(c.backdropPath, "w1920_and_h800_multi_faces"))
|
||||
: [],
|
||||
[jellyseerrApi, data?.combinedCredits]
|
||||
() =>
|
||||
jellyseerrApi
|
||||
? castedRoles.map((c) =>
|
||||
jellyseerrApi.imageProxy(
|
||||
c.backdropPath,
|
||||
"w1920_and_h800_multi_faces",
|
||||
),
|
||||
)
|
||||
: [],
|
||||
[jellyseerrApi, data?.combinedCredits],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -58,15 +72,15 @@ export default function page() {
|
||||
<Image
|
||||
key={data?.details?.id}
|
||||
id={data?.details?.id.toString()}
|
||||
className="rounded-full bottom-1"
|
||||
className='rounded-full bottom-1'
|
||||
source={{
|
||||
uri: jellyseerrApi?.imageProxy(
|
||||
data?.details?.profilePath,
|
||||
"w600_and_h600_bestv2"
|
||||
"w600_and_h600_bestv2",
|
||||
),
|
||||
}}
|
||||
cachePolicy={"memory-disk"}
|
||||
contentFit="cover"
|
||||
contentFit='cover'
|
||||
style={{
|
||||
width: 125,
|
||||
height: 125,
|
||||
@@ -75,27 +89,27 @@ export default function page() {
|
||||
}
|
||||
HeaderContent={() => (
|
||||
<>
|
||||
<Text className="font-bold text-2xl mb-1">
|
||||
{data?.details?.name}
|
||||
</Text>
|
||||
<Text className="opacity-50">
|
||||
<Text className='font-bold text-2xl mb-1'>{data?.details?.name}</Text>
|
||||
<Text className='opacity-50'>
|
||||
{t("jellyseerr.born")}{" "}
|
||||
{new Date(data?.details?.birthday!!).toLocaleDateString(
|
||||
{new Date(data?.details?.birthday!).toLocaleDateString(
|
||||
`${locale}-${region}`,
|
||||
{
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
}
|
||||
},
|
||||
)}{" "}
|
||||
| {data?.details?.placeOfBirth}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
MainContent={() => (
|
||||
<OverviewText text={data?.details?.biography} className="mt-4" />
|
||||
<OverviewText text={data?.details?.biography} className='mt-4' />
|
||||
)}
|
||||
renderItem={(item, index) => (
|
||||
<JellyseerrPoster item={item as MovieResult | TvResult} />
|
||||
)}
|
||||
renderItem={(item, index) => <JellyseerrPoster item={item as MovieResult | TvResult} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,10 @@ import type {
|
||||
MaterialTopTabNavigationOptions,
|
||||
} from "@react-navigation/material-top-tabs";
|
||||
import { createMaterialTopTabNavigator } from "@react-navigation/material-top-tabs";
|
||||
import { ParamListBase, TabNavigationState } from "@react-navigation/native";
|
||||
import type {
|
||||
ParamListBase,
|
||||
TabNavigationState,
|
||||
} from "@react-navigation/native";
|
||||
import { Stack, withLayoutContext } from "expo-router";
|
||||
import React from "react";
|
||||
|
||||
@@ -21,8 +24,8 @@ const Layout = () => {
|
||||
<>
|
||||
<Stack.Screen options={{ title: "Live TV" }} />
|
||||
<Tab
|
||||
initialRouteName="programs"
|
||||
keyboardDismissMode="none"
|
||||
initialRouteName='programs'
|
||||
keyboardDismissMode='none'
|
||||
screenOptions={{
|
||||
tabBarBounces: true,
|
||||
tabBarLabelStyle: { fontSize: 10 },
|
||||
@@ -37,10 +40,10 @@ const Layout = () => {
|
||||
tabBarScrollEnabled: true,
|
||||
}}
|
||||
>
|
||||
<Tab.Screen name="programs" />
|
||||
<Tab.Screen name="guide" />
|
||||
<Tab.Screen name="channels" />
|
||||
<Tab.Screen name="recordings" />
|
||||
<Tab.Screen name='programs' />
|
||||
<Tab.Screen name='guide' />
|
||||
<Tab.Screen name='channels' />
|
||||
<Tab.Screen name='recordings' />
|
||||
</Tab>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -31,13 +31,13 @@ export default function page() {
|
||||
});
|
||||
|
||||
return (
|
||||
<View className="flex flex-1">
|
||||
<View className='flex flex-1'>
|
||||
<FlashList
|
||||
data={channels?.Items}
|
||||
estimatedItemSize={76}
|
||||
renderItem={({ item }) => (
|
||||
<View className="flex flex-row items-center px-4 mb-2">
|
||||
<View className="w-22 mr-4 rounded-lg overflow-hidden">
|
||||
<View className='flex flex-row items-center px-4 mb-2'>
|
||||
<View className='w-22 mr-4 rounded-lg overflow-hidden'>
|
||||
<ItemImage
|
||||
style={{
|
||||
aspectRatio: "1/1",
|
||||
@@ -47,7 +47,7 @@ export default function page() {
|
||||
item={item}
|
||||
/>
|
||||
</View>
|
||||
<Text className="font-bold">{item.Name}</Text>
|
||||
<Text className='font-bold'>{item.Name}</Text>
|
||||
</View>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Button,
|
||||
Dimensions,
|
||||
@@ -17,7 +18,6 @@ import {
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const HOUR_HEIGHT = 30;
|
||||
const ITEMS_PER_PAGE = 20;
|
||||
@@ -71,7 +71,7 @@ export default function page() {
|
||||
MaxStartDate: endOfDay.toISOString(),
|
||||
MinEndDate: isToday ? now.toISOString() : startOfDay.toISOString(),
|
||||
ChannelIds: channels?.Items?.map((c) => c.Id).filter(
|
||||
Boolean
|
||||
Boolean,
|
||||
) as string[],
|
||||
ImageTypeLimit: 1,
|
||||
EnableImages: false,
|
||||
@@ -100,7 +100,7 @@ export default function page() {
|
||||
return (
|
||||
<ScrollView
|
||||
nestedScrollEnabled
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
key={"home"}
|
||||
contentContainerStyle={{
|
||||
paddingLeft: insets.left,
|
||||
@@ -117,16 +117,16 @@ export default function page() {
|
||||
}
|
||||
/>
|
||||
|
||||
<View className="flex flex-row">
|
||||
<View className="flex flex-col w-[64px]">
|
||||
<View className='flex flex-row'>
|
||||
<View className='flex flex-col w-[64px]'>
|
||||
<View
|
||||
style={{
|
||||
height: HOUR_HEIGHT,
|
||||
}}
|
||||
className="bg-neutral-800"
|
||||
className='bg-neutral-800'
|
||||
></View>
|
||||
{channels?.Items?.map((c, i) => (
|
||||
<View className="h-16 w-16 mr-4 rounded-lg overflow-hidden" key={i}>
|
||||
<View className='h-16 w-16 mr-4 rounded-lg overflow-hidden' key={i}>
|
||||
<ItemImage
|
||||
style={{
|
||||
width: "100%",
|
||||
@@ -148,7 +148,7 @@ export default function page() {
|
||||
setScrollX(e.nativeEvent.contentOffset.x);
|
||||
}}
|
||||
>
|
||||
<View className="flex flex-col">
|
||||
<View className='flex flex-col'>
|
||||
<HourHeader height={HOUR_HEIGHT} />
|
||||
{channels?.Items?.map((c, i) => (
|
||||
<MemoizedLiveTVGuideRow
|
||||
@@ -180,14 +180,14 @@ const PageButtons: React.FC<PageButtonsProps> = ({
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<View className="flex flex-row justify-between items-center bg-neutral-800 w-full px-4 py-2">
|
||||
<View className='flex flex-row justify-between items-center bg-neutral-800 w-full px-4 py-2'>
|
||||
<TouchableOpacity
|
||||
onPress={onPrevPage}
|
||||
disabled={currentPage === 1}
|
||||
className="flex flex-row items-center"
|
||||
className='flex flex-row items-center'
|
||||
>
|
||||
<Ionicons
|
||||
name="chevron-back"
|
||||
name='chevron-back'
|
||||
size={24}
|
||||
color={currentPage === 1 ? "gray" : "white"}
|
||||
/>
|
||||
@@ -199,11 +199,11 @@ const PageButtons: React.FC<PageButtonsProps> = ({
|
||||
{t("live_tv.previous")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<Text className="text-white">Page {currentPage}</Text>
|
||||
<Text className='text-white'>Page {currentPage}</Text>
|
||||
<TouchableOpacity
|
||||
onPress={onNextPage}
|
||||
disabled={isNextDisabled}
|
||||
className="flex flex-row items-center"
|
||||
className='flex flex-row items-center'
|
||||
>
|
||||
<Text
|
||||
className={`mr-1 ${isNextDisabled ? "text-gray-500" : "text-white"}`}
|
||||
@@ -211,7 +211,7 @@ const PageButtons: React.FC<PageButtonsProps> = ({
|
||||
{t("live_tv.next")}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name="chevron-forward"
|
||||
name='chevron-forward'
|
||||
size={24}
|
||||
color={isNextDisabled ? "gray" : "white"}
|
||||
/>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList";
|
||||
import { TAB_HEIGHT } from "@/constants/Values";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useAtom } from "jotai";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ScrollView, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export default function page() {
|
||||
const [api] = useAtom(apiAtom);
|
||||
@@ -19,7 +19,7 @@ export default function page() {
|
||||
return (
|
||||
<ScrollView
|
||||
nestedScrollEnabled
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
key={"home"}
|
||||
contentContainerStyle={{
|
||||
paddingLeft: insets.left,
|
||||
@@ -28,7 +28,7 @@ export default function page() {
|
||||
paddingTop: 8,
|
||||
}}
|
||||
>
|
||||
<View className="flex flex-col space-y-2">
|
||||
<View className='flex flex-col space-y-2'>
|
||||
<ScrollingCollectionList
|
||||
queryKey={["livetv", "recommended"]}
|
||||
title={t("live_tv.on_now")}
|
||||
@@ -45,7 +45,7 @@ export default function page() {
|
||||
});
|
||||
return res.data.Items || [];
|
||||
}}
|
||||
orientation="horizontal"
|
||||
orientation='horizontal'
|
||||
/>
|
||||
<ScrollingCollectionList
|
||||
queryKey={["livetv", "shows"]}
|
||||
@@ -67,7 +67,7 @@ export default function page() {
|
||||
});
|
||||
return res.data.Items || [];
|
||||
}}
|
||||
orientation="horizontal"
|
||||
orientation='horizontal'
|
||||
/>
|
||||
<ScrollingCollectionList
|
||||
queryKey={["livetv", "movies"]}
|
||||
@@ -85,7 +85,7 @@ export default function page() {
|
||||
});
|
||||
return res.data.Items || [];
|
||||
}}
|
||||
orientation="horizontal"
|
||||
orientation='horizontal'
|
||||
/>
|
||||
<ScrollingCollectionList
|
||||
queryKey={["livetv", "sports"]}
|
||||
@@ -103,7 +103,7 @@ export default function page() {
|
||||
});
|
||||
return res.data.Items || [];
|
||||
}}
|
||||
orientation="horizontal"
|
||||
orientation='horizontal'
|
||||
/>
|
||||
<ScrollingCollectionList
|
||||
queryKey={["livetv", "kids"]}
|
||||
@@ -121,7 +121,7 @@ export default function page() {
|
||||
});
|
||||
return res.data.Items || [];
|
||||
}}
|
||||
orientation="horizontal"
|
||||
orientation='horizontal'
|
||||
/>
|
||||
<ScrollingCollectionList
|
||||
queryKey={["livetv", "news"]}
|
||||
@@ -139,7 +139,7 @@ export default function page() {
|
||||
});
|
||||
return res.data.Items || [];
|
||||
}}
|
||||
orientation="horizontal"
|
||||
orientation='horizontal'
|
||||
/>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Text } from "@/components/common/Text";
|
||||
import React from "react";
|
||||
import { View } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View } from "react-native";
|
||||
|
||||
export default function page() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<View className="flex items-center justify-center h-full -mt-12">
|
||||
<View className='flex items-center justify-center h-full -mt-12'>
|
||||
<Text>{t("live_tv.coming_soon")}</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -14,9 +14,10 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { Image } from "expo-image";
|
||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useEffect, useMemo } from "react";
|
||||
import { Platform, View } from "react-native";
|
||||
import type React from "react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, View } from "react-native";
|
||||
|
||||
const page: React.FC = () => {
|
||||
const navigation = useNavigation();
|
||||
@@ -49,7 +50,7 @@ const page: React.FC = () => {
|
||||
quality: 90,
|
||||
width: 1000,
|
||||
}),
|
||||
[item]
|
||||
[item],
|
||||
);
|
||||
|
||||
const logoUrl = useMemo(
|
||||
@@ -58,7 +59,7 @@ const page: React.FC = () => {
|
||||
api,
|
||||
item,
|
||||
}),
|
||||
[item]
|
||||
[item],
|
||||
);
|
||||
|
||||
const { data: allEpisodes, isLoading } = useQuery({
|
||||
@@ -83,22 +84,22 @@ const page: React.FC = () => {
|
||||
item &&
|
||||
allEpisodes &&
|
||||
allEpisodes.length > 0 && (
|
||||
<View className="flex flex-row items-center space-x-2">
|
||||
<View className='flex flex-row items-center space-x-2'>
|
||||
<AddToFavorites item={item} />
|
||||
{!Platform.isTV && (
|
||||
<>
|
||||
<DownloadItems
|
||||
size="large"
|
||||
size='large'
|
||||
title={t("item_card.download.download_series")}
|
||||
items={allEpisodes || []}
|
||||
MissingDownloadIconComponent={() => (
|
||||
<Ionicons name="download" size={22} color="white" />
|
||||
<Ionicons name='download' size={22} color='white' />
|
||||
)}
|
||||
DownloadedIconComponent={() => (
|
||||
<Ionicons
|
||||
name="checkmark-done-outline"
|
||||
name='checkmark-done-outline'
|
||||
size={24}
|
||||
color="#9333ea"
|
||||
color='#9333ea'
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
@@ -142,9 +143,9 @@ const page: React.FC = () => {
|
||||
</>
|
||||
}
|
||||
>
|
||||
<View className="flex flex-col pt-4">
|
||||
<View className='flex flex-col pt-4'>
|
||||
<SeriesHeader item={item} />
|
||||
<View className="mb-4">
|
||||
<View className='mb-4'>
|
||||
<NextUp seriesId={seriesId} />
|
||||
</View>
|
||||
<SeasonPicker item={item} initialSeasonIndex={Number(seasonIndex)} />
|
||||
|
||||
@@ -1,35 +1,35 @@
|
||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useCallback, useEffect, useMemo } from "react";
|
||||
import { FlatList, useWindowDimensions, View } from "react-native";
|
||||
import { FlatList, View, useWindowDimensions } from "react-native";
|
||||
|
||||
import { ItemCardText } from "@/components/ItemCardText";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||
import { FilterButton } from "@/components/filters/FilterButton";
|
||||
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
|
||||
import { ItemCardText } from "@/components/ItemCardText";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { ItemPoster } from "@/components/posters/ItemPoster";
|
||||
import { useOrientation } from "@/hooks/useOrientation";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import {
|
||||
SortByOption,
|
||||
SortOrderOption,
|
||||
genreFilterAtom,
|
||||
getSortByPreference,
|
||||
getSortOrderPreference,
|
||||
sortByAtom,
|
||||
SortByOption,
|
||||
sortByPreferenceAtom,
|
||||
sortOptions,
|
||||
sortOrderAtom,
|
||||
SortOrderOption,
|
||||
sortOrderOptions,
|
||||
sortOrderPreferenceAtom,
|
||||
tagsFilterAtom,
|
||||
yearFilterAtom,
|
||||
} from "@/utils/atoms/filters";
|
||||
import {
|
||||
import type {
|
||||
BaseItemDto,
|
||||
BaseItemDtoQueryResult,
|
||||
BaseItemKind,
|
||||
@@ -40,8 +40,8 @@ import {
|
||||
getUserLibraryApi,
|
||||
} from "@jellyfin/sdk/lib/utils/api";
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
const Page = () => {
|
||||
const searchParams = useLocalSearchParams();
|
||||
@@ -58,7 +58,7 @@ const Page = () => {
|
||||
const [sortOrder, _setSortOrder] = useAtom(sortOrderAtom);
|
||||
const [sortByPreference, setSortByPreference] = useAtom(sortByPreferenceAtom);
|
||||
const [sortOrderPreference, setOderByPreference] = useAtom(
|
||||
sortOrderPreferenceAtom
|
||||
sortOrderPreferenceAtom,
|
||||
);
|
||||
|
||||
const { orientation } = useOrientation();
|
||||
@@ -88,7 +88,7 @@ const Page = () => {
|
||||
}
|
||||
_setSortBy(sortBy);
|
||||
},
|
||||
[libraryId, sortByPreference]
|
||||
[libraryId, sortByPreference],
|
||||
);
|
||||
|
||||
const setSortOrder = useCallback(
|
||||
@@ -102,7 +102,7 @@ const Page = () => {
|
||||
}
|
||||
_setSortOrder(sortOrder);
|
||||
},
|
||||
[libraryId, sortOrderPreference]
|
||||
[libraryId, sortOrderPreference],
|
||||
);
|
||||
|
||||
const nrOfCols = useMemo(() => {
|
||||
@@ -169,7 +169,7 @@ const Page = () => {
|
||||
fields: ["PrimaryImageAspectRatio", "SortName"],
|
||||
genres: selectedGenres,
|
||||
tags: selectedTags,
|
||||
years: selectedYears.map((year) => parseInt(year)),
|
||||
years: selectedYears.map((year) => Number.parseInt(year)),
|
||||
includeItemTypes: itemType ? [itemType] : undefined,
|
||||
});
|
||||
|
||||
@@ -185,7 +185,7 @@ const Page = () => {
|
||||
selectedTags,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
]
|
||||
],
|
||||
);
|
||||
|
||||
const { data, isFetching, fetchNextPage, hasNextPage, isLoading } =
|
||||
@@ -211,7 +211,7 @@ const Page = () => {
|
||||
const totalItems = lastPage.TotalRecordCount;
|
||||
const accumulatedItems = pages.reduce(
|
||||
(acc, curr) => acc + (curr?.Items?.length || 0),
|
||||
0
|
||||
0,
|
||||
);
|
||||
|
||||
if (accumulatedItems < totalItems) {
|
||||
@@ -248,8 +248,8 @@ const Page = () => {
|
||||
? index % nrOfCols === 0
|
||||
? "flex-end"
|
||||
: (index + 1) % nrOfCols === 0
|
||||
? "flex-start"
|
||||
: "center"
|
||||
? "flex-start"
|
||||
: "center"
|
||||
: "center",
|
||||
width: "89%",
|
||||
}}
|
||||
@@ -260,14 +260,14 @@ const Page = () => {
|
||||
</View>
|
||||
</TouchableItemRouter>
|
||||
),
|
||||
[orientation]
|
||||
[orientation],
|
||||
);
|
||||
|
||||
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
|
||||
|
||||
const ListHeaderComponent = useCallback(
|
||||
() => (
|
||||
<View className="">
|
||||
<View className=''>
|
||||
<FlatList
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
@@ -286,13 +286,13 @@ const Page = () => {
|
||||
key: "genre",
|
||||
component: (
|
||||
<FilterButton
|
||||
className="mr-1"
|
||||
className='mr-1'
|
||||
collectionId={libraryId}
|
||||
queryKey="genreFilter"
|
||||
queryKey='genreFilter'
|
||||
queryFn={async () => {
|
||||
if (!api) return null;
|
||||
const response = await getFilterApi(
|
||||
api
|
||||
api,
|
||||
).getQueryFiltersLegacy({
|
||||
userId: user?.Id,
|
||||
parentId: libraryId,
|
||||
@@ -313,13 +313,13 @@ const Page = () => {
|
||||
key: "year",
|
||||
component: (
|
||||
<FilterButton
|
||||
className="mr-1"
|
||||
className='mr-1'
|
||||
collectionId={libraryId}
|
||||
queryKey="yearFilter"
|
||||
queryKey='yearFilter'
|
||||
queryFn={async () => {
|
||||
if (!api) return null;
|
||||
const response = await getFilterApi(
|
||||
api
|
||||
api,
|
||||
).getQueryFiltersLegacy({
|
||||
userId: user?.Id,
|
||||
parentId: libraryId,
|
||||
@@ -338,13 +338,13 @@ const Page = () => {
|
||||
key: "tags",
|
||||
component: (
|
||||
<FilterButton
|
||||
className="mr-1"
|
||||
className='mr-1'
|
||||
collectionId={libraryId}
|
||||
queryKey="tagsFilter"
|
||||
queryKey='tagsFilter'
|
||||
queryFn={async () => {
|
||||
if (!api) return null;
|
||||
const response = await getFilterApi(
|
||||
api
|
||||
api,
|
||||
).getQueryFiltersLegacy({
|
||||
userId: user?.Id,
|
||||
parentId: libraryId,
|
||||
@@ -365,9 +365,9 @@ const Page = () => {
|
||||
key: "sortBy",
|
||||
component: (
|
||||
<FilterButton
|
||||
className="mr-1"
|
||||
className='mr-1'
|
||||
collectionId={libraryId}
|
||||
queryKey="sortBy"
|
||||
queryKey='sortBy'
|
||||
queryFn={async () => sortOptions.map((s) => s.key)}
|
||||
set={setSortBy}
|
||||
values={sortBy}
|
||||
@@ -385,9 +385,9 @@ const Page = () => {
|
||||
key: "sortOrder",
|
||||
component: (
|
||||
<FilterButton
|
||||
className="mr-1"
|
||||
className='mr-1'
|
||||
collectionId={libraryId}
|
||||
queryKey="sortOrder"
|
||||
queryKey='sortOrder'
|
||||
queryFn={async () => sortOrderOptions.map((s) => s.key)}
|
||||
set={setSortOrder}
|
||||
values={sortOrder}
|
||||
@@ -422,22 +422,24 @@ const Page = () => {
|
||||
sortOrder,
|
||||
setSortOrder,
|
||||
isFetching,
|
||||
]
|
||||
],
|
||||
);
|
||||
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
if (isLoading || isLibraryLoading)
|
||||
return (
|
||||
<View className="w-full h-full flex items-center justify-center">
|
||||
<View className='w-full h-full flex items-center justify-center'>
|
||||
<Loader />
|
||||
</View>
|
||||
);
|
||||
|
||||
if (flatData.length === 0)
|
||||
return (
|
||||
<View className="h-full w-full flex justify-center items-center">
|
||||
<Text className="text-lg text-neutral-500">{t("library.no_items_found")}</Text>
|
||||
<View className='h-full w-full flex justify-center items-center'>
|
||||
<Text className='text-lg text-neutral-500'>
|
||||
{t("library.no_items_found")}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -445,11 +447,13 @@ const Page = () => {
|
||||
<FlashList
|
||||
key={orientation}
|
||||
ListEmptyComponent={
|
||||
<View className="flex flex-col items-center justify-center h-full">
|
||||
<Text className="font-bold text-xl text-neutral-500">{t("library.no_results")}</Text>
|
||||
<View className='flex flex-col items-center justify-center h-full'>
|
||||
<Text className='font-bold text-xl text-neutral-500'>
|
||||
{t("library.no_results")}
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
data={flatData}
|
||||
renderItem={renderItem}
|
||||
extraData={[orientation, nrOfCols]}
|
||||
|
||||
@@ -16,7 +16,7 @@ export default function IndexLayout() {
|
||||
return (
|
||||
<Stack>
|
||||
<Stack.Screen
|
||||
name="index"
|
||||
name='index'
|
||||
options={{
|
||||
headerShown: true,
|
||||
headerLargeTitle: true,
|
||||
@@ -33,9 +33,9 @@ export default function IndexLayout() {
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<Ionicons
|
||||
name="ellipsis-horizontal-outline"
|
||||
name='ellipsis-horizontal-outline'
|
||||
size={24}
|
||||
color="white"
|
||||
color='white'
|
||||
/>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
@@ -50,9 +50,9 @@ export default function IndexLayout() {
|
||||
<DropdownMenu.Label>
|
||||
{t("library.options.display")}
|
||||
</DropdownMenu.Label>
|
||||
<DropdownMenu.Group key="display-group">
|
||||
<DropdownMenu.Group key='display-group'>
|
||||
<DropdownMenu.Sub>
|
||||
<DropdownMenu.SubTrigger key="image-style-trigger">
|
||||
<DropdownMenu.SubTrigger key='image-style-trigger'>
|
||||
{t("library.options.display")}
|
||||
</DropdownMenu.SubTrigger>
|
||||
<DropdownMenu.SubContent
|
||||
@@ -63,7 +63,7 @@ export default function IndexLayout() {
|
||||
sideOffset={10}
|
||||
>
|
||||
<DropdownMenu.CheckboxItem
|
||||
key="display-option-1"
|
||||
key='display-option-1'
|
||||
value={settings.libraryOptions.display === "row"}
|
||||
onValueChange={() =>
|
||||
updateSettings({
|
||||
@@ -75,12 +75,12 @@ export default function IndexLayout() {
|
||||
}
|
||||
>
|
||||
<DropdownMenu.ItemIndicator />
|
||||
<DropdownMenu.ItemTitle key="display-title-1">
|
||||
<DropdownMenu.ItemTitle key='display-title-1'>
|
||||
{t("library.options.row")}
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.CheckboxItem>
|
||||
<DropdownMenu.CheckboxItem
|
||||
key="display-option-2"
|
||||
key='display-option-2'
|
||||
value={settings.libraryOptions.display === "list"}
|
||||
onValueChange={() =>
|
||||
updateSettings({
|
||||
@@ -92,14 +92,14 @@ export default function IndexLayout() {
|
||||
}
|
||||
>
|
||||
<DropdownMenu.ItemIndicator />
|
||||
<DropdownMenu.ItemTitle key="display-title-2">
|
||||
<DropdownMenu.ItemTitle key='display-title-2'>
|
||||
{t("library.options.list")}
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.CheckboxItem>
|
||||
</DropdownMenu.SubContent>
|
||||
</DropdownMenu.Sub>
|
||||
<DropdownMenu.Sub>
|
||||
<DropdownMenu.SubTrigger key="image-style-trigger">
|
||||
<DropdownMenu.SubTrigger key='image-style-trigger'>
|
||||
{t("library.options.image_style")}
|
||||
</DropdownMenu.SubTrigger>
|
||||
<DropdownMenu.SubContent
|
||||
@@ -110,7 +110,7 @@ export default function IndexLayout() {
|
||||
sideOffset={10}
|
||||
>
|
||||
<DropdownMenu.CheckboxItem
|
||||
key="poster-option"
|
||||
key='poster-option'
|
||||
value={
|
||||
settings.libraryOptions.imageStyle === "poster"
|
||||
}
|
||||
@@ -124,12 +124,12 @@ export default function IndexLayout() {
|
||||
}
|
||||
>
|
||||
<DropdownMenu.ItemIndicator />
|
||||
<DropdownMenu.ItemTitle key="poster-title">
|
||||
<DropdownMenu.ItemTitle key='poster-title'>
|
||||
{t("library.options.poster")}
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.CheckboxItem>
|
||||
<DropdownMenu.CheckboxItem
|
||||
key="cover-option"
|
||||
key='cover-option'
|
||||
value={settings.libraryOptions.imageStyle === "cover"}
|
||||
onValueChange={() =>
|
||||
updateSettings({
|
||||
@@ -141,17 +141,17 @@ export default function IndexLayout() {
|
||||
}
|
||||
>
|
||||
<DropdownMenu.ItemIndicator />
|
||||
<DropdownMenu.ItemTitle key="cover-title">
|
||||
<DropdownMenu.ItemTitle key='cover-title'>
|
||||
{t("library.options.cover")}
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.CheckboxItem>
|
||||
</DropdownMenu.SubContent>
|
||||
</DropdownMenu.Sub>
|
||||
</DropdownMenu.Group>
|
||||
<DropdownMenu.Group key="show-titles-group">
|
||||
<DropdownMenu.Group key='show-titles-group'>
|
||||
<DropdownMenu.CheckboxItem
|
||||
disabled={settings.libraryOptions.imageStyle === "poster"}
|
||||
key="show-titles-option"
|
||||
key='show-titles-option'
|
||||
value={settings.libraryOptions.showTitles}
|
||||
onValueChange={(newValue: string) => {
|
||||
if (settings.libraryOptions.imageStyle === "poster")
|
||||
@@ -165,12 +165,12 @@ export default function IndexLayout() {
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemIndicator />
|
||||
<DropdownMenu.ItemTitle key="show-titles-title">
|
||||
<DropdownMenu.ItemTitle key='show-titles-title'>
|
||||
{t("library.options.show_titles")}
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.CheckboxItem>
|
||||
<DropdownMenu.CheckboxItem
|
||||
key="show-stats-option"
|
||||
key='show-stats-option'
|
||||
value={settings.libraryOptions.showStats}
|
||||
onValueChange={(newValue: string) => {
|
||||
updateSettings({
|
||||
@@ -182,7 +182,7 @@ export default function IndexLayout() {
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemIndicator />
|
||||
<DropdownMenu.ItemTitle key="show-stats-title">
|
||||
<DropdownMenu.ItemTitle key='show-stats-title'>
|
||||
{t("library.options.show_stats")}
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.CheckboxItem>
|
||||
@@ -195,7 +195,7 @@ export default function IndexLayout() {
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="[libraryId]"
|
||||
name='[libraryId]'
|
||||
options={{
|
||||
title: "",
|
||||
headerShown: true,
|
||||
@@ -208,7 +208,7 @@ export default function IndexLayout() {
|
||||
<Stack.Screen key={name} name={name} options={options} />
|
||||
))}
|
||||
<Stack.Screen
|
||||
name="collections/[collectionId]"
|
||||
name='collections/[collectionId]'
|
||||
options={{
|
||||
title: "",
|
||||
headerShown: true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { LibraryItemCard } from "@/components/library/LibraryItemCard";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import {
|
||||
@@ -11,9 +11,9 @@ import { FlashList } from "@shopify/flash-list";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useAtom } from "jotai";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export default function index() {
|
||||
const [api] = useAtom(apiAtom);
|
||||
@@ -23,7 +23,7 @@ export default function index() {
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { data, isLoading: isLoading } = useQuery({
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["user-views", user?.Id],
|
||||
queryFn: async () => {
|
||||
const response = await getUserViewsApi(api!).getUserViews({
|
||||
@@ -41,7 +41,7 @@ export default function index() {
|
||||
?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!))
|
||||
.filter((l) => l.CollectionType !== "music")
|
||||
.filter((l) => l.CollectionType !== "books") || [],
|
||||
[data, settings?.hiddenLibraries]
|
||||
[data, settings?.hiddenLibraries],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -65,22 +65,24 @@ export default function index() {
|
||||
|
||||
if (isLoading)
|
||||
return (
|
||||
<View className="justify-center items-center h-full">
|
||||
<View className='justify-center items-center h-full'>
|
||||
<Loader />
|
||||
</View>
|
||||
);
|
||||
|
||||
if (!libraries)
|
||||
return (
|
||||
<View className="h-full w-full flex justify-center items-center">
|
||||
<Text className="text-lg text-neutral-500">{t("library.no_libraries_found")}</Text>
|
||||
<View className='h-full w-full flex justify-center items-center'>
|
||||
<Text className='text-lg text-neutral-500'>
|
||||
{t("library.no_libraries_found")}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<FlashList
|
||||
extraData={settings}
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
contentContainerStyle={{
|
||||
paddingTop: 17,
|
||||
paddingHorizontal: settings?.libraryOptions?.display === "row" ? 0 : 17,
|
||||
@@ -97,10 +99,10 @@ export default function index() {
|
||||
style={{
|
||||
height: StyleSheet.hairlineWidth,
|
||||
}}
|
||||
className="bg-neutral-800 mx-2 my-4"
|
||||
className='bg-neutral-800 mx-2 my-4'
|
||||
></View>
|
||||
) : (
|
||||
<View className="h-4" />
|
||||
<View className='h-4' />
|
||||
)
|
||||
}
|
||||
estimatedItemSize={200}
|
||||
|
||||
@@ -3,15 +3,15 @@ import {
|
||||
nestedTabPageScreenOptions,
|
||||
} from "@/components/stacks/NestedTabPageStack";
|
||||
import { Stack } from "expo-router";
|
||||
import { Platform } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform } from "react-native";
|
||||
|
||||
export default function SearchLayout() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Stack>
|
||||
<Stack.Screen
|
||||
name="index"
|
||||
name='index'
|
||||
options={{
|
||||
headerShown: true,
|
||||
headerLargeTitle: true,
|
||||
@@ -28,7 +28,7 @@ export default function SearchLayout() {
|
||||
<Stack.Screen key={name} name={name} options={options} />
|
||||
))}
|
||||
<Stack.Screen
|
||||
name="collections/[collectionId]"
|
||||
name='collections/[collectionId]'
|
||||
options={{
|
||||
title: "",
|
||||
headerShown: true,
|
||||
@@ -37,17 +37,17 @@ export default function SearchLayout() {
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen name="jellyseerr/page" options={commonScreenOptions} />
|
||||
<Stack.Screen name='jellyseerr/page' options={commonScreenOptions} />
|
||||
<Stack.Screen
|
||||
name="jellyseerr/person/[personId]"
|
||||
name='jellyseerr/person/[personId]'
|
||||
options={commonScreenOptions}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="jellyseerr/company/[companyId]"
|
||||
name='jellyseerr/company/[companyId]'
|
||||
options={commonScreenOptions}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="jellyseerr/genre/[genreId]"
|
||||
name='jellyseerr/genre/[genreId]'
|
||||
options={commonScreenOptions}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
@@ -1,30 +1,43 @@
|
||||
import {Text} from "@/components/common/Text";
|
||||
import {TouchableItemRouter} from "@/components/common/TouchableItemRouter";
|
||||
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
|
||||
import {Tag} from "@/components/GenreTags";
|
||||
import {ItemCardText} from "@/components/ItemCardText";
|
||||
import {JellyseerrSearchSort, JellyserrIndexPage} from "@/components/jellyseerr/JellyseerrIndexPage";
|
||||
import { Tag } from "@/components/GenreTags";
|
||||
import { ItemCardText } from "@/components/ItemCardText";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||
import { FilterButton } from "@/components/filters/FilterButton";
|
||||
import {
|
||||
JellyseerrSearchSort,
|
||||
JellyserrIndexPage,
|
||||
} from "@/components/jellyseerr/JellyseerrIndexPage";
|
||||
import MoviePoster from "@/components/posters/MoviePoster";
|
||||
import SeriesPoster from "@/components/posters/SeriesPoster";
|
||||
import {LoadingSkeleton} from "@/components/search/LoadingSkeleton";
|
||||
import {SearchItemWrapper} from "@/components/search/SearchItemWrapper";
|
||||
import {useJellyseerr} from "@/hooks/useJellyseerr";
|
||||
import {apiAtom, userAtom} from "@/providers/JellyfinProvider";
|
||||
import {useSettings} from "@/utils/atoms/settings";
|
||||
import {BaseItemDto, BaseItemKind,} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import {getItemsApi, getSearchApi} from "@jellyfin/sdk/lib/utils/api";
|
||||
import {useQuery} from "@tanstack/react-query";
|
||||
import { LoadingSkeleton } from "@/components/search/LoadingSkeleton";
|
||||
import { SearchItemWrapper } from "@/components/search/SearchItemWrapper";
|
||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { sortOrderOptions } from "@/utils/atoms/filters";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { eventBus } from "@/utils/eventBus";
|
||||
import type {
|
||||
BaseItemDto,
|
||||
BaseItemKind,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getItemsApi, getSearchApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import axios from "axios";
|
||||
import {router, useLocalSearchParams, useNavigation} from "expo-router";
|
||||
import {useAtom} from "jotai";
|
||||
import React, {useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState,} from "react";
|
||||
import {Platform, ScrollView, TouchableOpacity, View} from "react-native";
|
||||
import {useSafeAreaInsets} from "react-native-safe-area-context";
|
||||
import {useDebounce} from "use-debounce";
|
||||
import {useTranslation} from "react-i18next";
|
||||
import {eventBus} from "@/utils/eventBus";
|
||||
import {sortOrderOptions} from "@/utils/atoms/filters";
|
||||
import {FilterButton} from "@/components/filters/FilterButton";
|
||||
import { router, useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useDebounce } from "use-debounce";
|
||||
|
||||
type SearchType = "Library" | "Discover";
|
||||
|
||||
@@ -55,8 +68,15 @@ export default function search() {
|
||||
|
||||
const [settings] = useSettings();
|
||||
const { jellyseerrApi } = useJellyseerr();
|
||||
const [jellyseerrOrderBy, setJellyseerrOrderBy] = useState<JellyseerrSearchSort>(JellyseerrSearchSort[JellyseerrSearchSort.DEFAULT] as unknown as JellyseerrSearchSort)
|
||||
const [jellyseerrSortOrder, setJellyseerrSortOrder] = useState<"asc" | "desc">("desc")
|
||||
const [jellyseerrOrderBy, setJellyseerrOrderBy] =
|
||||
useState<JellyseerrSearchSort>(
|
||||
JellyseerrSearchSort[
|
||||
JellyseerrSearchSort.DEFAULT
|
||||
] as unknown as JellyseerrSearchSort,
|
||||
);
|
||||
const [jellyseerrSortOrder, setJellyseerrSortOrder] = useState<
|
||||
"asc" | "desc"
|
||||
>("desc");
|
||||
|
||||
const searchEngine = useMemo(() => {
|
||||
return settings?.searchEngine || "Jellyfin";
|
||||
@@ -112,7 +132,7 @@ export default function search() {
|
||||
return []; // Ensure an empty array is returned in case of an error
|
||||
}
|
||||
},
|
||||
[api, searchEngine, settings]
|
||||
[api, searchEngine, settings],
|
||||
);
|
||||
|
||||
type HeaderSearchBarRef = {
|
||||
@@ -220,26 +240,29 @@ export default function search() {
|
||||
return (
|
||||
<>
|
||||
<ScrollView
|
||||
keyboardDismissMode="on-drag"
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
keyboardDismissMode='on-drag'
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
contentContainerStyle={{
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
className="flex flex-col"
|
||||
className='flex flex-col'
|
||||
style={{
|
||||
marginTop: Platform.OS === "android" ? 16 : 0,
|
||||
}}
|
||||
>
|
||||
{jellyseerrApi && (
|
||||
<>
|
||||
<ScrollView horizontal className="flex flex-row flex-wrap space-x-2 px-4 mb-2">
|
||||
<ScrollView
|
||||
horizontal
|
||||
className='flex flex-row flex-wrap space-x-2 px-4 mb-2'
|
||||
>
|
||||
<TouchableOpacity onPress={() => setSearchType("Library")}>
|
||||
<Tag
|
||||
text={t("search.library")}
|
||||
textClass="p-1"
|
||||
textClass='p-1'
|
||||
className={
|
||||
searchType === "Library" ? "bg-purple-600" : undefined
|
||||
}
|
||||
@@ -248,41 +271,50 @@ export default function search() {
|
||||
<TouchableOpacity onPress={() => setSearchType("Discover")}>
|
||||
<Tag
|
||||
text={t("search.discover")}
|
||||
textClass="p-1"
|
||||
textClass='p-1'
|
||||
className={
|
||||
searchType === "Discover" ? "bg-purple-600" : undefined
|
||||
}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
{searchType === "Discover" && !loading && noResults && debouncedSearch.length > 0 && (
|
||||
<View className="flex flex-row justify-end items-center space-x-1">
|
||||
<FilterButton
|
||||
collectionId="search"
|
||||
queryKey="jellyseerr_search"
|
||||
queryFn={async () => Object.keys(JellyseerrSearchSort).filter(v => isNaN(Number(v)))}
|
||||
set={value => setJellyseerrOrderBy(value[0])}
|
||||
values={[jellyseerrOrderBy]}
|
||||
title={t("library.filters.sort_by")}
|
||||
renderItemLabel={(item) => t(`home.settings.plugins.jellyseerr.order_by.${item}`)}
|
||||
showSearch={false}
|
||||
/>
|
||||
<FilterButton
|
||||
collectionId="order"
|
||||
queryKey="jellysearr_search"
|
||||
queryFn={async () => ["asc", "desc"]}
|
||||
set={value => setJellyseerrSortOrder(value[0])}
|
||||
values={[jellyseerrSortOrder]}
|
||||
title={t("library.filters.sort_order")}
|
||||
renderItemLabel={(item) => t(`library.filters.${item}`)}
|
||||
showSearch={false}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
{searchType === "Discover" &&
|
||||
!loading &&
|
||||
noResults &&
|
||||
debouncedSearch.length > 0 && (
|
||||
<View className='flex flex-row justify-end items-center space-x-1'>
|
||||
<FilterButton
|
||||
collectionId='search'
|
||||
queryKey='jellyseerr_search'
|
||||
queryFn={async () =>
|
||||
Object.keys(JellyseerrSearchSort).filter((v) =>
|
||||
isNaN(Number(v)),
|
||||
)
|
||||
}
|
||||
set={(value) => setJellyseerrOrderBy(value[0])}
|
||||
values={[jellyseerrOrderBy]}
|
||||
title={t("library.filters.sort_by")}
|
||||
renderItemLabel={(item) =>
|
||||
t(`home.settings.plugins.jellyseerr.order_by.${item}`)
|
||||
}
|
||||
showSearch={false}
|
||||
/>
|
||||
<FilterButton
|
||||
collectionId='order'
|
||||
queryKey='jellysearr_search'
|
||||
queryFn={async () => ["asc", "desc"]}
|
||||
set={(value) => setJellyseerrSortOrder(value[0])}
|
||||
values={[jellyseerrSortOrder]}
|
||||
title={t("library.filters.sort_order")}
|
||||
renderItemLabel={(item) => t(`library.filters.${item}`)}
|
||||
showSearch={false}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
</>
|
||||
)}
|
||||
|
||||
<View className="mt-2">
|
||||
<View className='mt-2'>
|
||||
<LoadingSkeleton isLoading={loading} />
|
||||
</View>
|
||||
|
||||
@@ -294,14 +326,14 @@ export default function search() {
|
||||
renderItem={(item: BaseItemDto) => (
|
||||
<TouchableItemRouter
|
||||
key={item.Id}
|
||||
className="flex flex-col w-28 mr-2"
|
||||
className='flex flex-col w-28 mr-2'
|
||||
item={item}
|
||||
>
|
||||
<MoviePoster item={item} key={item.Id} />
|
||||
<Text numberOfLines={2} className="mt-2">
|
||||
<Text numberOfLines={2} className='mt-2'>
|
||||
{item.Name}
|
||||
</Text>
|
||||
<Text className="opacity-50 text-xs">
|
||||
<Text className='opacity-50 text-xs'>
|
||||
{item.ProductionYear}
|
||||
</Text>
|
||||
</TouchableItemRouter>
|
||||
@@ -314,13 +346,13 @@ export default function search() {
|
||||
<TouchableItemRouter
|
||||
key={item.Id}
|
||||
item={item}
|
||||
className="flex flex-col w-28 mr-2"
|
||||
className='flex flex-col w-28 mr-2'
|
||||
>
|
||||
<SeriesPoster item={item} key={item.Id} />
|
||||
<Text numberOfLines={2} className="mt-2">
|
||||
<Text numberOfLines={2} className='mt-2'>
|
||||
{item.Name}
|
||||
</Text>
|
||||
<Text className="opacity-50 text-xs">
|
||||
<Text className='opacity-50 text-xs'>
|
||||
{item.ProductionYear}
|
||||
</Text>
|
||||
</TouchableItemRouter>
|
||||
@@ -333,7 +365,7 @@ export default function search() {
|
||||
<TouchableItemRouter
|
||||
item={item}
|
||||
key={item.Id}
|
||||
className="flex flex-col w-44 mr-2"
|
||||
className='flex flex-col w-44 mr-2'
|
||||
>
|
||||
<ContinueWatchingPoster item={item} />
|
||||
<ItemCardText item={item} />
|
||||
@@ -347,10 +379,10 @@ export default function search() {
|
||||
<TouchableItemRouter
|
||||
key={item.Id}
|
||||
item={item}
|
||||
className="flex flex-col w-28 mr-2"
|
||||
className='flex flex-col w-28 mr-2'
|
||||
>
|
||||
<MoviePoster item={item} key={item.Id} />
|
||||
<Text numberOfLines={2} className="mt-2">
|
||||
<Text numberOfLines={2} className='mt-2'>
|
||||
{item.Name}
|
||||
</Text>
|
||||
</TouchableItemRouter>
|
||||
@@ -363,7 +395,7 @@ export default function search() {
|
||||
<TouchableItemRouter
|
||||
item={item}
|
||||
key={item.Id}
|
||||
className="flex flex-col w-28 mr-2"
|
||||
className='flex flex-col w-28 mr-2'
|
||||
>
|
||||
<MoviePoster item={item} />
|
||||
<ItemCardText item={item} />
|
||||
@@ -383,22 +415,22 @@ export default function search() {
|
||||
<>
|
||||
{!loading && noResults && debouncedSearch.length > 0 ? (
|
||||
<View>
|
||||
<Text className="text-center text-lg font-bold mt-4">
|
||||
<Text className='text-center text-lg font-bold mt-4'>
|
||||
{t("search.no_results_found_for")}
|
||||
</Text>
|
||||
<Text className="text-xs text-purple-600 text-center">
|
||||
<Text className='text-xs text-purple-600 text-center'>
|
||||
"{debouncedSearch}"
|
||||
</Text>
|
||||
</View>
|
||||
) : debouncedSearch.length === 0 ? (
|
||||
<View className="mt-4 flex flex-col items-center space-y-2">
|
||||
<View className='mt-4 flex flex-col items-center space-y-2'>
|
||||
{exampleSearches.map((e) => (
|
||||
<TouchableOpacity
|
||||
onPress={() => setSearch(e)}
|
||||
key={e}
|
||||
className="mb-2"
|
||||
className='mb-2'
|
||||
>
|
||||
<Text className="text-purple-600">{e}</Text>
|
||||
<Text className='text-purple-600'>{e}</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
import React, { useCallback, useRef } from "react";
|
||||
import { Platform } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform } from "react-native";
|
||||
|
||||
import { useFocusEffect, useRouter, withLayoutContext } from "expo-router";
|
||||
|
||||
import {
|
||||
type NativeBottomTabNavigationEventMap,
|
||||
createNativeBottomTabNavigator,
|
||||
NativeBottomTabNavigationEventMap,
|
||||
} from "@bottom-tabs/react-navigation";
|
||||
|
||||
const { Navigator } = createNativeBottomTabNavigator();
|
||||
import { BottomTabNavigationOptions } from "@react-navigation/bottom-tabs";
|
||||
import type { BottomTabNavigationOptions } from "@react-navigation/bottom-tabs";
|
||||
|
||||
import { Colors } from "@/constants/Colors";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { eventBus } from "@/utils/eventBus";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
import type {
|
||||
ParamListBase,
|
||||
TabNavigationState,
|
||||
} from "@react-navigation/native";
|
||||
import { SystemBars } from "react-native-edge-to-edge";
|
||||
import { eventBus } from "@/utils/eventBus";
|
||||
|
||||
export const NativeTabs = withLayoutContext<
|
||||
BottomTabNavigationOptions,
|
||||
@@ -46,12 +46,12 @@ export default function TabLayout() {
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}
|
||||
}, [])
|
||||
}, []),
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SystemBars hidden={false} style="light" />
|
||||
<SystemBars hidden={false} style='light' />
|
||||
<NativeTabs
|
||||
sidebarAdaptable={false}
|
||||
ignoresTopSafeArea
|
||||
@@ -59,16 +59,16 @@ export default function TabLayout() {
|
||||
backgroundColor: "#121212",
|
||||
}}
|
||||
tabBarActiveTintColor={Colors.primary}
|
||||
scrollEdgeAppearance="default"
|
||||
scrollEdgeAppearance='default'
|
||||
>
|
||||
<NativeTabs.Screen redirect name="index" />
|
||||
<NativeTabs.Screen redirect name='index' />
|
||||
<NativeTabs.Screen
|
||||
listeners={({ navigation }) => ({
|
||||
tabPress: (e) => {
|
||||
eventBus.emit("scrollToTop");
|
||||
},
|
||||
})}
|
||||
name="(home)"
|
||||
name='(home)'
|
||||
options={{
|
||||
title: t("tabs.home"),
|
||||
tabBarIcon:
|
||||
@@ -87,7 +87,7 @@ export default function TabLayout() {
|
||||
eventBus.emit("searchTabPressed");
|
||||
},
|
||||
})}
|
||||
name="(search)"
|
||||
name='(search)'
|
||||
options={{
|
||||
title: t("tabs.search"),
|
||||
tabBarIcon:
|
||||
@@ -101,7 +101,7 @@ export default function TabLayout() {
|
||||
}}
|
||||
/>
|
||||
<NativeTabs.Screen
|
||||
name="(favorites)"
|
||||
name='(favorites)'
|
||||
options={{
|
||||
title: t("tabs.favorites"),
|
||||
tabBarIcon:
|
||||
@@ -117,7 +117,7 @@ export default function TabLayout() {
|
||||
}}
|
||||
/>
|
||||
<NativeTabs.Screen
|
||||
name="(libraries)"
|
||||
name='(libraries)'
|
||||
options={{
|
||||
title: t("tabs.library"),
|
||||
tabBarIcon:
|
||||
@@ -131,7 +131,7 @@ export default function TabLayout() {
|
||||
}}
|
||||
/>
|
||||
<NativeTabs.Screen
|
||||
name="(custom-links)"
|
||||
name='(custom-links)'
|
||||
options={{
|
||||
title: t("tabs.custom_links"),
|
||||
// @ts-expect-error
|
||||
|
||||
@@ -21,7 +21,9 @@ export default function Layout() {
|
||||
if (settings.followDeviceOrientation === true) {
|
||||
ScreenOrientation.unlockAsync();
|
||||
} else {
|
||||
ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.PORTRAIT_UP);
|
||||
ScreenOrientation.lockAsync(
|
||||
ScreenOrientation.OrientationLock.PORTRAIT_UP,
|
||||
);
|
||||
}
|
||||
};
|
||||
});
|
||||
@@ -31,7 +33,7 @@ export default function Layout() {
|
||||
<SystemBars hidden />
|
||||
<Stack>
|
||||
<Stack.Screen
|
||||
name="direct-player"
|
||||
name='direct-player'
|
||||
options={{
|
||||
headerShown: false,
|
||||
autoHideHomeIndicator: true,
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { BITRATES } from "@/components/BitrateSelector";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Controls } from "@/components/video-player/controls/Controls";
|
||||
import { getDownloadedFileUrl } from "@/hooks/useDownloadedFileOpener";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||
import { useWebSocket } from "@/hooks/useWebsockets";
|
||||
import { VlcPlayerView } from "@/modules";
|
||||
import {
|
||||
import type {
|
||||
PipStartedPayload,
|
||||
PlaybackStatePayload,
|
||||
ProgressUpdatePayload,
|
||||
@@ -20,22 +20,33 @@ import { writeToLog } from "@/utils/log";
|
||||
import native from "@/utils/profiles/native";
|
||||
import { msToTicks, ticksToSeconds } from "@/utils/time";
|
||||
import {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
type BaseItemDto,
|
||||
type MediaSourceInfo,
|
||||
PlaybackOrder,
|
||||
PlaybackProgressInfo,
|
||||
type PlaybackProgressInfo,
|
||||
RepeatMode,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
import { getPlaystateApi, getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import {
|
||||
getPlaystateApi,
|
||||
getUserLibraryApi,
|
||||
} from "@jellyfin/sdk/lib/utils/api";
|
||||
import { activateKeepAwakeAsync, deactivateKeepAwake } from "expo-keep-awake";
|
||||
import { useGlobalSearchParams, useNavigation } from "expo-router";
|
||||
import { useAtomValue } from "jotai";
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Alert, Platform, View } from "react-native";
|
||||
import { useSharedValue } from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
const downloadProvider = !Platform.isTV ? require("@/providers/DownloadProvider") : null;
|
||||
const downloadProvider = !Platform.isTV
|
||||
? require("@/providers/DownloadProvider")
|
||||
: null;
|
||||
|
||||
export default function page() {
|
||||
const videoRef = useRef<VlcPlayerViewRef>(null);
|
||||
@@ -88,9 +99,15 @@ export default function page() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const offline = offlineStr === "true";
|
||||
|
||||
const audioIndex = audioIndexStr ? parseInt(audioIndexStr, 10) : undefined;
|
||||
const subtitleIndex = subtitleIndexStr ? parseInt(subtitleIndexStr, 10) : -1;
|
||||
const bitrateValue = bitrateValueStr ? parseInt(bitrateValueStr, 10) : BITRATES[0].value;
|
||||
const audioIndex = audioIndexStr
|
||||
? Number.parseInt(audioIndexStr, 10)
|
||||
: undefined;
|
||||
const subtitleIndex = subtitleIndexStr
|
||||
? Number.parseInt(subtitleIndexStr, 10)
|
||||
: -1;
|
||||
const bitrateValue = bitrateValueStr
|
||||
? Number.parseInt(bitrateValueStr, 10)
|
||||
: BITRATES[0].value;
|
||||
|
||||
const [item, setItem] = useState<BaseItemDto | null>(null);
|
||||
const [itemStatus, setItemStatus] = useState({
|
||||
@@ -165,7 +182,10 @@ export default function page() {
|
||||
if (!res) return;
|
||||
const { mediaSource, sessionId, url } = res;
|
||||
if (!sessionId || !mediaSource || !url) {
|
||||
Alert.alert(t("player.error"), t("player.failed_to_get_stream_url"));
|
||||
Alert.alert(
|
||||
t("player.error"),
|
||||
t("player.failed_to_get_stream_url"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
result = { mediaSource, sessionId, url };
|
||||
@@ -252,7 +272,17 @@ export default function page() {
|
||||
|
||||
reportPlaybackProgress();
|
||||
},
|
||||
[item?.Id, audioIndex, subtitleIndex, mediaSourceId, isPlaying, stream, isSeeking, isPlaybackStopped, isBuffering]
|
||||
[
|
||||
item?.Id,
|
||||
audioIndex,
|
||||
subtitleIndex,
|
||||
mediaSourceId,
|
||||
isPlaying,
|
||||
stream,
|
||||
isSeeking,
|
||||
isPlaybackStopped,
|
||||
isBuffering,
|
||||
],
|
||||
);
|
||||
|
||||
const onPipStarted = useCallback((e: PipStartedPayload) => {
|
||||
@@ -265,11 +295,23 @@ export default function page() {
|
||||
await getPlaystateApi(api).reportPlaybackProgress({
|
||||
playbackProgressInfo: currentPlayStateInfo() as PlaybackProgressInfo,
|
||||
});
|
||||
}, [api, isPlaying, offline, stream, item?.Id, audioIndex, subtitleIndex, mediaSourceId, progress]);
|
||||
}, [
|
||||
api,
|
||||
isPlaying,
|
||||
offline,
|
||||
stream,
|
||||
item?.Id,
|
||||
audioIndex,
|
||||
subtitleIndex,
|
||||
mediaSourceId,
|
||||
progress,
|
||||
]);
|
||||
|
||||
const startPosition = useMemo(() => {
|
||||
if (offline) return 0;
|
||||
return item?.UserData?.PlaybackPositionTicks ? ticksToSeconds(item.UserData.PlaybackPositionTicks) : 0;
|
||||
return item?.UserData?.PlaybackPositionTicks
|
||||
? ticksToSeconds(item.UserData.PlaybackPositionTicks)
|
||||
: 0;
|
||||
}, [item]);
|
||||
|
||||
useWebSocket({
|
||||
@@ -303,16 +345,19 @@ export default function page() {
|
||||
setIsBuffering(true);
|
||||
}
|
||||
},
|
||||
[reportPlaybackProgress]
|
||||
[reportPlaybackProgress],
|
||||
);
|
||||
|
||||
const allAudio = stream?.mediaSource.MediaStreams?.filter((audio) => audio.Type === "Audio") || [];
|
||||
const allAudio =
|
||||
stream?.mediaSource.MediaStreams?.filter(
|
||||
(audio) => audio.Type === "Audio",
|
||||
) || [];
|
||||
|
||||
// Move all the external subtitles last, because vlc places them last.
|
||||
const allSubs =
|
||||
stream?.mediaSource.MediaStreams?.filter((sub) => sub.Type === "Subtitle").sort(
|
||||
(a, b) => Number(a.IsExternal) - Number(b.IsExternal)
|
||||
) || [];
|
||||
stream?.mediaSource.MediaStreams?.filter(
|
||||
(sub) => sub.Type === "Subtitle",
|
||||
).sort((a, b) => Number(a.IsExternal) - Number(b.IsExternal)) || [];
|
||||
|
||||
const externalSubtitles = allSubs
|
||||
.filter((sub: any) => sub.DeliveryMethod === "External")
|
||||
@@ -323,13 +368,20 @@ export default function page() {
|
||||
|
||||
const textSubs = allSubs.filter((sub) => sub.IsTextSubtitleStream);
|
||||
|
||||
const chosenSubtitleTrack = allSubs.find((sub) => sub.Index === subtitleIndex);
|
||||
const chosenSubtitleTrack = allSubs.find(
|
||||
(sub) => sub.Index === subtitleIndex,
|
||||
);
|
||||
const chosenAudioTrack = allAudio.find((audio) => audio.Index === audioIndex);
|
||||
|
||||
const notTranscoding = !stream?.mediaSource.TranscodingUrl;
|
||||
let initOptions = [`--sub-text-scale=${settings.subtitleSize}`];
|
||||
if (chosenSubtitleTrack && (notTranscoding || chosenSubtitleTrack.IsTextSubtitleStream)) {
|
||||
const finalIndex = notTranscoding ? allSubs.indexOf(chosenSubtitleTrack) : textSubs.indexOf(chosenSubtitleTrack);
|
||||
const initOptions = [`--sub-text-scale=${settings.subtitleSize}`];
|
||||
if (
|
||||
chosenSubtitleTrack &&
|
||||
(notTranscoding || chosenSubtitleTrack.IsTextSubtitleStream)
|
||||
) {
|
||||
const finalIndex = notTranscoding
|
||||
? allSubs.indexOf(chosenSubtitleTrack)
|
||||
: textSubs.indexOf(chosenSubtitleTrack);
|
||||
initOptions.push(`--sub-track=${finalIndex}`);
|
||||
}
|
||||
|
||||
@@ -347,7 +399,7 @@ export default function page() {
|
||||
|
||||
if (itemStatus.isLoading || streamStatus.isLoading) {
|
||||
return (
|
||||
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
|
||||
<View className='w-screen h-screen flex flex-col items-center justify-center bg-black'>
|
||||
<Loader />
|
||||
</View>
|
||||
);
|
||||
@@ -355,8 +407,8 @@ export default function page() {
|
||||
|
||||
if (!item || !stream || itemStatus.isError || streamStatus.isError)
|
||||
return (
|
||||
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
|
||||
<Text className="text-white">{t("player.error")}</Text>
|
||||
<View className='w-screen h-screen flex flex-col items-center justify-center bg-black'>
|
||||
<Text className='text-white'>{t("player.error")}</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -394,7 +446,10 @@ export default function page() {
|
||||
}}
|
||||
onVideoError={(e) => {
|
||||
console.error("Video Error:", e.nativeEvent);
|
||||
Alert.alert(t("player.error"), t("player.an_error_occured_while_playing_the_video"));
|
||||
Alert.alert(
|
||||
t("player.error"),
|
||||
t("player.an_error_occured_while_playing_the_video"),
|
||||
);
|
||||
writeToLog("ERROR", "Video Error", e.nativeEvent);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ScrollViewStyleReset } from "expo-router/html";
|
||||
import { type PropsWithChildren } from "react";
|
||||
import type { PropsWithChildren } from "react";
|
||||
|
||||
/**
|
||||
* This file is web-only and used to configure the root HTML for every web page during static rendering.
|
||||
@@ -7,13 +7,13 @@ import { type PropsWithChildren } from "react";
|
||||
*/
|
||||
export default function Root({ children }: PropsWithChildren) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<html lang='en'>
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<meta httpEquiv="X-UA-Compatible" content="IE=edge" />
|
||||
<meta charSet='utf-8' />
|
||||
<meta httpEquiv='X-UA-Compatible' content='IE=edge' />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, shrink-to-fit=no"
|
||||
name='viewport'
|
||||
content='width=device-width, initial-scale=1, shrink-to-fit=no'
|
||||
/>
|
||||
|
||||
{/*
|
||||
|
||||
@@ -9,9 +9,9 @@ export default function NotFoundScreen() {
|
||||
<>
|
||||
<Stack.Screen options={{ title: "Oops!" }} />
|
||||
<ThemedView style={styles.container}>
|
||||
<ThemedText type="title">This screen doesn't exist.</ThemedText>
|
||||
<ThemedText type='title'>This screen doesn't exist.</ThemedText>
|
||||
<Link href={"/home"} style={styles.link}>
|
||||
<ThemedText type="link">Go to home screen!</ThemedText>
|
||||
<ThemedText type='link'>Go to home screen!</ThemedText>
|
||||
</Link>
|
||||
</ThemedView>
|
||||
</>
|
||||
|
||||
186
app/_layout.tsx
186
app/_layout.tsx
@@ -1,49 +1,61 @@
|
||||
import "@/augmentations";
|
||||
import { Platform } from "react-native";
|
||||
import i18n from "@/i18n";
|
||||
import { DownloadProvider } from "@/providers/DownloadProvider";
|
||||
import { getOrSetDeviceId, getTokenFromStorage, JellyfinProvider, apiAtom } from "@/providers/JellyfinProvider";
|
||||
import {
|
||||
JellyfinProvider,
|
||||
apiAtom,
|
||||
getOrSetDeviceId,
|
||||
getTokenFromStorage,
|
||||
} from "@/providers/JellyfinProvider";
|
||||
import { JobQueueProvider } from "@/providers/JobQueueProvider";
|
||||
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
|
||||
import { WebSocketProvider } from "@/providers/WebSocketProvider";
|
||||
import { Settings, useSettings } from "@/utils/atoms/settings";
|
||||
import { type Settings, useSettings } from "@/utils/atoms/settings";
|
||||
import {
|
||||
BACKGROUND_FETCH_TASK,
|
||||
BACKGROUND_FETCH_TASK_SESSIONS,
|
||||
registerBackgroundFetchAsyncSessions,
|
||||
} from "@/utils/background-tasks";
|
||||
import {LogProvider, writeErrorLog, writeToLog} from "@/utils/log";
|
||||
import { LogProvider, writeErrorLog, writeToLog } from "@/utils/log";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
import { cancelJobById, getAllJobsByDeviceId } from "@/utils/optimize-server";
|
||||
import { ActionSheetProvider } from "@expo/react-native-action-sheet";
|
||||
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
const BackGroundDownloader = !Platform.isTV ? require("@kesha-antonov/react-native-background-downloader") : null;
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { Platform } from "react-native";
|
||||
const BackGroundDownloader = !Platform.isTV
|
||||
? require("@kesha-antonov/react-native-background-downloader")
|
||||
: null;
|
||||
import { DarkTheme, ThemeProvider } from "@react-navigation/native";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
const BackgroundFetch = !Platform.isTV ? require("expo-background-fetch") : null;
|
||||
const BackgroundFetch = !Platform.isTV
|
||||
? require("expo-background-fetch")
|
||||
: null;
|
||||
import * as FileSystem from "expo-file-system";
|
||||
const Notifications = !Platform.isTV ? require("expo-notifications") : null;
|
||||
import { router, Stack, useSegments } from "expo-router";
|
||||
import * as SplashScreen from "expo-splash-screen";
|
||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||
import { Stack, router, useSegments } from "expo-router";
|
||||
import * as SplashScreen from "expo-splash-screen";
|
||||
const TaskManager = !Platform.isTV ? require("expo-task-manager") : null;
|
||||
import { getLocales } from "expo-localization";
|
||||
import { Provider as JotaiProvider } from "jotai";
|
||||
import {useEffect, useRef, useState} from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { I18nextProvider } from "react-i18next";
|
||||
import { Appearance, AppState } from "react-native";
|
||||
import { AppState, Appearance } from "react-native";
|
||||
import { SystemBars } from "react-native-edge-to-edge";
|
||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||
import "react-native-reanimated";
|
||||
import { Toaster } from "sonner-native";
|
||||
import { useAtom } from "jotai";
|
||||
import { userAtom } from "@/providers/JellyfinProvider";
|
||||
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
|
||||
import { store } from "@/utils/store";
|
||||
import {EventSubscription} from "expo-modules-core";
|
||||
import {ExpoPushToken} from "expo-notifications/build/Tokens.types";
|
||||
import {Notification, NotificationResponse} from "expo-notifications/build/Notifications.types";
|
||||
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
|
||||
import type { EventSubscription } from "expo-modules-core";
|
||||
import type {
|
||||
Notification,
|
||||
NotificationResponse,
|
||||
} from "expo-notifications/build/Notifications.types";
|
||||
import type { ExpoPushToken } from "expo-notifications/build/Tokens.types";
|
||||
import { useAtom } from "jotai";
|
||||
import { Toaster } from "sonner-native";
|
||||
|
||||
if (!Platform.isTV) {
|
||||
Notifications.setNotificationHandler({
|
||||
@@ -77,16 +89,20 @@ function useNotificationObserver() {
|
||||
}
|
||||
}
|
||||
|
||||
Notifications.getLastNotificationResponseAsync().then((response: { notification: any }) => {
|
||||
if (!isMounted || !response?.notification) {
|
||||
return;
|
||||
}
|
||||
redirect(response?.notification);
|
||||
});
|
||||
Notifications.getLastNotificationResponseAsync().then(
|
||||
(response: { notification: any }) => {
|
||||
if (!isMounted || !response?.notification) {
|
||||
return;
|
||||
}
|
||||
redirect(response?.notification);
|
||||
},
|
||||
);
|
||||
|
||||
const subscription = Notifications.addNotificationResponseReceivedListener((response: { notification: any }) => {
|
||||
redirect(response.notification);
|
||||
});
|
||||
const subscription = Notifications.addNotificationResponseReceivedListener(
|
||||
(response: { notification: any }) => {
|
||||
redirect(response.notification);
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
@@ -124,13 +140,15 @@ if (!Platform.isTV) {
|
||||
const settings: Partial<Settings> = JSON.parse(settingsData);
|
||||
const url = settings?.optimizedVersionsServerUrl;
|
||||
|
||||
if (!settings?.autoDownload || !url) return BackgroundFetch.BackgroundFetchResult.NoData;
|
||||
if (!settings?.autoDownload || !url)
|
||||
return BackgroundFetch.BackgroundFetchResult.NoData;
|
||||
|
||||
const token = getTokenFromStorage();
|
||||
const deviceId = getOrSetDeviceId();
|
||||
const baseDirectory = FileSystem.documentDirectory;
|
||||
|
||||
if (!token || !deviceId || !baseDirectory) return BackgroundFetch.BackgroundFetchResult.NoData;
|
||||
if (!token || !deviceId || !baseDirectory)
|
||||
return BackgroundFetch.BackgroundFetchResult.NoData;
|
||||
|
||||
const jobs = await getAllJobsByDeviceId({
|
||||
deviceId,
|
||||
@@ -140,7 +158,7 @@ if (!Platform.isTV) {
|
||||
|
||||
console.log("TaskManager ~ Active jobs: ", jobs.length);
|
||||
|
||||
for (let job of jobs) {
|
||||
for (const job of jobs) {
|
||||
if (job.status === "completed") {
|
||||
const downloadUrl = url + "download/" + job.id;
|
||||
const tasks = await BackGroundDownloader.checkForExistingDownloads();
|
||||
@@ -207,7 +225,9 @@ if (!Platform.isTV) {
|
||||
|
||||
const checkAndRequestPermissions = async () => {
|
||||
try {
|
||||
const hasAskedBefore = storage.getString("hasAskedForNotificationPermission");
|
||||
const hasAskedBefore = storage.getString(
|
||||
"hasAskedForNotificationPermission",
|
||||
);
|
||||
|
||||
if (hasAskedBefore !== "true") {
|
||||
const { status } = await Notifications.requestPermissionsAsync();
|
||||
@@ -225,7 +245,11 @@ const checkAndRequestPermissions = async () => {
|
||||
console.log("Already asked for notification permissions before.");
|
||||
}
|
||||
} catch (error) {
|
||||
writeToLog("ERROR", "Error checking/requesting notification permissions:", error);
|
||||
writeToLog(
|
||||
"ERROR",
|
||||
"Error checking/requesting notification permissions:",
|
||||
error,
|
||||
);
|
||||
console.error("Error checking/requesting notification permissions:", error);
|
||||
}
|
||||
};
|
||||
@@ -266,7 +290,9 @@ function Layout() {
|
||||
const segments = useSegments();
|
||||
|
||||
useEffect(() => {
|
||||
i18n.changeLanguage(settings?.preferedLanguage ?? getLocales()[0].languageCode ?? "en");
|
||||
i18n.changeLanguage(
|
||||
settings?.preferedLanguage ?? getLocales()[0].languageCode ?? "en",
|
||||
);
|
||||
}, [settings?.preferedLanguage, i18n]);
|
||||
|
||||
if (!Platform.isTV) {
|
||||
@@ -278,21 +304,24 @@ function Layout() {
|
||||
|
||||
useEffect(() => {
|
||||
if (expoPushToken && api && user) {
|
||||
api?.post("/Streamyfin/device", {
|
||||
token: expoPushToken.data,
|
||||
deviceId: getOrSetDeviceId(),
|
||||
userId: user.Id
|
||||
}).then(_ => console.log("Posted expo push token"))
|
||||
.catch(_ => writeErrorLog("Failed to push expo push token to plugin"))
|
||||
}
|
||||
else console.log("No token available")
|
||||
api
|
||||
?.post("/Streamyfin/device", {
|
||||
token: expoPushToken.data,
|
||||
deviceId: getOrSetDeviceId(),
|
||||
userId: user.Id,
|
||||
})
|
||||
.then((_) => console.log("Posted expo push token"))
|
||||
.catch((_) =>
|
||||
writeErrorLog("Failed to push expo push token to plugin"),
|
||||
);
|
||||
} else console.log("No token available");
|
||||
}, [api, expoPushToken, user]);
|
||||
|
||||
async function registerNotifications() {
|
||||
if (Platform.OS === 'android') {
|
||||
console.log("Setting android notification channel 'default'")
|
||||
await Notifications?.setNotificationChannelAsync('default', {
|
||||
name: 'default'
|
||||
if (Platform.OS === "android") {
|
||||
console.log("Setting android notification channel 'default'");
|
||||
await Notifications?.setNotificationChannelAsync("default", {
|
||||
name: "default",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -308,22 +337,35 @@ function Layout() {
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
registerNotifications()
|
||||
registerNotifications();
|
||||
|
||||
notificationListener.current = Notifications?.addNotificationReceivedListener((notification: Notification) => {
|
||||
console.log("Notification received while app running", notification);
|
||||
});
|
||||
notificationListener.current =
|
||||
Notifications?.addNotificationReceivedListener(
|
||||
(notification: Notification) => {
|
||||
console.log(
|
||||
"Notification received while app running",
|
||||
notification,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
responseListener.current = Notifications?.addNotificationResponseReceivedListener((response: NotificationResponse) => {
|
||||
console.log("Notification interacted with", response);
|
||||
});
|
||||
responseListener.current =
|
||||
Notifications?.addNotificationResponseReceivedListener(
|
||||
(response: NotificationResponse) => {
|
||||
console.log("Notification interacted with", response);
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
notificationListener.current &&
|
||||
Notifications?.removeNotificationSubscription(notificationListener.current);
|
||||
Notifications?.removeNotificationSubscription(
|
||||
notificationListener.current,
|
||||
);
|
||||
responseListener.current &&
|
||||
Notifications?.removeNotificationSubscription(responseListener.current);
|
||||
}
|
||||
Notifications?.removeNotificationSubscription(
|
||||
responseListener.current,
|
||||
);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -337,16 +379,24 @@ function Layout() {
|
||||
ScreenOrientation.unlockAsync();
|
||||
} else {
|
||||
// If the user has auto rotate disabled, lock the orientation to portrait
|
||||
ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.PORTRAIT_UP);
|
||||
ScreenOrientation.lockAsync(
|
||||
ScreenOrientation.OrientationLock.PORTRAIT_UP,
|
||||
);
|
||||
}
|
||||
}, [settings.followDeviceOrientation, segments]);
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = AppState.addEventListener("change", (nextAppState) => {
|
||||
if (appState.current.match(/inactive|background/) && nextAppState === "active") {
|
||||
BackGroundDownloader.checkForExistingDownloads();
|
||||
}
|
||||
});
|
||||
const subscription = AppState.addEventListener(
|
||||
"change",
|
||||
(nextAppState) => {
|
||||
if (
|
||||
appState.current.match(/inactive|background/) &&
|
||||
nextAppState === "active"
|
||||
) {
|
||||
BackGroundDownloader.checkForExistingDownloads();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
BackGroundDownloader.checkForExistingDownloads();
|
||||
|
||||
@@ -365,11 +415,11 @@ function Layout() {
|
||||
<WebSocketProvider>
|
||||
<DownloadProvider>
|
||||
<BottomSheetModalProvider>
|
||||
<SystemBars style="light" hidden={false} />
|
||||
<SystemBars style='light' hidden={false} />
|
||||
<ThemeProvider value={DarkTheme}>
|
||||
<Stack initialRouteName="(auth)/(tabs)">
|
||||
<Stack initialRouteName='(auth)/(tabs)'>
|
||||
<Stack.Screen
|
||||
name="(auth)/(tabs)"
|
||||
name='(auth)/(tabs)'
|
||||
options={{
|
||||
headerShown: false,
|
||||
title: "",
|
||||
@@ -377,7 +427,7 @@ function Layout() {
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(auth)/player"
|
||||
name='(auth)/player'
|
||||
options={{
|
||||
headerShown: false,
|
||||
title: "",
|
||||
@@ -385,14 +435,14 @@ function Layout() {
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="login"
|
||||
name='login'
|
||||
options={{
|
||||
headerShown: true,
|
||||
title: "",
|
||||
headerTransparent: true,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen name="+not-found" />
|
||||
<Stack.Screen name='+not-found' />
|
||||
</Stack>
|
||||
<Toaster
|
||||
duration={4000}
|
||||
@@ -423,7 +473,9 @@ function Layout() {
|
||||
function saveDownloadedItemInfo(item: BaseItemDto) {
|
||||
try {
|
||||
const downloadedItems = storage.getString("downloadedItems");
|
||||
let items: BaseItemDto[] = downloadedItems ? JSON.parse(downloadedItems) : [];
|
||||
const items: BaseItemDto[] = downloadedItems
|
||||
? JSON.parse(downloadedItems)
|
||||
: [];
|
||||
|
||||
const existingItemIndex = items.findIndex((i) => i.Id === item.Id);
|
||||
if (existingItemIndex !== -1) {
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import { Button } from "@/components/Button";
|
||||
import { Input } from "@/components/common/Input";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import JellyfinServerDiscovery from "@/components/JellyfinServerDiscovery";
|
||||
import { PreviousServersList } from "@/components/PreviousServersList";
|
||||
import { Input } from "@/components/common/Input";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
|
||||
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
||||
import { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
|
||||
import type { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { Image } from "expo-image";
|
||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import { useAtomValue } from "jotai";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import type React from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
KeyboardAvoidingView,
|
||||
@@ -21,8 +22,8 @@ import {
|
||||
} from "react-native";
|
||||
import { Keyboard } from "react-native";
|
||||
|
||||
import { z } from "zod";
|
||||
import { t } from "i18next";
|
||||
import { z } from "zod";
|
||||
const CredentialsSchema = z.object({
|
||||
username: z.string().min(1, t("login.username_required")),
|
||||
});
|
||||
@@ -81,10 +82,10 @@ const Login: React.FC = () => {
|
||||
onPress={() => {
|
||||
removeServer();
|
||||
}}
|
||||
className="flex flex-row items-center"
|
||||
className='flex flex-row items-center'
|
||||
>
|
||||
<Ionicons name="chevron-back" size={18} color={Colors.primary} />
|
||||
<Text className="ml-2 text-purple-600">
|
||||
<Ionicons name='chevron-back' size={18} color={Colors.primary} />
|
||||
<Text className='ml-2 text-purple-600'>
|
||||
{t("login.change_server")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
@@ -107,7 +108,7 @@ const Login: React.FC = () => {
|
||||
} else {
|
||||
Alert.alert(
|
||||
t("login.connection_failed"),
|
||||
t("login.an_unexpected_error_occured")
|
||||
t("login.an_unexpected_error_occured"),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
@@ -176,7 +177,7 @@ const Login: React.FC = () => {
|
||||
if (result === undefined) {
|
||||
Alert.alert(
|
||||
t("login.connection_failed"),
|
||||
t("login.could_not_connect_to_server")
|
||||
t("login.could_not_connect_to_server"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -195,13 +196,13 @@ const Login: React.FC = () => {
|
||||
{
|
||||
text: t("login.got_it"),
|
||||
},
|
||||
]
|
||||
],
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
Alert.alert(
|
||||
t("login.error_title"),
|
||||
t("login.failed_to_initiate_quick_connect")
|
||||
t("login.failed_to_initiate_quick_connect"),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -213,22 +214,22 @@ const Login: React.FC = () => {
|
||||
>
|
||||
{api?.basePath ? (
|
||||
<>
|
||||
<View className="flex flex-col h-full relative items-center justify-center">
|
||||
<View className="px-4 -mt-20 w-full">
|
||||
<View className="flex flex-col space-y-2">
|
||||
<Text className="text-2xl font-bold -mb-2">
|
||||
<View className='flex flex-col h-full relative items-center justify-center'>
|
||||
<View className='px-4 -mt-20 w-full'>
|
||||
<View className='flex flex-col space-y-2'>
|
||||
<Text className='text-2xl font-bold -mb-2'>
|
||||
<>
|
||||
{serverName ? (
|
||||
<>
|
||||
{t("login.login_to_title") + " "}
|
||||
<Text className="text-purple-600">{serverName}</Text>
|
||||
<Text className='text-purple-600'>{serverName}</Text>
|
||||
</>
|
||||
) : (
|
||||
t("login.login_title")
|
||||
)}
|
||||
</>
|
||||
</Text>
|
||||
<Text className="text-xs text-neutral-400">
|
||||
<Text className='text-xs text-neutral-400'>
|
||||
{api.basePath}
|
||||
</Text>
|
||||
<Input
|
||||
@@ -237,13 +238,13 @@ const Login: React.FC = () => {
|
||||
setCredentials({ ...credentials, username: text })
|
||||
}
|
||||
value={credentials.username}
|
||||
keyboardType="default"
|
||||
returnKeyType="done"
|
||||
autoCapitalize="none"
|
||||
keyboardType='default'
|
||||
returnKeyType='done'
|
||||
autoCapitalize='none'
|
||||
// Changed from username to oneTimeCode because it is a known issue in RN
|
||||
// https://github.com/facebook/react-native/issues/47106#issuecomment-2521270037
|
||||
textContentType="oneTimeCode"
|
||||
clearButtonMode="while-editing"
|
||||
textContentType='oneTimeCode'
|
||||
clearButtonMode='while-editing'
|
||||
maxLength={500}
|
||||
/>
|
||||
|
||||
@@ -254,42 +255,42 @@ const Login: React.FC = () => {
|
||||
}
|
||||
value={credentials.password}
|
||||
secureTextEntry
|
||||
keyboardType="default"
|
||||
returnKeyType="done"
|
||||
autoCapitalize="none"
|
||||
textContentType="password"
|
||||
clearButtonMode="while-editing"
|
||||
keyboardType='default'
|
||||
returnKeyType='done'
|
||||
autoCapitalize='none'
|
||||
textContentType='password'
|
||||
clearButtonMode='while-editing'
|
||||
maxLength={500}
|
||||
/>
|
||||
<View className="flex flex-row items-center justify-between">
|
||||
<View className='flex flex-row items-center justify-between'>
|
||||
<Button
|
||||
onPress={handleLogin}
|
||||
loading={loading}
|
||||
className="flex-1 mr-2"
|
||||
className='flex-1 mr-2'
|
||||
>
|
||||
{t("login.login_button")}
|
||||
</Button>
|
||||
<TouchableOpacity
|
||||
onPress={handleQuickConnect}
|
||||
className="p-2 bg-neutral-900 rounded-xl h-12 w-12 flex items-center justify-center"
|
||||
className='p-2 bg-neutral-900 rounded-xl h-12 w-12 flex items-center justify-center'
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name="cellphone-lock"
|
||||
name='cellphone-lock'
|
||||
size={24}
|
||||
color="white"
|
||||
color='white'
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="absolute bottom-0 left-0 w-full px-4 mb-2"></View>
|
||||
<View className='absolute bottom-0 left-0 w-full px-4 mb-2'></View>
|
||||
</View>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<View className="flex flex-col h-full items-center justify-center w-full">
|
||||
<View className="flex flex-col gap-y-2 px-4 w-full -mt-36">
|
||||
<View className='flex flex-col h-full items-center justify-center w-full'>
|
||||
<View className='flex flex-col gap-y-2 px-4 w-full -mt-36'>
|
||||
<Image
|
||||
style={{
|
||||
width: 100,
|
||||
@@ -299,19 +300,19 @@ const Login: React.FC = () => {
|
||||
}}
|
||||
source={require("@/assets/images/StreamyFinFinal.png")}
|
||||
/>
|
||||
<Text className="text-3xl font-bold">Streamyfin</Text>
|
||||
<Text className="text-neutral-500">
|
||||
<Text className='text-3xl font-bold'>Streamyfin</Text>
|
||||
<Text className='text-neutral-500'>
|
||||
{t("server.enter_url_to_jellyfin_server")}
|
||||
</Text>
|
||||
<Input
|
||||
aria-label="Server URL"
|
||||
aria-label='Server URL'
|
||||
placeholder={t("server.server_url_placeholder")}
|
||||
onChangeText={setServerURL}
|
||||
value={serverURL}
|
||||
keyboardType="url"
|
||||
returnKeyType="done"
|
||||
autoCapitalize="none"
|
||||
textContentType="URL"
|
||||
keyboardType='url'
|
||||
returnKeyType='done'
|
||||
autoCapitalize='none'
|
||||
textContentType='URL'
|
||||
maxLength={500}
|
||||
/>
|
||||
<Button
|
||||
@@ -320,7 +321,7 @@ const Login: React.FC = () => {
|
||||
onPress={async () => {
|
||||
await handleConnect(serverURL);
|
||||
}}
|
||||
className="w-full grow"
|
||||
className='w-full grow'
|
||||
>
|
||||
{t("server.connect_button")}
|
||||
</Button>
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
import { Api, AUTHORIZATION_HEADER } from "@jellyfin/sdk";
|
||||
import { AxiosRequestConfig, AxiosResponse } from "axios";
|
||||
import { StreamyfinPluginConfig } from "@/utils/atoms/settings";
|
||||
import type { StreamyfinPluginConfig } from "@/utils/atoms/settings";
|
||||
import { AUTHORIZATION_HEADER, Api } from "@jellyfin/sdk";
|
||||
import type { AxiosRequestConfig, AxiosResponse } from "axios";
|
||||
|
||||
declare module "@jellyfin/sdk" {
|
||||
interface Api {
|
||||
get<T, D = any>(
|
||||
url: string,
|
||||
config?: AxiosRequestConfig<D>
|
||||
config?: AxiosRequestConfig<D>,
|
||||
): Promise<AxiosResponse<T>>;
|
||||
post<T, D = any>(
|
||||
url: string,
|
||||
data: D,
|
||||
config?: AxiosRequestConfig<D>
|
||||
config?: AxiosRequestConfig<D>,
|
||||
): Promise<AxiosResponse<T>>;
|
||||
delete<T, D = any>(
|
||||
url: string,
|
||||
config?: AxiosRequestConfig<D>
|
||||
config?: AxiosRequestConfig<D>,
|
||||
): Promise<AxiosResponse<T>>;
|
||||
getStreamyfinPluginConfig(): Promise<AxiosResponse<StreamyfinPluginConfig>>;
|
||||
}
|
||||
@@ -23,7 +23,7 @@ declare module "@jellyfin/sdk" {
|
||||
|
||||
Api.prototype.get = function <T, D = any>(
|
||||
url: string,
|
||||
config: AxiosRequestConfig<D> = {}
|
||||
config: AxiosRequestConfig<D> = {},
|
||||
): Promise<AxiosResponse<T>> {
|
||||
return this.axiosInstance.get<T>(`${this.basePath}${url}`, {
|
||||
...(config ?? {}),
|
||||
@@ -34,7 +34,7 @@ Api.prototype.get = function <T, D = any>(
|
||||
Api.prototype.post = function <T, D = any>(
|
||||
url: string,
|
||||
data: D,
|
||||
config: AxiosRequestConfig<D>
|
||||
config: AxiosRequestConfig<D>,
|
||||
): Promise<AxiosResponse<T>> {
|
||||
return this.axiosInstance.post<T>(`${this.basePath}${url}`, data, {
|
||||
...(config || {}),
|
||||
@@ -44,7 +44,7 @@ Api.prototype.post = function <T, D = any>(
|
||||
|
||||
Api.prototype.delete = function <T, D = any>(
|
||||
url: string,
|
||||
config: AxiosRequestConfig<D>
|
||||
config: AxiosRequestConfig<D>,
|
||||
): Promise<AxiosResponse<T>> {
|
||||
return this.axiosInstance.delete<T>(`${this.basePath}${url}`, {
|
||||
...(config || {}),
|
||||
|
||||
@@ -1,22 +1,21 @@
|
||||
import {MMKV} from "react-native-mmkv";
|
||||
import { MMKV } from "react-native-mmkv";
|
||||
|
||||
declare module "react-native-mmkv" {
|
||||
interface MMKV {
|
||||
get<T>(key: string): T | undefined
|
||||
setAny(key: string, value: any | undefined): void
|
||||
get<T>(key: string): T | undefined;
|
||||
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);
|
||||
return serializedItem ? JSON.parse(serializedItem) : undefined;
|
||||
}
|
||||
};
|
||||
|
||||
MMKV.prototype.setAny = function (key: string, value: any | undefined): void {
|
||||
if (value === undefined) {
|
||||
this.delete(key)
|
||||
}
|
||||
else {
|
||||
this.delete(key);
|
||||
} else {
|
||||
this.set(key, JSON.stringify(value));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -7,17 +7,19 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
Number.prototype.bytesToReadable = function (decimals: number = 2) {
|
||||
Number.prototype.bytesToReadable = function (decimals = 2) {
|
||||
const bytes = this.valueOf();
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
if (bytes === 0) return "0 Bytes";
|
||||
|
||||
const k = 1024;
|
||||
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));
|
||||
|
||||
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 () {
|
||||
|
||||
@@ -5,12 +5,10 @@ declare global {
|
||||
}
|
||||
|
||||
String.prototype.toTitle = function () {
|
||||
return this
|
||||
.replaceAll("_", " ")
|
||||
.replace(
|
||||
/\w\S*/g,
|
||||
text => text.charAt(0).toUpperCase() + text.substring(1).toLowerCase()
|
||||
);
|
||||
}
|
||||
return this.replaceAll("_", " ").replace(
|
||||
/\w\S*/g,
|
||||
(text) => text.charAt(0).toUpperCase() + text.substring(1).toLowerCase(),
|
||||
);
|
||||
};
|
||||
|
||||
export {};
|
||||
export {};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
module.exports = function (api) {
|
||||
module.exports = (api) => {
|
||||
api.cache(true);
|
||||
return {
|
||||
presets: ["babel-preset-expo"],
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"style": {
|
||||
"useImportType": "off",
|
||||
"noNonNullAssertion": "off"
|
||||
},
|
||||
"recommended": true,
|
||||
"style": {
|
||||
"useImportType": "off",
|
||||
|
||||
@@ -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 {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 {
|
||||
item: BaseItemDto;
|
||||
}
|
||||
|
||||
export const AddToFavorites:FC<Props> = ({ item, ...props }) => {
|
||||
export const AddToFavorites: FC<Props> = ({ item, ...props }) => {
|
||||
const { isFavorite, toggleFavorite } = useFavorite(item);
|
||||
|
||||
return (
|
||||
<View {...props}>
|
||||
<RoundButton
|
||||
size="large"
|
||||
size='large'
|
||||
icon={isFavorite ? "heart" : "heart-outline"}
|
||||
fillColor={isFavorite ? "primary" : undefined}
|
||||
onPress={toggleFavorite}
|
||||
|
||||
@@ -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 { Platform, TouchableOpacity, View } from "react-native";
|
||||
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||
import { Text } from "./common/Text";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Text } from "./common/Text";
|
||||
|
||||
interface Props extends React.ComponentProps<typeof View> {
|
||||
source?: MediaSourceInfo;
|
||||
@@ -20,31 +20,31 @@ export const AudioTrackSelector: React.FC<Props> = ({
|
||||
if (Platform.isTV) return null;
|
||||
const audioStreams = useMemo(
|
||||
() => source?.MediaStreams?.filter((x) => x.Type === "Audio"),
|
||||
[source]
|
||||
[source],
|
||||
);
|
||||
|
||||
const selectedAudioSteam = useMemo(
|
||||
() => audioStreams?.find((x) => x.Index === selected),
|
||||
[audioStreams, selected]
|
||||
[audioStreams, selected],
|
||||
);
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<View
|
||||
className="flex shrink"
|
||||
className='flex shrink'
|
||||
style={{
|
||||
minWidth: 50,
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<View className="flex flex-col" {...props}>
|
||||
<Text className="opacity-50 mb-1 text-xs">
|
||||
<View className='flex flex-col' {...props}>
|
||||
<Text className='opacity-50 mb-1 text-xs'>
|
||||
{t("item_card.audio")}
|
||||
</Text>
|
||||
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between">
|
||||
<Text className="" numberOfLines={1}>
|
||||
<TouchableOpacity className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
|
||||
<Text className='' numberOfLines={1}>
|
||||
{selectedAudioSteam?.DisplayTitle}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
@@ -52,8 +52,8 @@ export const AudioTrackSelector: React.FC<Props> = ({
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
loop={true}
|
||||
side="bottom"
|
||||
align="start"
|
||||
side='bottom'
|
||||
align='start'
|
||||
alignOffset={0}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={8}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { View, ViewProps } from "react-native";
|
||||
import { View, type ViewProps } from "react-native";
|
||||
import { Text } from "./common/Text";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
@@ -22,7 +22,7 @@ export const Badge: React.FC<Props> = ({
|
||||
${variant === "gray" && "bg-neutral-800"}
|
||||
`}
|
||||
>
|
||||
{iconLeft && <View className="mr-1">{iconLeft}</View>}
|
||||
{iconLeft && <View className='mr-1'>{iconLeft}</View>}
|
||||
<Text
|
||||
className={`
|
||||
text-xs
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Platform, TouchableOpacity, View } from "react-native";
|
||||
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||
import { Text } from "./common/Text";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Text } from "./common/Text";
|
||||
|
||||
export type Bitrate = {
|
||||
key: string;
|
||||
@@ -40,7 +40,11 @@ export const BITRATES: Bitrate[] = [
|
||||
key: "250 Kb/s",
|
||||
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> {
|
||||
onChange: (value: Bitrate) => void;
|
||||
@@ -58,10 +62,14 @@ export const BitrateSelector: React.FC<Props> = ({
|
||||
const sorted = useMemo(() => {
|
||||
if (inverted)
|
||||
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(
|
||||
(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 (
|
||||
<View
|
||||
className="flex shrink"
|
||||
className='flex shrink'
|
||||
style={{
|
||||
minWidth: 60,
|
||||
maxWidth: 200,
|
||||
@@ -77,12 +85,12 @@ export const BitrateSelector: React.FC<Props> = ({
|
||||
>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<View className="flex flex-col" {...props}>
|
||||
<Text className="opacity-50 mb-1 text-xs">
|
||||
<View className='flex flex-col' {...props}>
|
||||
<Text className='opacity-50 mb-1 text-xs'>
|
||||
{t("item_card.quality")}
|
||||
</Text>
|
||||
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between">
|
||||
<Text style={{}} className="" numberOfLines={1}>
|
||||
<TouchableOpacity className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
|
||||
<Text style={{}} className='' numberOfLines={1}>
|
||||
{BITRATES.find((b) => b.value === selected?.value)?.key}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
@@ -90,8 +98,8 @@ export const BitrateSelector: React.FC<Props> = ({
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
loop={false}
|
||||
side="bottom"
|
||||
align="center"
|
||||
side='bottom'
|
||||
align='center'
|
||||
alignOffset={0}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={0}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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 { Loader } from "./Loader";
|
||||
|
||||
@@ -63,7 +64,7 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
|
||||
{...props}
|
||||
>
|
||||
{loading ? (
|
||||
<View className="p-0.5">
|
||||
<View className='p-0.5'>
|
||||
<Loader />
|
||||
</View>
|
||||
) : (
|
||||
@@ -72,7 +73,7 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
|
||||
flex flex-row items-center justify-between w-full
|
||||
${justify === "between" ? "justify-between" : "justify-center"}`}
|
||||
>
|
||||
{iconLeft ? iconLeft : <View className="w-4"></View>}
|
||||
{iconLeft ? iconLeft : <View className='w-4'></View>}
|
||||
<Text
|
||||
className={`
|
||||
text-white font-bold text-base
|
||||
@@ -84,7 +85,7 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
|
||||
>
|
||||
{children}
|
||||
</Text>
|
||||
{iconRight ? iconRight : <View className="w-4"></View>}
|
||||
{iconRight ? iconRight : <View className='w-4'></View>}
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Feather } from "@expo/vector-icons";
|
||||
import React, { useCallback, useEffect } from "react";
|
||||
import { Platform, TouchableOpacity, ViewProps } from "react-native";
|
||||
import { Platform, TouchableOpacity, type ViewProps } from "react-native";
|
||||
import GoogleCast, {
|
||||
CastButton,
|
||||
CastContext,
|
||||
@@ -45,18 +45,18 @@ export function Chromecast({
|
||||
const AndroidCastButton = useCallback(
|
||||
() =>
|
||||
Platform.OS === "android" ? (
|
||||
<CastButton tintColor="transparent" />
|
||||
<CastButton tintColor='transparent' />
|
||||
) : (
|
||||
<></>
|
||||
),
|
||||
[Platform.OS]
|
||||
[Platform.OS],
|
||||
);
|
||||
|
||||
if (background === "transparent")
|
||||
return (
|
||||
<RoundButton
|
||||
size="large"
|
||||
className="mr-2"
|
||||
size='large'
|
||||
className='mr-2'
|
||||
background={false}
|
||||
onPress={() => {
|
||||
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
|
||||
@@ -65,13 +65,13 @@ export function Chromecast({
|
||||
{...props}
|
||||
>
|
||||
<AndroidCastButton />
|
||||
<Feather name="cast" size={22} color={"white"} />
|
||||
<Feather name='cast' size={22} color={"white"} />
|
||||
</RoundButton>
|
||||
);
|
||||
|
||||
return (
|
||||
<RoundButton
|
||||
size="large"
|
||||
size='large'
|
||||
onPress={() => {
|
||||
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
|
||||
else CastContext.showCastDialog();
|
||||
@@ -79,7 +79,7 @@ export function Chromecast({
|
||||
{...props}
|
||||
>
|
||||
<AndroidCastButton />
|
||||
<Feather name="cast" size={22} color={"white"} />
|
||||
<Feather name='cast' size={22} color={"white"} />
|
||||
</RoundButton>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
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 { useAtomValue } from "jotai";
|
||||
import { useMemo } from "react";
|
||||
import type React from "react";
|
||||
import { View } from "react-native";
|
||||
import { WatchedIndicator } from "./WatchedIndicator";
|
||||
import React from "react";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
|
||||
type ContinueWatchingPosterProps = {
|
||||
item: BaseItemDto;
|
||||
@@ -71,7 +71,7 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
|
||||
|
||||
if (!url)
|
||||
return (
|
||||
<View className="aspect-video border border-neutral-800 w-44"></View>
|
||||
<View className='aspect-video border border-neutral-800 w-44'></View>
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -81,7 +81,7 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
|
||||
${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
|
||||
key={item.Id}
|
||||
id={item.Id}
|
||||
@@ -89,12 +89,12 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
|
||||
uri: url,
|
||||
}}
|
||||
cachePolicy={"memory-disk"}
|
||||
contentFit="cover"
|
||||
className="w-full h-full"
|
||||
contentFit='cover'
|
||||
className='w-full h-full'
|
||||
/>
|
||||
{showPlayButton && (
|
||||
<View className="absolute inset-0 flex items-center justify-center">
|
||||
<Ionicons name="play-circle" size={40} color="white" />
|
||||
<View className='absolute inset-0 flex items-center justify-center'>
|
||||
<Ionicons name='play-circle' size={40} color='white' />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
@@ -10,29 +10,30 @@ import download from "@/utils/profiles/download";
|
||||
import Ionicons from "@expo/vector-icons/Ionicons";
|
||||
import {
|
||||
BottomSheetBackdrop,
|
||||
BottomSheetBackdropProps,
|
||||
type BottomSheetBackdropProps,
|
||||
BottomSheetModal,
|
||||
BottomSheetView,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import {
|
||||
import type {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
} 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 React, { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { Alert, Platform, View, ViewProps } from "react-native";
|
||||
import type React from "react";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { Alert, Platform, View, type ViewProps } from "react-native";
|
||||
import { toast } from "sonner-native";
|
||||
import { AudioTrackSelector } from "./AudioTrackSelector";
|
||||
import { Bitrate, BitrateSelector } from "./BitrateSelector";
|
||||
import { type Bitrate, BitrateSelector } from "./BitrateSelector";
|
||||
import { Button } from "./Button";
|
||||
import { Text } from "./common/Text";
|
||||
import { Loader } from "./Loader";
|
||||
import { MediaSourceSelector } from "./MediaSourceSelector";
|
||||
import ProgressCircle from "./ProgressCircle";
|
||||
import { RoundButton } from "./RoundButton";
|
||||
import { SubtitleTrackSelector } from "./SubtitleTrackSelector";
|
||||
import { t } from "i18next";
|
||||
import { Text } from "./common/Text";
|
||||
|
||||
interface DownloadProps extends ViewProps {
|
||||
items: BaseItemDto[];
|
||||
@@ -70,16 +71,16 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
settings?.defaultBitrate ?? {
|
||||
key: "Max",
|
||||
value: undefined,
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const userCanDownload = useMemo(
|
||||
() => user?.Policy?.EnableContentDownloading,
|
||||
[user]
|
||||
[user],
|
||||
);
|
||||
const usingOptimizedServer = useMemo(
|
||||
() => settings?.downloadMethod === DownloadMethod.Optimized,
|
||||
[settings]
|
||||
[settings],
|
||||
);
|
||||
|
||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||
@@ -99,7 +100,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
const itemsNotDownloaded = useMemo(
|
||||
() =>
|
||||
items.filter((i) => !downloadedFiles?.some((f) => f.item.Id === i.Id)),
|
||||
[items, downloadedFiles]
|
||||
[items, downloadedFiles],
|
||||
);
|
||||
|
||||
const allItemsDownloaded = useMemo(() => {
|
||||
@@ -108,7 +109,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
}, [items, itemsNotDownloaded]);
|
||||
const itemsProcesses = useMemo(
|
||||
() => processes?.filter((p) => itemIds.includes(p.item.Id)),
|
||||
[processes, itemIds]
|
||||
[processes, itemIds],
|
||||
);
|
||||
|
||||
const progress = useMemo(() => {
|
||||
@@ -140,7 +141,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
params: {
|
||||
episodeSeasonIndex: firstItem.ParentIndexNumber,
|
||||
},
|
||||
} as Href)
|
||||
} as Href),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -160,12 +161,12 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
id: item.Id!,
|
||||
execute: async () => await initiateDownload(item),
|
||||
item,
|
||||
}))
|
||||
})),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
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)
|
||||
) {
|
||||
throw new Error(
|
||||
"DownloadItem ~ initiateDownload: No api or user or item"
|
||||
"DownloadItem ~ initiateDownload: No api or user or item",
|
||||
);
|
||||
}
|
||||
let mediaSource = selectedMediaSource;
|
||||
@@ -220,7 +221,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
if (!res) {
|
||||
Alert.alert(
|
||||
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;
|
||||
}
|
||||
@@ -250,7 +251,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
usingOptimizedServer,
|
||||
startBackgroundDownload,
|
||||
startRemuxing,
|
||||
]
|
||||
],
|
||||
);
|
||||
|
||||
const renderBackdrop = useCallback(
|
||||
@@ -261,7 +262,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
appearsOnIndex={0}
|
||||
/>
|
||||
),
|
||||
[]
|
||||
[],
|
||||
);
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
@@ -274,7 +275,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
setSelectedAudioStream(audioIndex ?? 0);
|
||||
setSelectedSubtitleStream(subtitleIndex ?? -1);
|
||||
setMaxBitrate(bitrate);
|
||||
}, [items, itemsNotDownloaded, settings])
|
||||
}, [items, itemsNotDownloaded, settings]),
|
||||
);
|
||||
|
||||
const renderButtonContent = () => {
|
||||
@@ -282,18 +283,18 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
return progress === 0 ? (
|
||||
<Loader />
|
||||
) : (
|
||||
<View className="-rotate-45">
|
||||
<View className='-rotate-45'>
|
||||
<ProgressCircle
|
||||
size={24}
|
||||
fill={progress}
|
||||
width={4}
|
||||
tintColor="#9334E9"
|
||||
backgroundColor="#bdc3c7"
|
||||
tintColor='#9334E9'
|
||||
backgroundColor='#bdc3c7'
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
} else if (itemsQueued) {
|
||||
return <Ionicons name="hourglass" size={24} color="white" />;
|
||||
return <Ionicons name='hourglass' size={24} color='white' />;
|
||||
} else if (allItemsDownloaded) {
|
||||
return <DownloadedIconComponent />;
|
||||
} else {
|
||||
@@ -331,19 +332,19 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
backdropComponent={renderBackdrop}
|
||||
>
|
||||
<BottomSheetView>
|
||||
<View className="flex flex-col space-y-4 px-4 pb-8 pt-2">
|
||||
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>
|
||||
<View>
|
||||
<Text className="font-bold text-2xl text-neutral-100">
|
||||
<Text className='font-bold text-2xl text-neutral-100'>
|
||||
{title}
|
||||
</Text>
|
||||
<Text className="text-neutral-300">
|
||||
<Text className='text-neutral-300'>
|
||||
{subtitle ||
|
||||
t("item_card.download.download_x_item", {
|
||||
item_count: itemsNotDownloaded.length,
|
||||
})}
|
||||
</Text>
|
||||
</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
|
||||
inverted
|
||||
onChange={setMaxBitrate}
|
||||
@@ -357,7 +358,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
selected={selectedMediaSource}
|
||||
/>
|
||||
{selectedMediaSource && (
|
||||
<View className="flex flex-col space-y-2">
|
||||
<View className='flex flex-col space-y-2'>
|
||||
<AudioTrackSelector
|
||||
source={selectedMediaSource}
|
||||
onChange={setSelectedAudioStream}
|
||||
@@ -374,14 +375,14 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
)}
|
||||
</View>
|
||||
<Button
|
||||
className="mt-auto"
|
||||
className='mt-auto'
|
||||
onPress={acceptDownloadOptions}
|
||||
color="purple"
|
||||
color='purple'
|
||||
>
|
||||
{t("item_card.download.download_button")}
|
||||
</Button>
|
||||
<View className="opacity-70 text-center w-full flex items-center">
|
||||
<Text className="text-xs">
|
||||
<View className='opacity-70 text-center w-full flex items-center'>
|
||||
<Text className='text-xs'>
|
||||
{usingOptimizedServer
|
||||
? t("item_card.download.using_optimized_server")
|
||||
: t("item_card.download.using_default_method")}
|
||||
@@ -411,10 +412,10 @@ export const DownloadSingleItem: React.FC<{
|
||||
subtitle={item.Name!}
|
||||
items={[item]}
|
||||
MissingDownloadIconComponent={() => (
|
||||
<Ionicons name="cloud-download-outline" size={24} color="white" />
|
||||
<Ionicons name='cloud-download-outline' size={24} color='white' />
|
||||
)}
|
||||
DownloadedIconComponent={() => (
|
||||
<Ionicons name="cloud-download" size={26} color="#9333ea" />
|
||||
<Ionicons name='cloud-download' size={26} color='#9333ea' />
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,49 +1,57 @@
|
||||
// GenreTags.tsx
|
||||
import React from "react";
|
||||
import {StyleProp, TextStyle, View, ViewProps} from "react-native";
|
||||
import type React from "react";
|
||||
import {
|
||||
type StyleProp,
|
||||
type TextStyle,
|
||||
View,
|
||||
type ViewProps,
|
||||
} from "react-native";
|
||||
import { Text } from "./common/Text";
|
||||
|
||||
interface TagProps {
|
||||
tags?: string[];
|
||||
textClass?: ViewProps["className"]
|
||||
textClass?: ViewProps["className"];
|
||||
}
|
||||
|
||||
export const Tag: React.FC<{ text: string, textClass?: ViewProps["className"], textStyle?: StyleProp<TextStyle>} & ViewProps> = ({
|
||||
text,
|
||||
textClass,
|
||||
textStyle,
|
||||
...props
|
||||
}) => {
|
||||
export const Tag: React.FC<
|
||||
{
|
||||
text: string;
|
||||
textClass?: ViewProps["className"];
|
||||
textStyle?: StyleProp<TextStyle>;
|
||||
} & ViewProps
|
||||
> = ({ text, textClass, textStyle, ...props }) => {
|
||||
return (
|
||||
<View className="bg-neutral-800 rounded-full px-2 py-1" {...props}>
|
||||
<Text className={textClass} style={textStyle}>{text}</Text>
|
||||
<View className='bg-neutral-800 rounded-full px-2 py-1' {...props}>
|
||||
<Text className={textClass} style={textStyle}>
|
||||
{text}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export const Tags: React.FC<TagProps & {tagProps?: ViewProps} & ViewProps> = ({
|
||||
tags,
|
||||
textClass = "text-xs",
|
||||
tagProps,
|
||||
...props
|
||||
}) => {
|
||||
export const Tags: React.FC<
|
||||
TagProps & { tagProps?: ViewProps } & ViewProps
|
||||
> = ({ tags, textClass = "text-xs", tagProps, ...props }) => {
|
||||
if (!tags || tags.length === 0) return null;
|
||||
|
||||
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) => (
|
||||
<View key={idx}>
|
||||
<Tag key={idx} textClass={textClass} text={tag} {...tagProps}/>
|
||||
<Tag key={idx} textClass={textClass} text={tag} {...tagProps} />
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export const GenreTags: React.FC<{ genres?: string[]}> = ({ genres }) => {
|
||||
export const GenreTags: React.FC<{ genres?: string[] }> = ({ genres }) => {
|
||||
return (
|
||||
<View className="mt-2">
|
||||
<Tags tags={genres}/>
|
||||
<View className='mt-2'>
|
||||
<Tags tags={genres} />
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 { Text } from "./common/Text";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { tc } from "@/utils/textTools";
|
||||
|
||||
type ItemCardProps = {
|
||||
item: BaseItemDto;
|
||||
@@ -10,13 +10,13 @@ type ItemCardProps = {
|
||||
|
||||
export const ItemCardText: React.FC<ItemCardProps> = ({ item }) => {
|
||||
return (
|
||||
<View className="mt-2 flex flex-col">
|
||||
<View className='mt-2 flex flex-col'>
|
||||
{item.Type === "Episode" ? (
|
||||
<>
|
||||
<Text numberOfLines={1} ellipsizeMode="tail" className="">
|
||||
<Text numberOfLines={1} ellipsizeMode='tail' className=''>
|
||||
{item.Name}
|
||||
</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()}`}
|
||||
{" - "}
|
||||
{item.SeriesName}
|
||||
@@ -24,8 +24,10 @@ export const ItemCardText: React.FC<ItemCardProps> = ({ item }) => {
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text numberOfLines={1} ellipsizeMode="tail">{item.Name}</Text>
|
||||
<Text className="text-xs opacity-50">{item.ProductionYear}</Text>
|
||||
<Text numberOfLines={1} ellipsizeMode='tail'>
|
||||
{item.Name}
|
||||
</Text>
|
||||
<Text className='text-xs opacity-50'>{item.ProductionYear}</Text>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { AudioTrackSelector } from "@/components/AudioTrackSelector";
|
||||
import { Bitrate, BitrateSelector } from "@/components/BitrateSelector";
|
||||
import { type Bitrate, BitrateSelector } from "@/components/BitrateSelector";
|
||||
import { DownloadSingleItem } from "@/components/DownloadItem";
|
||||
import { OverviewText } from "@/components/OverviewText";
|
||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||
@@ -19,7 +19,7 @@ import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||
import {
|
||||
import type {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
@@ -86,18 +86,18 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
||||
navigation.setOptions({
|
||||
headerRight: () =>
|
||||
item && (
|
||||
<View className="flex flex-row items-center space-x-2">
|
||||
<View className='flex flex-row items-center space-x-2'>
|
||||
<Chromecast.Chromecast
|
||||
background="blur"
|
||||
background='blur'
|
||||
width={22}
|
||||
height={22}
|
||||
/>
|
||||
{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 && (
|
||||
<DownloadSingleItem item={item} size="large" />
|
||||
<DownloadSingleItem item={item} size='large' />
|
||||
)}
|
||||
<PlayedStatus items={[item]} size="large" />
|
||||
<PlayedStatus items={[item]} size='large' />
|
||||
<AddToFavorites item={item} />
|
||||
</View>
|
||||
)}
|
||||
@@ -123,7 +123,7 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
||||
|
||||
return (
|
||||
<View
|
||||
className="flex-1 relative"
|
||||
className='flex-1 relative'
|
||||
style={{
|
||||
paddingLeft: insets.left,
|
||||
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 px-4 w-full space-y-2 pt-2 mb-2 shrink">
|
||||
<ItemHeader item={item} className="mb-4" />
|
||||
<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'>
|
||||
<ItemHeader item={item} className='mb-4' />
|
||||
{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
|
||||
className="mr-1"
|
||||
className='mr-1'
|
||||
onChange={(val) =>
|
||||
setSelectedOptions(
|
||||
(prev) => prev && { ...prev, bitrate: val }
|
||||
(prev) => prev && { ...prev, bitrate: val },
|
||||
)
|
||||
}
|
||||
selected={selectedOptions.bitrate}
|
||||
/>
|
||||
<MediaSourceSelector
|
||||
className="mr-1"
|
||||
className='mr-1'
|
||||
item={item}
|
||||
onChange={(val) =>
|
||||
setSelectedOptions(
|
||||
@@ -188,13 +188,13 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
||||
prev && {
|
||||
...prev,
|
||||
mediaSource: val,
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
selected={selectedOptions.mediaSource}
|
||||
/>
|
||||
<AudioTrackSelector
|
||||
className="mr-1"
|
||||
className='mr-1'
|
||||
source={selectedOptions.mediaSource}
|
||||
onChange={(val) => {
|
||||
setSelectedOptions(
|
||||
@@ -202,7 +202,7 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
||||
prev && {
|
||||
...prev,
|
||||
audioIndex: val,
|
||||
}
|
||||
},
|
||||
);
|
||||
}}
|
||||
selected={selectedOptions.audioIndex}
|
||||
@@ -215,7 +215,7 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
||||
prev && {
|
||||
...prev,
|
||||
subtitleIndex: val,
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
selected={selectedOptions.subtitleIndex}
|
||||
@@ -224,7 +224,7 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
||||
)}
|
||||
|
||||
<PlayButton
|
||||
className="grow"
|
||||
className='grow'
|
||||
selectedOptions={selectedOptions}
|
||||
item={item}
|
||||
/>
|
||||
@@ -235,24 +235,24 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
||||
)}
|
||||
|
||||
<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 === "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 && (
|
||||
<View className="mb-4">
|
||||
<View className='mb-4'>
|
||||
{item.People.slice(0, 3).map((person, idx) => (
|
||||
<MoreMoviesWithActor
|
||||
currentItem={item}
|
||||
key={idx}
|
||||
actorId={person.Id!}
|
||||
className="mb-4"
|
||||
className='mb-4'
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
@@ -265,5 +265,5 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
|
||||
</ParallaxScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import React from "react";
|
||||
import { View, ViewProps } from "react-native";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import type React from "react";
|
||||
import { View, type ViewProps } from "react-native";
|
||||
import { GenreTags } from "./GenreTags";
|
||||
import { MoviesTitleHeader } from "./movies/MoviesTitleHeader";
|
||||
import { Ratings } from "./Ratings";
|
||||
import { MoviesTitleHeader } from "./movies/MoviesTitleHeader";
|
||||
import { EpisodeTitleHeader } from "./series/EpisodeTitleHeader";
|
||||
import { ItemActions } from "./series/SeriesActions";
|
||||
|
||||
@@ -15,21 +15,21 @@ export const ItemHeader: React.FC<Props> = ({ item, ...props }) => {
|
||||
if (!item)
|
||||
return (
|
||||
<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}
|
||||
>
|
||||
<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-4 bg-neutral-900 rounded" />
|
||||
<View className="w-1/4 h-4 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-4 bg-neutral-900 rounded' />
|
||||
<View className='w-1/4 h-4 bg-neutral-900 rounded' />
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<View className="flex flex-col" {...props}>
|
||||
<View className="flex flex-col" {...props}>
|
||||
<View className="flex flex-row items-center justify-between">
|
||||
<Ratings item={item} className="mb-2" />
|
||||
<View className='flex flex-col' {...props}>
|
||||
<View className='flex flex-col' {...props}>
|
||||
<View className='flex flex-row items-center justify-between'>
|
||||
<Ratings item={item} className='mb-2' />
|
||||
<ItemActions item={item} />
|
||||
</View>
|
||||
{item.Type === "Episode" && (
|
||||
|
||||
@@ -1,22 +1,23 @@
|
||||
import { formatBitrate } from "@/utils/bitrate";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import {
|
||||
BottomSheetBackdrop,
|
||||
type BottomSheetBackdropProps,
|
||||
BottomSheetModal,
|
||||
BottomSheetScrollView,
|
||||
BottomSheetView,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import type {
|
||||
MediaSourceInfo,
|
||||
type MediaStream,
|
||||
MediaStream,
|
||||
} 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 { Badge } from "./Badge";
|
||||
import { Text } from "./common/Text";
|
||||
import {
|
||||
BottomSheetModal,
|
||||
BottomSheetBackdropProps,
|
||||
BottomSheetBackdrop,
|
||||
BottomSheetView,
|
||||
BottomSheetScrollView,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import { Button } from "./Button";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { formatBitrate } from "@/utils/bitrate";
|
||||
import { Text } from "./common/Text";
|
||||
|
||||
interface Props {
|
||||
source?: MediaSourceInfo;
|
||||
@@ -27,13 +28,13 @@ export const ItemTechnicalDetails: React.FC<Props> = ({ source, ...props }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<View className="px-4 mt-2 mb-4">
|
||||
<Text className="text-lg font-bold mb-4">{t("item_card.video")}</Text>
|
||||
<View className='px-4 mt-2 mb-4'>
|
||||
<Text className='text-lg font-bold mb-4'>{t("item_card.video")}</Text>
|
||||
<TouchableOpacity onPress={() => bottomSheetModalRef.current?.present()}>
|
||||
<View className="flex flex-row space-x-2">
|
||||
<View className='flex flex-row space-x-2'>
|
||||
<VideoStreamInfo source={source} />
|
||||
</View>
|
||||
<Text className="text-purple-600">{t("item_card.more_details")}</Text>
|
||||
<Text className='text-purple-600'>{t("item_card.more_details")}</Text>
|
||||
</TouchableOpacity>
|
||||
<BottomSheetModal
|
||||
ref={bottomSheetModalRef}
|
||||
@@ -53,37 +54,37 @@ export const ItemTechnicalDetails: React.FC<Props> = ({ source, ...props }) => {
|
||||
)}
|
||||
>
|
||||
<BottomSheetScrollView>
|
||||
<View className="flex flex-col space-y-2 p-4 mb-4">
|
||||
<View className="">
|
||||
<Text className="text-lg font-bold mb-4">
|
||||
<View className='flex flex-col space-y-2 p-4 mb-4'>
|
||||
<View className=''>
|
||||
<Text className='text-lg font-bold mb-4'>
|
||||
{t("item_card.video")}
|
||||
</Text>
|
||||
<View className="flex flex-row space-x-2">
|
||||
<View className='flex flex-row space-x-2'>
|
||||
<VideoStreamInfo source={source} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="">
|
||||
<Text className="text-lg font-bold mb-2">
|
||||
<View className=''>
|
||||
<Text className='text-lg font-bold mb-2'>
|
||||
{t("item_card.audio")}
|
||||
</Text>
|
||||
<AudioStreamInfo
|
||||
audioStreams={
|
||||
source?.MediaStreams?.filter(
|
||||
(stream) => stream.Type === "Audio"
|
||||
(stream) => stream.Type === "Audio",
|
||||
) || []
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className="">
|
||||
<Text className="text-lg font-bold mb-2">
|
||||
<View className=''>
|
||||
<Text className='text-lg font-bold mb-2'>
|
||||
{t("item_card.subtitles")}
|
||||
</Text>
|
||||
<SubtitleStreamInfo
|
||||
subtitleStreams={
|
||||
source?.MediaStreams?.filter(
|
||||
(stream) => stream.Type === "Subtitle"
|
||||
(stream) => stream.Type === "Subtitle",
|
||||
) || []
|
||||
}
|
||||
/>
|
||||
@@ -101,25 +102,25 @@ const SubtitleStreamInfo = ({
|
||||
subtitleStreams: MediaStream[];
|
||||
}) => {
|
||||
return (
|
||||
<View className="flex flex-col">
|
||||
<View className='flex flex-col'>
|
||||
{subtitleStreams.map((stream, index) => (
|
||||
<View key={stream.Index} className="flex flex-col">
|
||||
<Text className="text-xs mb-3 text-neutral-400">
|
||||
<View key={stream.Index} className='flex flex-col'>
|
||||
<Text className='text-xs mb-3 text-neutral-400'>
|
||||
{stream.DisplayTitle}
|
||||
</Text>
|
||||
<View className="flex flex-row flex-wrap gap-2">
|
||||
<View className='flex flex-row flex-wrap gap-2'>
|
||||
<Badge
|
||||
variant="gray"
|
||||
variant='gray'
|
||||
iconLeft={
|
||||
<Ionicons name="language-outline" size={16} color="white" />
|
||||
<Ionicons name='language-outline' size={16} color='white' />
|
||||
}
|
||||
text={stream.Language}
|
||||
/>
|
||||
<Badge
|
||||
variant="gray"
|
||||
variant='gray'
|
||||
text={stream.Codec}
|
||||
iconLeft={
|
||||
<Ionicons name="layers-outline" size={16} color="white" />
|
||||
<Ionicons name='layers-outline' size={16} color='white' />
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
@@ -131,40 +132,40 @@ const SubtitleStreamInfo = ({
|
||||
|
||||
const AudioStreamInfo = ({ audioStreams }: { audioStreams: MediaStream[] }) => {
|
||||
return (
|
||||
<View className="flex flex-col">
|
||||
<View className='flex flex-col'>
|
||||
{audioStreams.map((audioStreams, index) => (
|
||||
<View key={index} className="flex flex-col">
|
||||
<Text className="mb-3 text-neutral-400 text-xs">
|
||||
<View key={index} className='flex flex-col'>
|
||||
<Text className='mb-3 text-neutral-400 text-xs'>
|
||||
{audioStreams.DisplayTitle}
|
||||
</Text>
|
||||
<View className="flex-row flex-wrap gap-2">
|
||||
<View className='flex-row flex-wrap gap-2'>
|
||||
<Badge
|
||||
variant="gray"
|
||||
variant='gray'
|
||||
iconLeft={
|
||||
<Ionicons name="language-outline" size={16} color="white" />
|
||||
<Ionicons name='language-outline' size={16} color='white' />
|
||||
}
|
||||
text={audioStreams.Language}
|
||||
/>
|
||||
<Badge
|
||||
variant="gray"
|
||||
variant='gray'
|
||||
iconLeft={
|
||||
<Ionicons
|
||||
name="musical-notes-outline"
|
||||
name='musical-notes-outline'
|
||||
size={16}
|
||||
color="white"
|
||||
color='white'
|
||||
/>
|
||||
}
|
||||
text={audioStreams.Codec}
|
||||
/>
|
||||
<Badge
|
||||
variant="gray"
|
||||
iconLeft={<Ionicons name="mic-outline" size={16} color="white" />}
|
||||
variant='gray'
|
||||
iconLeft={<Ionicons name='mic-outline' size={16} color='white' />}
|
||||
text={audioStreams.ChannelLayout}
|
||||
/>
|
||||
<Badge
|
||||
variant="gray"
|
||||
variant='gray'
|
||||
iconLeft={
|
||||
<Ionicons name="speedometer-outline" size={16} color="white" />
|
||||
<Ionicons name='speedometer-outline' size={16} color='white' />
|
||||
}
|
||||
text={formatBitrate(audioStreams.BitRate)}
|
||||
/>
|
||||
@@ -180,48 +181,48 @@ const VideoStreamInfo = ({ source }: { source?: MediaSourceInfo }) => {
|
||||
|
||||
const videoStream = useMemo(() => {
|
||||
return source.MediaStreams?.find(
|
||||
(stream) => stream.Type === "Video"
|
||||
(stream) => stream.Type === "Video",
|
||||
) as MediaStream;
|
||||
}, [source.MediaStreams]);
|
||||
|
||||
if (!videoStream) return null;
|
||||
|
||||
return (
|
||||
<View className="flex-row flex-wrap gap-2">
|
||||
<View className='flex-row flex-wrap gap-2'>
|
||||
<Badge
|
||||
variant="gray"
|
||||
iconLeft={<Ionicons name="film-outline" size={16} color="white" />}
|
||||
variant='gray'
|
||||
iconLeft={<Ionicons name='film-outline' size={16} color='white' />}
|
||||
text={formatFileSize(source.Size)}
|
||||
/>
|
||||
<Badge
|
||||
variant="gray"
|
||||
iconLeft={<Ionicons name="film-outline" size={16} color="white" />}
|
||||
variant='gray'
|
||||
iconLeft={<Ionicons name='film-outline' size={16} color='white' />}
|
||||
text={`${videoStream.Width}x${videoStream.Height}`}
|
||||
/>
|
||||
<Badge
|
||||
variant="gray"
|
||||
variant='gray'
|
||||
iconLeft={
|
||||
<Ionicons name="color-palette-outline" size={16} color="white" />
|
||||
<Ionicons name='color-palette-outline' size={16} color='white' />
|
||||
}
|
||||
text={videoStream.VideoRange}
|
||||
/>
|
||||
<Badge
|
||||
variant="gray"
|
||||
variant='gray'
|
||||
iconLeft={
|
||||
<Ionicons name="code-working-outline" size={16} color="white" />
|
||||
<Ionicons name='code-working-outline' size={16} color='white' />
|
||||
}
|
||||
text={videoStream.Codec}
|
||||
/>
|
||||
<Badge
|
||||
variant="gray"
|
||||
variant='gray'
|
||||
iconLeft={
|
||||
<Ionicons name="speedometer-outline" size={16} color="white" />
|
||||
<Ionicons name='speedometer-outline' size={16} color='white' />
|
||||
}
|
||||
text={formatBitrate(videoStream.BitRate)}
|
||||
/>
|
||||
<Badge
|
||||
variant="gray"
|
||||
iconLeft={<Ionicons name="play-outline" size={16} color="white" />}
|
||||
variant='gray'
|
||||
iconLeft={<Ionicons name='play-outline' size={16} color='white' />}
|
||||
text={`${videoStream.AverageFrameRate?.toFixed(0)} fps`}
|
||||
/>
|
||||
</View>
|
||||
@@ -233,6 +234,8 @@ const formatFileSize = (bytes?: number | null) => {
|
||||
|
||||
const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
|
||||
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];
|
||||
};
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import React from "react";
|
||||
import { View, Text, TouchableOpacity } from "react-native";
|
||||
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 { ListGroup } from "./list/ListGroup";
|
||||
import { ListItem } from "./list/ListItem";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface Props {
|
||||
onServerSelect?: (server: { address: string; serverName?: string }) => void;
|
||||
@@ -15,15 +15,17 @@ const JellyfinServerDiscovery: React.FC<Props> = ({ onServerSelect }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<View className="mt-2">
|
||||
<Button onPress={startDiscovery} color="black">
|
||||
<Text className="text-white text-center">
|
||||
{isSearching ? t("server.searching") : t("server.search_for_local_servers")}
|
||||
<View className='mt-2'>
|
||||
<Button onPress={startDiscovery} color='black'>
|
||||
<Text className='text-white text-center'>
|
||||
{isSearching
|
||||
? t("server.searching")
|
||||
: t("server.search_for_local_servers")}
|
||||
</Text>
|
||||
</Button>
|
||||
|
||||
{servers.length ? (
|
||||
<ListGroup title={t("server.servers")} className="mt-4">
|
||||
<ListGroup title={t("server.servers")} className='mt-4'>
|
||||
{servers.map((server) => (
|
||||
<ListItem
|
||||
key={server.address}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {
|
||||
ActivityIndicator,
|
||||
ActivityIndicatorProps,
|
||||
type ActivityIndicatorProps,
|
||||
Platform,
|
||||
View,
|
||||
} from "react-native";
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import {
|
||||
import type {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useMemo } from "react";
|
||||
import { Platform, TouchableOpacity, View } from "react-native";
|
||||
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||
import { Text } from "./common/Text";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Text } from "./common/Text";
|
||||
|
||||
interface Props extends React.ComponentProps<typeof View> {
|
||||
item: BaseItemDto;
|
||||
@@ -24,9 +24,9 @@ export const MediaSourceSelector: React.FC<Props> = ({
|
||||
const selectedName = useMemo(
|
||||
() =>
|
||||
item.MediaSources?.find((x) => x.Id === selected?.Id)?.MediaStreams?.find(
|
||||
(x) => x.Type === "Video"
|
||||
(x) => x.Type === "Video",
|
||||
)?.DisplayTitle || "",
|
||||
[item, selected]
|
||||
[item, selected],
|
||||
);
|
||||
|
||||
const { t } = useTranslation();
|
||||
@@ -54,26 +54,26 @@ export const MediaSourceSelector: React.FC<Props> = ({
|
||||
|
||||
return (
|
||||
<View
|
||||
className="flex shrink"
|
||||
className='flex shrink'
|
||||
style={{
|
||||
minWidth: 50,
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<View className="flex flex-col" {...props}>
|
||||
<Text className="opacity-50 mb-1 text-xs">
|
||||
<View className='flex flex-col' {...props}>
|
||||
<Text className='opacity-50 mb-1 text-xs'>
|
||||
{t("item_card.video")}
|
||||
</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>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
loop={true}
|
||||
side="bottom"
|
||||
align="start"
|
||||
side='bottom'
|
||||
align='start'
|
||||
alignOffset={0}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={8}
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import React from "react";
|
||||
import { View, ViewProps } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { ItemCardText } from "@/components/ItemCardText";
|
||||
import { HorizontalScroll } from "@/components/common/HorrizontalScroll";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||
import MoviePoster from "@/components/posters/MoviePoster";
|
||||
import { ItemCardText } from "@/components/ItemCardText";
|
||||
import { useAtom } from "jotai";
|
||||
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 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 { View, type ViewProps } from "react-native";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
actorId: string;
|
||||
@@ -77,8 +77,8 @@ export const MoreMoviesWithActor: React.FC<Props> = ({
|
||||
|
||||
return (
|
||||
<View {...props}>
|
||||
<Text className="text-lg font-bold mb-2 px-4">
|
||||
{t("item_card.more_with", {name: actor?.Name})}
|
||||
<Text className='text-lg font-bold mb-2 px-4'>
|
||||
{t("item_card.more_with", { name: actor?.Name })}
|
||||
</Text>
|
||||
<HorizontalScroll
|
||||
data={items}
|
||||
@@ -88,7 +88,7 @@ export const MoreMoviesWithActor: React.FC<Props> = ({
|
||||
<TouchableItemRouter
|
||||
key={idx}
|
||||
item={item}
|
||||
className="flex flex-col w-28"
|
||||
className='flex flex-col w-28'
|
||||
>
|
||||
<View>
|
||||
<MoviePoster item={item} />
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { TouchableOpacity, View, ViewProps } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { tc } from "@/utils/textTools";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TouchableOpacity, View, type ViewProps } from "react-native";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
text?: string | null;
|
||||
@@ -20,20 +20,22 @@ export const OverviewText: React.FC<Props> = ({
|
||||
if (!text) return null;
|
||||
|
||||
return (
|
||||
<View className="flex flex-col" {...props}>
|
||||
<Text className="text-lg font-bold mb-2">{t("item_card.overview")}</Text>
|
||||
<View className='flex flex-col' {...props}>
|
||||
<Text className='text-lg font-bold mb-2'>{t("item_card.overview")}</Text>
|
||||
<TouchableOpacity
|
||||
onPress={() =>
|
||||
setLimit((prev) =>
|
||||
prev === characterLimit ? text.length : characterLimit
|
||||
prev === characterLimit ? text.length : characterLimit,
|
||||
)
|
||||
}
|
||||
>
|
||||
<View>
|
||||
<Text>{tc(text, limit)}</Text>
|
||||
{text.length > characterLimit && (
|
||||
<Text className="text-purple-600 mt-1">
|
||||
{limit === characterLimit ? t("item_card.show_more") : t("item_card.show_less")}
|
||||
<Text className='text-purple-600 mt-1'>
|
||||
{limit === characterLimit
|
||||
? t("item_card.show_more")
|
||||
: t("item_card.show_less")}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
import { type PropsWithChildren, type ReactElement } from "react";
|
||||
import {NativeScrollEvent, NativeSyntheticEvent, View, ViewProps} from "react-native";
|
||||
import type { PropsWithChildren, ReactElement } from "react";
|
||||
import {
|
||||
type NativeScrollEvent,
|
||||
NativeSyntheticEvent,
|
||||
View,
|
||||
type ViewProps,
|
||||
} from "react-native";
|
||||
import Animated, {
|
||||
interpolate,
|
||||
useAnimatedRef,
|
||||
@@ -35,36 +40,40 @@ export const ParallaxScrollView: React.FC<PropsWithChildren<Props>> = ({
|
||||
translateY: interpolate(
|
||||
scrollOffset.value,
|
||||
[-headerHeight, 0, headerHeight],
|
||||
[-headerHeight / 2, 0, headerHeight * 0.75]
|
||||
[-headerHeight / 2, 0, headerHeight * 0.75],
|
||||
),
|
||||
},
|
||||
{
|
||||
scale: interpolate(
|
||||
scrollOffset.value,
|
||||
[-headerHeight, 0, headerHeight],
|
||||
[2, 1, 1]
|
||||
[2, 1, 1],
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
function isCloseToBottom({layoutMeasurement, contentOffset, contentSize}: NativeScrollEvent) {
|
||||
return layoutMeasurement.height + contentOffset.y >= contentSize.height - 20;
|
||||
function isCloseToBottom({
|
||||
layoutMeasurement,
|
||||
contentOffset,
|
||||
contentSize,
|
||||
}: NativeScrollEvent) {
|
||||
return (
|
||||
layoutMeasurement.height + contentOffset.y >= contentSize.height - 20
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View className="flex-1" {...props}>
|
||||
<View className='flex-1' {...props}>
|
||||
<Animated.ScrollView
|
||||
style={{
|
||||
position: "relative",
|
||||
}}
|
||||
ref={scrollRef}
|
||||
scrollEventThrottle={16}
|
||||
onScroll={e => {
|
||||
if (isCloseToBottom(e.nativeEvent))
|
||||
onEndReached?.()
|
||||
onScroll={(e) => {
|
||||
if (isCloseToBottom(e.nativeEvent)) onEndReached?.();
|
||||
}}
|
||||
>
|
||||
{logo && (
|
||||
@@ -73,7 +82,7 @@ export const ParallaxScrollView: React.FC<PropsWithChildren<Props>> = ({
|
||||
top: headerHeight - 200,
|
||||
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}
|
||||
</View>
|
||||
@@ -95,7 +104,7 @@ export const ParallaxScrollView: React.FC<PropsWithChildren<Props>> = ({
|
||||
style={{
|
||||
top: -50,
|
||||
}}
|
||||
className="relative flex-1 bg-transparent pb-24"
|
||||
className='relative flex-1 bg-transparent pb-24'
|
||||
>
|
||||
<LinearGradient
|
||||
// Background Linear Gradient
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { BlurView } from "expo-blur";
|
||||
import React from "react";
|
||||
import { Platform, View, ViewProps } from "react-native";
|
||||
import type React from "react";
|
||||
import { Platform, View, type ViewProps } from "react-native";
|
||||
interface Props extends ViewProps {
|
||||
blurAmount?: number;
|
||||
blurType?: "light" | "dark" | "xlight";
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
import { Platform, Pressable } from "react-native";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
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 { runtimeTicksToMinutes } from "@/utils/time";
|
||||
import { useActionSheet } from "@expo/react-native-action-sheet";
|
||||
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 { useAtom, useAtomValue } from "jotai";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, Pressable } from "react-native";
|
||||
import { Alert, TouchableOpacity, View } from "react-native";
|
||||
import CastContext, {
|
||||
CastButton,
|
||||
@@ -30,12 +34,8 @@ import Animated, {
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import { Button } from "./Button";
|
||||
import { 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";
|
||||
import type { Button } from "./Button";
|
||||
import type { SelectedOptions } from "./ItemContent";
|
||||
|
||||
interface Props extends React.ComponentProps<typeof Button> {
|
||||
item: BaseItemDto;
|
||||
@@ -74,7 +74,7 @@ export const PlayButton: React.FC<Props> = ({
|
||||
(q: string) => {
|
||||
router.push(`/player/direct-player?${q}`);
|
||||
},
|
||||
[router]
|
||||
[router],
|
||||
);
|
||||
|
||||
const onPress = useCallback(async () => {
|
||||
@@ -140,7 +140,7 @@ export const PlayButton: React.FC<Props> = ({
|
||||
console.warn("No URL returned from getStreamUrl", data);
|
||||
Alert.alert(
|
||||
t("player.client_error"),
|
||||
t("player.could_not_create_stream_for_chromecast")
|
||||
t("player.could_not_create_stream_for_chromecast"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -170,36 +170,36 @@ export const PlayButton: React.FC<Props> = ({
|
||||
],
|
||||
}
|
||||
: item.Type === "Movie"
|
||||
? {
|
||||
type: "movie",
|
||||
title: item.Name || "",
|
||||
subtitle: item.Overview || "",
|
||||
images: [
|
||||
{
|
||||
url: getPrimaryImageUrl({
|
||||
api,
|
||||
item,
|
||||
quality: 90,
|
||||
width: 2000,
|
||||
})!,
|
||||
},
|
||||
],
|
||||
}
|
||||
: {
|
||||
type: "generic",
|
||||
title: item.Name || "",
|
||||
subtitle: item.Overview || "",
|
||||
images: [
|
||||
{
|
||||
url: getPrimaryImageUrl({
|
||||
api,
|
||||
item,
|
||||
quality: 90,
|
||||
width: 2000,
|
||||
})!,
|
||||
},
|
||||
],
|
||||
},
|
||||
? {
|
||||
type: "movie",
|
||||
title: item.Name || "",
|
||||
subtitle: item.Overview || "",
|
||||
images: [
|
||||
{
|
||||
url: getPrimaryImageUrl({
|
||||
api,
|
||||
item,
|
||||
quality: 90,
|
||||
width: 2000,
|
||||
})!,
|
||||
},
|
||||
],
|
||||
}
|
||||
: {
|
||||
type: "generic",
|
||||
title: item.Name || "",
|
||||
subtitle: item.Overview || "",
|
||||
images: [
|
||||
{
|
||||
url: getPrimaryImageUrl({
|
||||
api,
|
||||
item,
|
||||
quality: 90,
|
||||
width: 2000,
|
||||
})!,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
startTime: 0,
|
||||
})
|
||||
@@ -222,7 +222,7 @@ export const PlayButton: React.FC<Props> = ({
|
||||
case cancelButtonIndex:
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}, [
|
||||
item,
|
||||
@@ -243,7 +243,7 @@ export const PlayButton: React.FC<Props> = ({
|
||||
return userData.PlaybackPositionTicks > 0
|
||||
? Math.max(
|
||||
(userData.PlaybackPositionTicks / item.RunTimeTicks) * 100,
|
||||
MIN_PLAYBACK_WIDTH
|
||||
MIN_PLAYBACK_WIDTH,
|
||||
)
|
||||
: 0;
|
||||
}
|
||||
@@ -260,7 +260,7 @@ export const PlayButton: React.FC<Props> = ({
|
||||
easing: Easing.bezier(0.7, 0, 0.3, 1.0),
|
||||
});
|
||||
},
|
||||
[item]
|
||||
[item],
|
||||
);
|
||||
|
||||
useAnimatedReaction(
|
||||
@@ -273,7 +273,7 @@ export const PlayButton: React.FC<Props> = ({
|
||||
easing: Easing.bezier(0.9, 0, 0.31, 0.99),
|
||||
});
|
||||
},
|
||||
[colorAtom]
|
||||
[colorAtom],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -294,7 +294,7 @@ export const PlayButton: React.FC<Props> = ({
|
||||
backgroundColor: interpolateColor(
|
||||
colorChangeProgress.value,
|
||||
[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(
|
||||
colorChangeProgress.value,
|
||||
[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(
|
||||
widthProgress.value,
|
||||
[0, 1],
|
||||
[startWidth.value, targetWidth.value]
|
||||
[startWidth.value, targetWidth.value],
|
||||
)}%`,
|
||||
}));
|
||||
|
||||
@@ -318,7 +318,7 @@ export const PlayButton: React.FC<Props> = ({
|
||||
color: interpolateColor(
|
||||
colorChangeProgress.value,
|
||||
[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 (
|
||||
<TouchableOpacity
|
||||
disabled={!item}
|
||||
accessibilityLabel="Play button"
|
||||
accessibilityHint="Tap to play the media"
|
||||
accessibilityLabel='Play button'
|
||||
accessibilityHint='Tap to play the media'
|
||||
onPress={onPress}
|
||||
className={`relative`}
|
||||
{...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
|
||||
style={[
|
||||
animatedPrimaryStyle,
|
||||
@@ -348,7 +348,7 @@ export const PlayButton: React.FC<Props> = ({
|
||||
|
||||
<Animated.View
|
||||
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
|
||||
style={{
|
||||
@@ -356,25 +356,25 @@ export const PlayButton: React.FC<Props> = ({
|
||||
borderColor: colorAtom.primary,
|
||||
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" }]}>
|
||||
{runtimeTicksToMinutes(item?.RunTimeTicks)}
|
||||
</Animated.Text>
|
||||
<Animated.Text style={animatedTextStyle}>
|
||||
<Ionicons name="play-circle" size={24} />
|
||||
<Ionicons name='play-circle' size={24} />
|
||||
</Animated.Text>
|
||||
{client && (
|
||||
<Animated.Text style={animatedTextStyle}>
|
||||
<Feather name="cast" size={22} />
|
||||
<CastButton tintColor="transparent" />
|
||||
<Feather name='cast' size={22} />
|
||||
<CastButton tintColor='transparent' />
|
||||
</Animated.Text>
|
||||
)}
|
||||
{!client && settings?.openInVLC && (
|
||||
<Animated.Text style={animatedTextStyle}>
|
||||
<MaterialCommunityIcons
|
||||
name="vlc"
|
||||
name='vlc'
|
||||
size={18}
|
||||
color={animatedTextStyle.color}
|
||||
/>
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { Platform } from "react-native";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||
import { useActionSheet } from "@expo/react-native-action-sheet";
|
||||
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 { useAtom, useAtomValue } from "jotai";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform } from "react-native";
|
||||
import { Alert, TouchableOpacity, View } from "react-native";
|
||||
import Animated, {
|
||||
Easing,
|
||||
@@ -20,10 +22,8 @@ import Animated, {
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import { Button } from "./Button";
|
||||
import { SelectedOptions } from "./ItemContent";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import type { Button } from "./Button";
|
||||
import type { SelectedOptions } from "./ItemContent";
|
||||
|
||||
interface Props extends React.ComponentProps<typeof Button> {
|
||||
item: BaseItemDto;
|
||||
@@ -34,10 +34,10 @@ const ANIMATION_DURATION = 500;
|
||||
const MIN_PLAYBACK_WIDTH = 15;
|
||||
|
||||
export const PlayButton: React.FC<Props> = ({
|
||||
item,
|
||||
selectedOptions,
|
||||
...props
|
||||
}: Props) => {
|
||||
item,
|
||||
selectedOptions,
|
||||
...props
|
||||
}: Props) => {
|
||||
const { showActionSheetWithOptions } = useActionSheet();
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -60,7 +60,7 @@ export const PlayButton: React.FC<Props> = ({
|
||||
(q: string) => {
|
||||
router.push(`/player/direct-player?${q}`);
|
||||
},
|
||||
[router]
|
||||
[router],
|
||||
);
|
||||
|
||||
const onPress = () => {
|
||||
@@ -88,9 +88,9 @@ export const PlayButton: React.FC<Props> = ({
|
||||
if (userData && userData.PlaybackPositionTicks) {
|
||||
return userData.PlaybackPositionTicks > 0
|
||||
? Math.max(
|
||||
(userData.PlaybackPositionTicks / item.RunTimeTicks) * 100,
|
||||
MIN_PLAYBACK_WIDTH
|
||||
)
|
||||
(userData.PlaybackPositionTicks / item.RunTimeTicks) * 100,
|
||||
MIN_PLAYBACK_WIDTH,
|
||||
)
|
||||
: 0;
|
||||
}
|
||||
return 0;
|
||||
@@ -106,7 +106,7 @@ export const PlayButton: React.FC<Props> = ({
|
||||
easing: Easing.bezier(0.7, 0, 0.3, 1.0),
|
||||
});
|
||||
},
|
||||
[item]
|
||||
[item],
|
||||
);
|
||||
|
||||
useAnimatedReaction(
|
||||
@@ -119,7 +119,7 @@ export const PlayButton: React.FC<Props> = ({
|
||||
easing: Easing.bezier(0.9, 0, 0.31, 0.99),
|
||||
});
|
||||
},
|
||||
[colorAtom]
|
||||
[colorAtom],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -140,7 +140,7 @@ export const PlayButton: React.FC<Props> = ({
|
||||
backgroundColor: interpolateColor(
|
||||
colorChangeProgress.value,
|
||||
[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(
|
||||
colorChangeProgress.value,
|
||||
[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(
|
||||
widthProgress.value,
|
||||
[0, 1],
|
||||
[startWidth.value, targetWidth.value]
|
||||
[startWidth.value, targetWidth.value],
|
||||
)}%`,
|
||||
}));
|
||||
|
||||
@@ -164,7 +164,7 @@ export const PlayButton: React.FC<Props> = ({
|
||||
color: interpolateColor(
|
||||
colorChangeProgress.value,
|
||||
[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 (
|
||||
<TouchableOpacity
|
||||
accessibilityLabel="Play button"
|
||||
accessibilityHint="Tap to play the media"
|
||||
accessibilityLabel='Play button'
|
||||
accessibilityHint='Tap to play the media'
|
||||
onPress={onPress}
|
||||
className={`relative`}
|
||||
{...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
|
||||
style={[
|
||||
animatedPrimaryStyle,
|
||||
@@ -193,7 +193,7 @@ export const PlayButton: React.FC<Props> = ({
|
||||
|
||||
<Animated.View
|
||||
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
|
||||
style={{
|
||||
@@ -201,19 +201,19 @@ export const PlayButton: React.FC<Props> = ({
|
||||
borderColor: colorAtom.primary,
|
||||
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" }]}>
|
||||
{runtimeTicksToMinutes(item?.RunTimeTicks)}
|
||||
</Animated.Text>
|
||||
<Animated.Text style={animatedTextStyle}>
|
||||
<Ionicons name="play-circle" size={24} />
|
||||
<Ionicons name='play-circle' size={24} />
|
||||
</Animated.Text>
|
||||
{settings?.openInVLC && (
|
||||
<Animated.Text style={animatedTextStyle}>
|
||||
<MaterialCommunityIcons
|
||||
name="vlc"
|
||||
name='vlc'
|
||||
size={18}
|
||||
color={animatedTextStyle.color}
|
||||
/>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
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 React from "react";
|
||||
import { View, ViewProps } from "react-native";
|
||||
import type React from "react";
|
||||
import { View, type ViewProps } from "react-native";
|
||||
import { RoundButton } from "./RoundButton";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
@@ -18,7 +18,7 @@ export const PlayedStatus: React.FC<Props> = ({ items, ...props }) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["item", item.Id],
|
||||
});
|
||||
})
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["resumeItems"],
|
||||
});
|
||||
@@ -51,9 +51,9 @@ export const PlayedStatus: React.FC<Props> = ({ items, ...props }) => {
|
||||
<RoundButton
|
||||
fillColor={allPlayed ? "primary" : undefined}
|
||||
icon={allPlayed ? "checkmark" : "checkmark"}
|
||||
onPress={async () => {
|
||||
onPress={async () => {
|
||||
console.log(allPlayed);
|
||||
await markAsPlayedStatus(!allPlayed)
|
||||
await markAsPlayedStatus(!allPlayed);
|
||||
}}
|
||||
size={props.size}
|
||||
/>
|
||||
|
||||
@@ -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 { useMMKVString } from "react-native-mmkv";
|
||||
import { ListGroup } from "./list/ListGroup";
|
||||
import { ListItem } from "./list/ListItem";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface Server {
|
||||
address: string;
|
||||
@@ -29,7 +30,7 @@ export const PreviousServersList: React.FC<PreviousServersListProps> = ({
|
||||
|
||||
return (
|
||||
<View>
|
||||
<ListGroup title={t("server.previous_servers")} className="mt-4">
|
||||
<ListGroup title={t("server.previous_servers")} className='mt-4'>
|
||||
{previousServers.map((s) => (
|
||||
<ListItem
|
||||
key={s.address}
|
||||
@@ -43,7 +44,7 @@ export const PreviousServersList: React.FC<PreviousServersListProps> = ({
|
||||
setPreviousServers("[]");
|
||||
}}
|
||||
title={t("server.clear_button")}
|
||||
textColor="red"
|
||||
textColor='red'
|
||||
/>
|
||||
</ListGroup>
|
||||
</View>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from "react";
|
||||
import { View, StyleSheet } from "react-native";
|
||||
import type React from "react";
|
||||
import { StyleSheet, View } from "react-native";
|
||||
import { AnimatedCircularProgress } from "react-native-circular-progress";
|
||||
|
||||
type ProgressCircleProps = {
|
||||
|
||||
@@ -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 { useQuery } from "@tanstack/react-query";
|
||||
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
||||
import {MovieDetails} from "@/utils/jellyseerr/server/models/Movie";
|
||||
import {TvDetails} from "@/utils/jellyseerr/server/models/Tv";
|
||||
import {useMemo} from "react";
|
||||
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 { 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 {
|
||||
item?: BaseItemDto | null;
|
||||
@@ -18,21 +21,21 @@ interface Props extends ViewProps {
|
||||
export const Ratings: React.FC<Props> = ({ item, ...props }) => {
|
||||
if (!item) return null;
|
||||
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 && (
|
||||
<Badge text={item.OfficialRating} variant="gray" />
|
||||
<Badge text={item.OfficialRating} variant='gray' />
|
||||
)}
|
||||
{item.CommunityRating && (
|
||||
<Badge
|
||||
text={item.CommunityRating.toFixed(1)}
|
||||
variant="gray"
|
||||
iconLeft={<Ionicons name="star" size={14} color="gold" />}
|
||||
variant='gray'
|
||||
iconLeft={<Ionicons name='star' size={14} color='gold' />}
|
||||
/>
|
||||
)}
|
||||
{item.CriticRating && (
|
||||
<Badge
|
||||
text={item.CriticRating}
|
||||
variant="gray"
|
||||
variant='gray'
|
||||
iconLeft={
|
||||
<Image
|
||||
source={
|
||||
@@ -52,9 +55,9 @@ export const Ratings: React.FC<Props> = ({ item, ...props }) => {
|
||||
);
|
||||
};
|
||||
|
||||
export const JellyserrRatings: React.FC<{ result: MovieResult | TvResult | TvDetails | MovieDetails }> = ({
|
||||
result,
|
||||
}) => {
|
||||
export const JellyserrRatings: React.FC<{
|
||||
result: MovieResult | TvResult | TvDetails | MovieDetails;
|
||||
}> = ({ result }) => {
|
||||
const { jellyseerrApi, getMediaType } = useJellyseerr();
|
||||
|
||||
const mediaType = useMemo(() => getMediaType(result), [result]);
|
||||
@@ -76,14 +79,14 @@ export const JellyserrRatings: React.FC<{ result: MovieResult | TvResult | TvDet
|
||||
!!result.voteCount ||
|
||||
(data?.criticsRating && !!data?.criticsScore) ||
|
||||
(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 && (
|
||||
<Badge
|
||||
text={`${data.criticsScore}%`}
|
||||
variant="gray"
|
||||
variant='gray'
|
||||
iconLeft={
|
||||
<Image
|
||||
className="mr-1"
|
||||
className='mr-1'
|
||||
source={
|
||||
data?.criticsRating === "Rotten"
|
||||
? 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 && (
|
||||
<Badge
|
||||
text={`${data.audienceScore}%`}
|
||||
variant="gray"
|
||||
variant='gray'
|
||||
iconLeft={
|
||||
<Image
|
||||
className="mr-1"
|
||||
className='mr-1'
|
||||
source={
|
||||
data?.audienceRating === "Spilled"
|
||||
? require("@/utils/jellyseerr/src/assets/rt_aud_rotten.svg")
|
||||
@@ -120,10 +123,10 @@ export const JellyserrRatings: React.FC<{ result: MovieResult | TvResult | TvDet
|
||||
{!!result.voteCount && (
|
||||
<Badge
|
||||
text={`${Math.round(result.voteAverage * 10)}%`}
|
||||
variant="gray"
|
||||
variant='gray'
|
||||
iconLeft={
|
||||
<Image
|
||||
className="mr-1"
|
||||
className='mr-1'
|
||||
source={require("@/utils/jellyseerr/src/assets/tmdb_logo.svg")}
|
||||
style={{
|
||||
width: 14,
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { BlurView } from "expo-blur";
|
||||
import { PropsWithChildren } from "react";
|
||||
import type { PropsWithChildren } from "react";
|
||||
import {
|
||||
Platform,
|
||||
TouchableOpacity,
|
||||
TouchableOpacityProps,
|
||||
type TouchableOpacityProps,
|
||||
} from "react-native";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
|
||||
interface Props extends TouchableOpacityProps {
|
||||
onPress?: () => void;
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
import MoviePoster from "@/components/posters/MoviePoster";
|
||||
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 { useQuery } from "@tanstack/react-query";
|
||||
import { router } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { useMemo } from "react";
|
||||
import { ScrollView, TouchableOpacity, View, ViewProps } from "react-native";
|
||||
import { Text } from "./common/Text";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
type ViewProps,
|
||||
} from "react-native";
|
||||
import { ItemCardText } from "./ItemCardText";
|
||||
import { Loader } from "./Loader";
|
||||
import { HorizontalScroll } from "./common/HorrizontalScroll";
|
||||
import { Text } from "./common/Text";
|
||||
import { TouchableItemRouter } from "./common/TouchableItemRouter";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface SimilarItemsProps extends ViewProps {
|
||||
itemId?: string | null;
|
||||
@@ -39,17 +44,19 @@ export const SimilarItems: React.FC<SimilarItemsProps> = ({
|
||||
return response.data.Items || [];
|
||||
},
|
||||
enabled: !!api && !!user?.Id,
|
||||
staleTime: Infinity,
|
||||
staleTime: Number.POSITIVE_INFINITY,
|
||||
});
|
||||
|
||||
const movies = useMemo(
|
||||
() => similarItems?.filter((i) => i.Type === "Movie") || [],
|
||||
[similarItems]
|
||||
[similarItems],
|
||||
);
|
||||
|
||||
return (
|
||||
<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
|
||||
data={movies}
|
||||
loading={isLoading}
|
||||
@@ -59,7 +66,7 @@ export const SimilarItems: React.FC<SimilarItemsProps> = ({
|
||||
<TouchableItemRouter
|
||||
key={idx}
|
||||
item={item}
|
||||
className="flex flex-col w-28"
|
||||
className='flex flex-col w-28'
|
||||
>
|
||||
<View>
|
||||
<MoviePoster item={item} />
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
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 { Platform, TouchableOpacity, View } from "react-native";
|
||||
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||
import { Text } from "./common/Text";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Text } from "./common/Text";
|
||||
|
||||
interface Props extends React.ComponentProps<typeof View> {
|
||||
source?: MediaSourceInfo;
|
||||
@@ -25,7 +25,7 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
|
||||
|
||||
const selectedSubtitleSteam = useMemo(
|
||||
() => subtitleStreams?.find((x) => x.Index === selected),
|
||||
[subtitleStreams, selected]
|
||||
[subtitleStreams, selected],
|
||||
);
|
||||
|
||||
if (subtitleStreams?.length === 0) return null;
|
||||
@@ -34,7 +34,7 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
|
||||
|
||||
return (
|
||||
<View
|
||||
className="flex col shrink justify-start place-self-start items-start"
|
||||
className='flex col shrink justify-start place-self-start items-start'
|
||||
style={{
|
||||
minWidth: 60,
|
||||
maxWidth: 200,
|
||||
@@ -42,12 +42,12 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
|
||||
>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<View className="flex flex-col " {...props}>
|
||||
<Text numberOfLines={1} className="opacity-50 mb-1 text-xs">
|
||||
<View className='flex flex-col ' {...props}>
|
||||
<Text numberOfLines={1} className='opacity-50 mb-1 text-xs'>
|
||||
{t("item_card.subtitles")}
|
||||
</Text>
|
||||
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between">
|
||||
<Text className=" ">
|
||||
<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=' '>
|
||||
{selectedSubtitleSteam
|
||||
? tc(selectedSubtitleSteam?.DisplayTitle, 7)
|
||||
: t("item_card.none")}
|
||||
@@ -57,8 +57,8 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
loop={true}
|
||||
side="bottom"
|
||||
align="start"
|
||||
side='bottom'
|
||||
align='start'
|
||||
alignOffset={0}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={8}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Text, type TextProps, StyleSheet } from "react-native";
|
||||
import { StyleSheet, Text, type TextProps } from "react-native";
|
||||
|
||||
export type ThemedTextProps = TextProps & {
|
||||
lightColor?: string;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import React from "react";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import type React from "react";
|
||||
import { View } from "react-native";
|
||||
|
||||
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.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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import * as React from 'react';
|
||||
import renderer from 'react-test-renderer';
|
||||
import * as React from "react";
|
||||
import renderer from "react-test-renderer";
|
||||
|
||||
import { ThemedText } from '../ThemedText';
|
||||
import { ThemedText } from "../ThemedText";
|
||||
|
||||
it(`renders correctly`, () => {
|
||||
const tree = renderer.create(<ThemedText>Snapshot test!</ThemedText>).toJSON();
|
||||
const tree = renderer
|
||||
.create(<ThemedText>Snapshot test!</ThemedText>)
|
||||
.toJSON();
|
||||
|
||||
expect(tree).toMatchSnapshot();
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { View, ViewProps } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { View, type ViewProps } from "react-native";
|
||||
|
||||
interface Props extends ViewProps {}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 alignItems = (() => {
|
||||
@@ -29,7 +29,7 @@ export const ColumnItem = ({
|
||||
...rest
|
||||
}: ColumnItemProps) => {
|
||||
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
|
||||
className={`
|
||||
`}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||
import { Platform, TouchableOpacity, View, ViewProps } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||
import React, {
|
||||
PropsWithChildren,
|
||||
ReactNode,
|
||||
type PropsWithChildren,
|
||||
type ReactNode,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||
import { Platform, TouchableOpacity, View, type ViewProps } from "react-native";
|
||||
|
||||
interface Props<T> {
|
||||
data: T[];
|
||||
@@ -21,7 +21,7 @@ interface Props<T> {
|
||||
multiple?: boolean;
|
||||
}
|
||||
|
||||
const Dropdown = <T extends unknown>({
|
||||
const Dropdown = <T,>({
|
||||
data,
|
||||
disabled,
|
||||
placeholderText,
|
||||
@@ -47,10 +47,10 @@ const Dropdown = <T extends unknown>({
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
{typeof title === "string" ? (
|
||||
<View className="flex flex-col">
|
||||
<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">
|
||||
<Text style={{}} className="" numberOfLines={1}>
|
||||
<View className='flex flex-col'>
|
||||
<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'>
|
||||
<Text style={{}} className='' numberOfLines={1}>
|
||||
{selected?.length !== undefined
|
||||
? selected.map(titleExtractor).join(",")
|
||||
: placeholderText}
|
||||
@@ -63,8 +63,8 @@ const Dropdown = <T extends unknown>({
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
loop={false}
|
||||
side="bottom"
|
||||
align="center"
|
||||
side='bottom'
|
||||
align='center'
|
||||
alignOffset={0}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={0}
|
||||
@@ -88,10 +88,10 @@ const Dropdown = <T extends unknown>({
|
||||
}
|
||||
return [
|
||||
...prev.filter(
|
||||
(p) => keyExtractor(p) !== keyExtractor(item)
|
||||
(p) => keyExtractor(p) !== keyExtractor(item),
|
||||
),
|
||||
];
|
||||
})
|
||||
});
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>
|
||||
@@ -107,7 +107,7 @@ const Dropdown = <T extends unknown>({
|
||||
{titleExtractor(item)}
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
)
|
||||
),
|
||||
)}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
|
||||
@@ -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 {
|
||||
Platform,
|
||||
TouchableOpacity,
|
||||
TouchableOpacityProps,
|
||||
type TouchableOpacityProps,
|
||||
View,
|
||||
ViewProps,
|
||||
} 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 {
|
||||
background?: "blur" | "transparent";
|
||||
@@ -31,13 +31,13 @@ export const HeaderBackButton: React.FC<Props> = ({
|
||||
<BlurView
|
||||
{...props}
|
||||
intensity={100}
|
||||
className="overflow-hidden rounded-full p-2"
|
||||
className='overflow-hidden rounded-full p-2'
|
||||
>
|
||||
<Ionicons
|
||||
className="drop-shadow-2xl"
|
||||
name="arrow-back"
|
||||
className='drop-shadow-2xl'
|
||||
name='arrow-back'
|
||||
size={24}
|
||||
color="white"
|
||||
color='white'
|
||||
/>
|
||||
</BlurView>
|
||||
</TouchableOpacity>
|
||||
@@ -46,14 +46,14 @@ export const HeaderBackButton: React.FC<Props> = ({
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={() => router.back()}
|
||||
className=" bg-neutral-800/80 rounded-full p-2"
|
||||
className=' bg-neutral-800/80 rounded-full p-2'
|
||||
{...touchableOpacityProps}
|
||||
>
|
||||
<Ionicons
|
||||
className="drop-shadow-2xl"
|
||||
name="arrow-back"
|
||||
className='drop-shadow-2xl'
|
||||
name='arrow-back'
|
||||
size={24}
|
||||
color="white"
|
||||
color='white'
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
@@ -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 { View, ViewStyle } from "react-native";
|
||||
import { View, type ViewStyle } from "react-native";
|
||||
import { Text } from "./Text";
|
||||
|
||||
type PartialExcept<T, K extends keyof T> = Partial<T> & Pick<T, K>;
|
||||
@@ -44,7 +44,7 @@ export const HorizontalScroll = forwardRef<
|
||||
noItemsText,
|
||||
...props
|
||||
}: HorizontalScrollProps<T>,
|
||||
ref: React.ForwardedRef<HorizontalScrollRef>
|
||||
ref: React.ForwardedRef<HorizontalScrollRef>,
|
||||
) => {
|
||||
const flashListRef = useRef<FlashList<T>>(null);
|
||||
|
||||
@@ -66,16 +66,16 @@ export const HorizontalScroll = forwardRef<
|
||||
item: T;
|
||||
index: number;
|
||||
}) => (
|
||||
<View className="mr-2">
|
||||
<View className='mr-2'>
|
||||
<React.Fragment>{renderItem(item, index)}</React.Fragment>
|
||||
</View>
|
||||
);
|
||||
|
||||
if (!data || loading) {
|
||||
return (
|
||||
<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-10 w-full rounded-md mb-1"></View>
|
||||
<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-10 w-full rounded-md mb-1'></View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -95,8 +95,8 @@ export const HorizontalScroll = forwardRef<
|
||||
}}
|
||||
keyExtractor={keyExtractor}
|
||||
ListEmptyComponent={() => (
|
||||
<View className="flex-1 justify-center items-center">
|
||||
<Text className="text-center text-gray-500">
|
||||
<View className='flex-1 justify-center items-center'>
|
||||
<Text className='text-center text-gray-500'>
|
||||
{noItemsText || "No data available"}
|
||||
</Text>
|
||||
</View>
|
||||
@@ -104,5 +104,5 @@ export const HorizontalScroll = forwardRef<
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import {
|
||||
import type {
|
||||
BaseItemDto,
|
||||
BaseItemDtoQueryResult,
|
||||
} 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 { t } from "i18next";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useEffect, useMemo } from "react";
|
||||
import { View, ViewStyle } from "react-native";
|
||||
import { View, type ViewStyle } from "react-native";
|
||||
import Animated, {
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
@@ -15,7 +16,6 @@ import Animated, {
|
||||
} from "react-native-reanimated";
|
||||
import { Loader } from "../Loader";
|
||||
import { Text } from "./Text";
|
||||
import { t } from "i18next";
|
||||
|
||||
interface HorizontalScrollProps
|
||||
extends Omit<FlashListProps<BaseItemDto>, "renderItem" | "data" | "style"> {
|
||||
@@ -70,7 +70,7 @@ export function InfiniteHorizontalScroll({
|
||||
const totalItems = lastPage.TotalRecordCount;
|
||||
const accumulatedItems = pages.reduce(
|
||||
(acc, curr) => acc + (curr?.Items?.length || 0),
|
||||
0
|
||||
0,
|
||||
);
|
||||
|
||||
if (accumulatedItems < totalItems) {
|
||||
@@ -118,7 +118,7 @@ export function InfiniteHorizontalScroll({
|
||||
<FlashList
|
||||
data={flatData}
|
||||
renderItem={({ item, index }) => (
|
||||
<View className="mr-2">
|
||||
<View className='mr-2'>
|
||||
<React.Fragment>{renderItem(item, index)}</React.Fragment>
|
||||
</View>
|
||||
)}
|
||||
@@ -136,8 +136,10 @@ export function InfiniteHorizontalScroll({
|
||||
}}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
ListEmptyComponent={
|
||||
<View className="flex-1 justify-center items-center">
|
||||
<Text className="text-center text-gray-500">{t("item_card.no_data_available")}</Text>
|
||||
<View className='flex-1 justify-center items-center'>
|
||||
<Text className='text-center text-gray-500'>
|
||||
{t("item_card.no_data_available")}
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
{...props}
|
||||
|
||||
@@ -1,32 +1,35 @@
|
||||
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) {
|
||||
const { style, ...otherProps } = props;
|
||||
const inputRef = React.useRef<TextInput>(null);
|
||||
|
||||
return Platform.isTV ? (
|
||||
<TouchableOpacity
|
||||
onFocus={() => inputRef?.current?.focus?.()}
|
||||
>
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
className="p-4 rounded-xl bg-neutral-900"
|
||||
allowFontScaling={false}
|
||||
style={[{ color: "white" }, style]}
|
||||
placeholderTextColor={"#9CA3AF"}
|
||||
clearButtonMode="while-editing"
|
||||
{...otherProps}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onFocus={() => inputRef?.current?.focus?.()}>
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
className='p-4 rounded-xl bg-neutral-900'
|
||||
allowFontScaling={false}
|
||||
style={[{ color: "white" }, style]}
|
||||
placeholderTextColor={"#9CA3AF"}
|
||||
clearButtonMode='while-editing'
|
||||
{...otherProps}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
className="p-4 rounded-xl bg-neutral-900"
|
||||
className='p-4 rounded-xl bg-neutral-900'
|
||||
allowFontScaling={false}
|
||||
style={[{ color: "white" }, style]}
|
||||
placeholderTextColor={"#9CA3AF"}
|
||||
clearButtonMode="while-editing"
|
||||
clearButtonMode='while-editing'
|
||||
{...otherProps}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { getItemImage } from "@/utils/getItemImage";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { Image, ImageProps } from "expo-image";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { Image, type ImageProps } from "expo-image";
|
||||
import { useAtom } from "jotai";
|
||||
import {FC, useMemo} from "react";
|
||||
import { View, ViewProps } from "react-native";
|
||||
import { type FC, useMemo } from "react";
|
||||
import { View, type ViewProps } from "react-native";
|
||||
|
||||
interface Props extends ImageProps {
|
||||
item: BaseItemDto;
|
||||
@@ -52,13 +52,13 @@ export const ItemImage: FC<Props> = ({
|
||||
if (!source?.uri)
|
||||
return (
|
||||
<View
|
||||
{...props as ViewProps}
|
||||
className="flex flex-col items-center justify-center border border-neutral-800 bg-neutral-900"
|
||||
{...(props as ViewProps)}
|
||||
className='flex flex-col items-center justify-center border border-neutral-800 bg-neutral-900'
|
||||
>
|
||||
<Ionicons
|
||||
name="image-outline"
|
||||
name='image-outline'
|
||||
size={24}
|
||||
color="white"
|
||||
color='white'
|
||||
style={{ opacity: 0.4 }}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -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 { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search";
|
||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import {
|
||||
hasPermission,
|
||||
Permission,
|
||||
} from "@/utils/jellyseerr/server/lib/permissions";
|
||||
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
||||
import {TvDetails} from "@/utils/jellyseerr/server/models/Tv";
|
||||
import {MovieDetails} from "@/utils/jellyseerr/server/models/Movie";
|
||||
import {
|
||||
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 {
|
||||
result?: MovieResult | TvResult | MovieDetails | TvDetails;
|
||||
@@ -46,16 +50,13 @@ export const TouchableJellyseerrRouter: React.FC<PropsWithChildren<Props>> = ({
|
||||
);
|
||||
}, [jellyseerrApi, jellyseerrUser]);
|
||||
|
||||
const request = useCallback(
|
||||
() => {
|
||||
if (!result) return;
|
||||
requestMedia(mediaTitle, {
|
||||
mediaId: result.id,
|
||||
mediaType,
|
||||
})
|
||||
},
|
||||
[jellyseerrApi, result]
|
||||
);
|
||||
const request = useCallback(() => {
|
||||
if (!result) return;
|
||||
requestMedia(mediaTitle, {
|
||||
mediaId: result.id,
|
||||
mediaType,
|
||||
});
|
||||
}, [jellyseerrApi, result]);
|
||||
|
||||
if (from === "(home)" || from === "(search)" || from === "(libraries)")
|
||||
return (
|
||||
@@ -75,7 +76,7 @@ export const TouchableJellyseerrRouter: React.FC<PropsWithChildren<Props>> = ({
|
||||
releaseYear,
|
||||
canRequest,
|
||||
posterSrc,
|
||||
mediaType
|
||||
mediaType,
|
||||
},
|
||||
});
|
||||
}}
|
||||
@@ -91,10 +92,10 @@ export const TouchableJellyseerrRouter: React.FC<PropsWithChildren<Props>> = ({
|
||||
loop={false}
|
||||
key={"content"}
|
||||
>
|
||||
<ContextMenu.Label key="label-1">Actions</ContextMenu.Label>
|
||||
<ContextMenu.Label key='label-1'>Actions</ContextMenu.Label>
|
||||
{canRequest && mediaType === MediaType.MOVIE && (
|
||||
<ContextMenu.Item
|
||||
key="item-1"
|
||||
key='item-1'
|
||||
onSelect={() => {
|
||||
if (autoApprove) {
|
||||
request();
|
||||
@@ -102,7 +103,7 @@ export const TouchableJellyseerrRouter: React.FC<PropsWithChildren<Props>> = ({
|
||||
}}
|
||||
shouldDismissMenuOnSelect
|
||||
>
|
||||
<ContextMenu.ItemTitle key="item-1-title">
|
||||
<ContextMenu.ItemTitle key='item-1-title'>
|
||||
Request
|
||||
</ContextMenu.ItemTitle>
|
||||
<ContextMenu.ItemIcon
|
||||
@@ -116,7 +117,7 @@ export const TouchableJellyseerrRouter: React.FC<PropsWithChildren<Props>> = ({
|
||||
light: "purple",
|
||||
},
|
||||
}}
|
||||
androidIconName="download"
|
||||
androidIconName='download'
|
||||
/>
|
||||
</ContextMenu.Item>
|
||||
)}
|
||||
|
||||
@@ -4,16 +4,16 @@ import { View } from "react-native";
|
||||
export const LargePoster: React.FC<{ url?: string | null }> = ({ url }) => {
|
||||
if (!url)
|
||||
return (
|
||||
<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='p-4 rounded-xl overflow-hidden '>
|
||||
<View className='w-full aspect-video rounded-xl overflow-hidden border border-neutral-800'></View>
|
||||
</View>
|
||||
);
|
||||
|
||||
|
||||
return (
|
||||
<View className="p-4 rounded-xl overflow-hidden ">
|
||||
<View className='p-4 rounded-xl overflow-hidden '>
|
||||
<Image
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import React from "react";
|
||||
import { Platform, TextProps } from "react-native";
|
||||
import { UITextView } from "react-native-uitextview";
|
||||
import { Platform, type TextProps } from "react-native";
|
||||
import { Text as RNText } from "react-native";
|
||||
import { UITextView } from "react-native-uitextview";
|
||||
export function Text(
|
||||
props: TextProps & {
|
||||
uiTextView?: boolean;
|
||||
}
|
||||
},
|
||||
) {
|
||||
const { style, ...otherProps } = props;
|
||||
if (Platform.isTV)
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
|
||||
import { useFavorite } from "@/hooks/useFavorite";
|
||||
import {
|
||||
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
|
||||
import { useActionSheet } from "@expo/react-native-action-sheet";
|
||||
import type {
|
||||
BaseItemDto,
|
||||
BaseItemPerson,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useRouter, useSegments } from "expo-router";
|
||||
import { PropsWithChildren, useCallback } from "react";
|
||||
import { TouchableOpacity, TouchableOpacityProps } from "react-native";
|
||||
import { useActionSheet } from "@expo/react-native-action-sheet";
|
||||
import { type PropsWithChildren, useCallback } from "react";
|
||||
import { TouchableOpacity, type TouchableOpacityProps } from "react-native";
|
||||
|
||||
interface Props extends TouchableOpacityProps {
|
||||
item: BaseItemDto;
|
||||
@@ -15,7 +15,7 @@ interface Props extends TouchableOpacityProps {
|
||||
|
||||
export const itemRouter = (
|
||||
item: BaseItemDto | BaseItemPerson,
|
||||
from: string
|
||||
from: string,
|
||||
) => {
|
||||
if ("CollectionType" in item && item.CollectionType === "livetv") {
|
||||
return `/(auth)/(tabs)/${from}/livetv`;
|
||||
@@ -58,12 +58,24 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
||||
const { showActionSheetWithOptions } = useActionSheet();
|
||||
const markAsPlayedStatus = useMarkAsPlayed([item]);
|
||||
const { isFavorite, toggleFavorite } = useFavorite(item);
|
||||
|
||||
|
||||
const from = segments[2];
|
||||
|
||||
const showActionSheet = useCallback(() => {
|
||||
if (!(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"];
|
||||
if (
|
||||
!(
|
||||
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;
|
||||
|
||||
showActionSheetWithOptions(
|
||||
@@ -77,9 +89,9 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
||||
} else if (selectedIndex === 1) {
|
||||
await markAsPlayedStatus(false);
|
||||
} else if (selectedIndex === 2) {
|
||||
toggleFavorite()
|
||||
toggleFavorite();
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}, [showActionSheetWithOptions, isFavorite, markAsPlayedStatus]);
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { View, ViewProps } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { View, type ViewProps } from "react-native";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
index: number;
|
||||
@@ -12,18 +12,18 @@ export const VerticalSkeleton: React.FC<Props> = ({ index, ...props }) => {
|
||||
style={{
|
||||
width: "32%",
|
||||
}}
|
||||
className="flex flex-col"
|
||||
className='flex flex-col'
|
||||
{...props}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
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 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-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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Text } from "@/components/common/Text";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { DownloadMethod, useSettings } from "@/utils/atoms/settings";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
import { JobStatus } from "@/utils/optimize-server";
|
||||
import type { JobStatus } from "@/utils/optimize-server";
|
||||
import { formatTimeString } from "@/utils/time";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
@@ -14,9 +14,9 @@ import {
|
||||
ActivityIndicator,
|
||||
Platform,
|
||||
TouchableOpacity,
|
||||
TouchableOpacityProps,
|
||||
type TouchableOpacityProps,
|
||||
View,
|
||||
ViewProps,
|
||||
type ViewProps,
|
||||
} from "react-native";
|
||||
import { toast } from "sonner-native";
|
||||
import { Button } from "../Button";
|
||||
@@ -33,22 +33,22 @@ export const ActiveDownloads: React.FC<Props> = ({ ...props }) => {
|
||||
const { processes } = useDownload();
|
||||
if (processes?.length === 0)
|
||||
return (
|
||||
<View {...props} className="bg-neutral-900 p-4 rounded-2xl">
|
||||
<Text className="text-lg font-bold">
|
||||
<View {...props} className='bg-neutral-900 p-4 rounded-2xl'>
|
||||
<Text className='text-lg font-bold'>
|
||||
{t("home.downloads.active_download")}
|
||||
</Text>
|
||||
<Text className="opacity-50">
|
||||
<Text className='opacity-50'>
|
||||
{t("home.downloads.no_active_downloads")}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<View {...props} className="bg-neutral-900 p-4 rounded-2xl">
|
||||
<Text className="text-lg font-bold mb-2">
|
||||
<View {...props} className='bg-neutral-900 p-4 rounded-2xl'>
|
||||
<Text className='text-lg font-bold mb-2'>
|
||||
{t("home.downloads.active_downloads")}
|
||||
</Text>
|
||||
<View className="space-y-2">
|
||||
<View className='space-y-2'>
|
||||
{processes?.map((p: JobStatus) => (
|
||||
<DownloadCard key={p.item.Id} process={p} />
|
||||
))}
|
||||
@@ -89,7 +89,7 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
||||
} else {
|
||||
FFmpegKitProvider.FFmpegKit.cancel(Number(id));
|
||||
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 (
|
||||
<TouchableOpacity
|
||||
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}
|
||||
>
|
||||
{(process.status === "optimizing" ||
|
||||
@@ -133,10 +133,10 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
||||
}}
|
||||
></View>
|
||||
)}
|
||||
<View className="px-3 py-1.5 flex flex-col w-full">
|
||||
<View className="flex flex-row items-center w-full">
|
||||
<View className='px-3 py-1.5 flex flex-col w-full'>
|
||||
<View className='flex flex-row items-center w-full'>
|
||||
{base64Image && (
|
||||
<View className="w-14 aspect-[10/15] rounded-lg overflow-hidden mr-4">
|
||||
<View className='w-14 aspect-[10/15] rounded-lg overflow-hidden mr-4'>
|
||||
<Image
|
||||
source={{
|
||||
uri: `data:image/jpeg;base64,${base64Image}`,
|
||||
@@ -149,51 +149,51 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
<View className="shrink mb-1">
|
||||
<Text className="text-xs opacity-50">{process.item.Type}</Text>
|
||||
<Text className="font-semibold shrink">{process.item.Name}</Text>
|
||||
<Text className="text-xs opacity-50">
|
||||
<View className='shrink mb-1'>
|
||||
<Text className='text-xs opacity-50'>{process.item.Type}</Text>
|
||||
<Text className='font-semibold shrink'>{process.item.Name}</Text>
|
||||
<Text className='text-xs opacity-50'>
|
||||
{process.item.ProductionYear}
|
||||
</Text>
|
||||
<View className="flex flex-row items-center space-x-2 mt-1 text-purple-600">
|
||||
<View className='flex flex-row items-center space-x-2 mt-1 text-purple-600'>
|
||||
{process.progress === 0 ? (
|
||||
<ActivityIndicator size={"small"} color={"white"} />
|
||||
) : (
|
||||
<Text className="text-xs">{process.progress.toFixed(0)}%</Text>
|
||||
<Text className='text-xs'>{process.progress.toFixed(0)}%</Text>
|
||||
)}
|
||||
{process.speed && (
|
||||
<Text className="text-xs">{process.speed?.toFixed(2)}x</Text>
|
||||
<Text className='text-xs'>{process.speed?.toFixed(2)}x</Text>
|
||||
)}
|
||||
{eta(process) && (
|
||||
<Text className="text-xs">
|
||||
<Text className='text-xs'>
|
||||
{t("home.downloads.eta", { eta: eta(process) })}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View className="flex flex-row items-center space-x-2 mt-1 text-purple-600">
|
||||
<Text className="text-xs capitalize">{process.status}</Text>
|
||||
<View className='flex flex-row items-center space-x-2 mt-1 text-purple-600'>
|
||||
<Text className='text-xs capitalize'>{process.status}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
disabled={cancelJobMutation.isPending}
|
||||
onPress={() => cancelJobMutation.mutate(process.id)}
|
||||
className="ml-auto"
|
||||
className='ml-auto'
|
||||
>
|
||||
{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>
|
||||
</View>
|
||||
{process.status === "completed" && (
|
||||
<View className="flex flex-row mt-4 space-x-4">
|
||||
<View className='flex flex-row mt-4 space-x-4'>
|
||||
<Button
|
||||
onPress={() => {
|
||||
startDownload(process);
|
||||
}}
|
||||
className="w-full"
|
||||
className='w-full'
|
||||
>
|
||||
Download now
|
||||
</Button>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { TextProps } from "react-native";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import type React from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { TextProps } from "react-native";
|
||||
|
||||
interface DownloadSizeProps extends TextProps {
|
||||
items: BaseItemDto[];
|
||||
@@ -39,7 +40,7 @@ export const DownloadSize: React.FC<DownloadSizeProps> = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<Text className="text-xs text-neutral-500" {...props}>
|
||||
<Text className='text-xs text-neutral-500' {...props}>
|
||||
{sizeText}
|
||||
</Text>
|
||||
</>
|
||||
|
||||
@@ -1,22 +1,27 @@
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import { TouchableOpacity, TouchableOpacityProps, View } from "react-native";
|
||||
import {
|
||||
ActionSheetProvider,
|
||||
useActionSheet,
|
||||
} 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 { useDownload } from "@/providers/DownloadProvider";
|
||||
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 { DownloadSize } from "@/components/downloads/DownloadSize";
|
||||
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { Image } from "expo-image";
|
||||
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
||||
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
||||
|
||||
interface EpisodeCardProps extends TouchableOpacityProps {
|
||||
item: BaseItemDto;
|
||||
@@ -67,7 +72,7 @@ export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item, ...props }) => {
|
||||
// Cancelled
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}, [showActionSheetWithOptions, handleDeleteFile]);
|
||||
|
||||
@@ -76,27 +81,27 @@ export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item, ...props }) => {
|
||||
onPress={handleOpenFile}
|
||||
onLongPress={showActionSheet}
|
||||
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="mr-2">
|
||||
<ContinueWatchingPoster size="small" item={item} useEpisodePoster />
|
||||
<View className='flex flex-row items-start mb-2'>
|
||||
<View className='mr-2'>
|
||||
<ContinueWatchingPoster size='small' item={item} useEpisodePoster />
|
||||
</View>
|
||||
<View className="shrink">
|
||||
<Text numberOfLines={2} className="">
|
||||
<View className='shrink'>
|
||||
<Text numberOfLines={2} className=''>
|
||||
{item.Name}
|
||||
</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()}`}
|
||||
</Text>
|
||||
<Text className="text-xs text-neutral-500">
|
||||
<Text className='text-xs text-neutral-500'>
|
||||
{runtimeTicksToSeconds(item.RunTimeTicks)}
|
||||
</Text>
|
||||
<DownloadSize items={[item]} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Text numberOfLines={3} className="text-xs text-neutral-500 shrink">
|
||||
<Text numberOfLines={3} className='text-xs text-neutral-500 shrink'>
|
||||
{item.Overview}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
@@ -105,7 +110,7 @@ export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item, ...props }) => {
|
||||
|
||||
// Wrap the parent component with ActionSheetProvider
|
||||
export const EpisodeCardWithActionSheet: React.FC<EpisodeCardProps> = (
|
||||
props
|
||||
props,
|
||||
) => (
|
||||
<ActionSheetProvider>
|
||||
<EpisodeCard {...props} />
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import {
|
||||
ActionSheetProvider,
|
||||
useActionSheet,
|
||||
} from "@expo/react-native-action-sheet";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import type React from "react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
|
||||
import { DownloadSize } from "@/components/downloads/DownloadSize";
|
||||
@@ -69,14 +70,14 @@ export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
|
||||
// Cancelled
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}, [showActionSheetWithOptions, handleDeleteFile]);
|
||||
|
||||
return (
|
||||
<TouchableOpacity onPress={handleOpenFile} onLongPress={showActionSheet}>
|
||||
{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
|
||||
source={{
|
||||
uri: `data:image/jpeg;base64,${base64Image}`,
|
||||
@@ -89,16 +90,16 @@ export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
|
||||
/>
|
||||
</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
|
||||
name="image-outline"
|
||||
name='image-outline'
|
||||
size={24}
|
||||
color="gray"
|
||||
className="self-center mt-16"
|
||||
color='gray'
|
||||
className='self-center mt-16'
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
<View className="w-28">
|
||||
<View className='w-28'>
|
||||
<ItemCardText item={item} />
|
||||
</View>
|
||||
<DownloadSize items={[item]} />
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import {TouchableOpacity, View} from "react-native";
|
||||
import { DownloadSize } from "@/components/downloads/DownloadSize";
|
||||
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 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 { showActionSheetWithOptions } = useActionSheet();
|
||||
|
||||
@@ -18,16 +19,14 @@ export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({items}) => {
|
||||
return storage.getString(items[0].SeriesId!);
|
||||
}, []);
|
||||
|
||||
const deleteSeries = useCallback(
|
||||
async () => deleteItems(items),
|
||||
[items]
|
||||
);
|
||||
const deleteSeries = useCallback(async () => deleteItems(items), [items]);
|
||||
|
||||
const showActionSheet = useCallback(() => {
|
||||
const options = ["Delete", "Cancel"];
|
||||
const destructiveButtonIndex = 0;
|
||||
|
||||
showActionSheetWithOptions({
|
||||
showActionSheetWithOptions(
|
||||
{
|
||||
options,
|
||||
destructiveButtonIndex,
|
||||
},
|
||||
@@ -35,7 +34,7 @@ export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({items}) => {
|
||||
if (selectedIndex == destructiveButtonIndex) {
|
||||
deleteSeries();
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}, [showActionSheetWithOptions, deleteSeries]);
|
||||
|
||||
@@ -45,7 +44,7 @@ export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({items}) => {
|
||||
onLongPress={showActionSheet}
|
||||
>
|
||||
{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
|
||||
source={{
|
||||
uri: `data:image/jpeg;base64,${base64Image}`,
|
||||
@@ -56,25 +55,26 @@ export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({items}) => {
|
||||
resizeMode: "cover",
|
||||
}}
|
||||
/>
|
||||
<View
|
||||
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>
|
||||
<View 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>
|
||||
</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
|
||||
name="image-outline"
|
||||
name='image-outline'
|
||||
size={24}
|
||||
color="gray"
|
||||
className="self-center mt-16"
|
||||
color='gray'
|
||||
className='self-center mt-16'
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View className="w-28 mt-2 flex flex-col">
|
||||
<Text numberOfLines={2} className="">{items[0].SeriesName}</Text>
|
||||
<Text className="text-xs opacity-50">{items[0].ProductionYear}</Text>
|
||||
<View className='w-28 mt-2 flex flex-col'>
|
||||
<Text numberOfLines={2} className=''>
|
||||
{items[0].SeriesName}
|
||||
</Text>
|
||||
<Text className='text-xs opacity-50'>{items[0].ProductionYear}</Text>
|
||||
<DownloadSize items={items} />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Text } from "@/components/common/Text";
|
||||
import { FontAwesome, Ionicons } from "@expo/vector-icons";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { TouchableOpacity, View, ViewProps } from "react-native";
|
||||
import { TouchableOpacity, View, type ViewProps } from "react-native";
|
||||
import { FilterSheet } from "./FilterSheet";
|
||||
|
||||
interface FilterButtonProps<T> extends ViewProps {
|
||||
@@ -68,16 +68,16 @@ export const FilterButton = <T,>({
|
||||
</Text>
|
||||
{icon === "filter" ? (
|
||||
<Ionicons
|
||||
name="filter"
|
||||
name='filter'
|
||||
size={14}
|
||||
color="white"
|
||||
color='white'
|
||||
style={{ opacity: 0.5 }}
|
||||
/>
|
||||
) : (
|
||||
<FontAwesome
|
||||
name="sort"
|
||||
name='sort'
|
||||
size={14}
|
||||
color="white"
|
||||
color='white'
|
||||
style={{ opacity: 0.5 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
import {
|
||||
BottomSheetBackdrop,
|
||||
BottomSheetBackdropProps,
|
||||
type BottomSheetBackdropProps,
|
||||
BottomSheetFlatList,
|
||||
BottomSheetModal,
|
||||
BottomSheetScrollView,
|
||||
BottomSheetView,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import type React from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { StyleSheet, TouchableOpacity, View, ViewProps } from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
type ViewProps,
|
||||
} from "react-native";
|
||||
import { Button } from "../Button";
|
||||
import { Input } from "../common/Input";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface Props<T> extends ViewProps {
|
||||
open: boolean;
|
||||
@@ -130,7 +130,7 @@ export const FilterSheet = <T,>({
|
||||
appearsOnIndex={0}
|
||||
/>
|
||||
),
|
||||
[]
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -153,18 +153,20 @@ export const FilterSheet = <T,>({
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
<View className="px-4 mt-2 mb-8">
|
||||
<Text className="font-bold text-2xl">{title}</Text>
|
||||
<Text className="mb-2 text-neutral-500">{t("search.x_items", {count: _data?.length})}</Text>
|
||||
<View className='px-4 mt-2 mb-8'>
|
||||
<Text className='font-bold text-2xl'>{title}</Text>
|
||||
<Text className='mb-2 text-neutral-500'>
|
||||
{t("search.x_items", { count: _data?.length })}
|
||||
</Text>
|
||||
{showSearch && (
|
||||
<Input
|
||||
placeholder={t("search.search")}
|
||||
className="my-2"
|
||||
className='my-2'
|
||||
value={search}
|
||||
onChangeText={(text) => {
|
||||
setSearch(text);
|
||||
}}
|
||||
returnKeyType="done"
|
||||
returnKeyType='done'
|
||||
/>
|
||||
)}
|
||||
<View
|
||||
@@ -172,7 +174,7 @@ export const FilterSheet = <T,>({
|
||||
borderRadius: 20,
|
||||
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) => (
|
||||
<View key={index}>
|
||||
@@ -185,20 +187,20 @@ export const FilterSheet = <T,>({
|
||||
}, 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>
|
||||
{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>
|
||||
<View
|
||||
style={{
|
||||
height: StyleSheet.hairlineWidth,
|
||||
}}
|
||||
className="h-1 divide-neutral-700 "
|
||||
className='h-1 divide-neutral-700 '
|
||||
></View>
|
||||
</View>
|
||||
))}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user