Compare commits

..

2 Commits

Author SHA1 Message Date
Alex Kim
017bd4d074 Fixed file paths in the controls directory 2025-02-16 14:06:30 +11:00
herrrta
8b3141dfc6 fix: IOS video player black screens
- restores player view when re-entering apps foreground
- added logger
2025-02-15 15:16:25 -05:00
309 changed files with 5461 additions and 11396 deletions

1
.gitattributes vendored
View File

@@ -1 +0,0 @@
.modules/vlc-player/Frameworks/*.xcframework filter=lfs diff=lfs merge=lfs -text

View File

@@ -43,7 +43,6 @@ body:
label: Version label: Version
description: What version of Streamyfin are you running? description: What version of Streamyfin are you running?
options: options:
- 0.27.0
- 0.26.1 - 0.26.1
- 0.26.0 - 0.26.0
- 0.25.0 - 0.25.0

View File

@@ -1,39 +0,0 @@
name: Handle Stale Issues
on:
schedule:
- cron: "30 1 * * *" # Runs at 1:30 UTC every day
jobs:
stale:
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: write
steps:
- uses: actions/stale@v9
with:
# Issue specific settings
days-before-issue-stale: 90
days-before-issue-close: 7
stale-issue-label: "stale"
stale-issue-message: |
This issue has been automatically marked as stale because it has had no activity in the last 30 days.
If this issue is still relevant, please leave a comment to keep it open.
Otherwise, it will be closed in 7 days if no further activity occurs.
Thank you for your contributions!
close-issue-message: |
This issue has been automatically closed because it has been inactive for 7 days since being marked as stale.
If you believe this issue is still relevant, please feel free to reopen it and add a comment explaining the current status.
# Pull request settings (disabled)
days-before-pr-stale: -1
days-before-pr-close: -1
# Other settings
repo-token: ${{ secrets.GITHUB_TOKEN }}
operations-per-run: 100
exempt-issue-labels: "Roadmap v1,help needed,enhancement"

5
.gitignore vendored
View File

@@ -10,8 +10,6 @@ npm-debug.*
*.orig.* *.orig.*
web-build/ web-build/
modules/vlc-player/android/build modules/vlc-player/android/build
modules/vlc-player/android/.gradle
bun.lockb
# macOS # macOS
.DS_Store .DS_Store
@@ -44,6 +42,3 @@ credentials.json
.vscode/ .vscode/
.idea/ .idea/
.ruby-lsp .ruby-lsp
modules/hls-downloader/android/build
streamyfin-4fec1-firebase-adminsdk.json
.env

View File

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

17
.vscode/settings.json vendored
View File

@@ -1,24 +1,15 @@
{ {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"[javascript]": { "[javascript]": {
"editor.defaultFormatter": "biomejs.biome", "editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true "editor.formatOnSave": true
}, },
"[typescriptreact]": { "[typescriptreact]": {
"editor.defaultFormatter": "biomejs.biome", "editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true "editor.formatOnSave": true
}, },
"prettier.printWidth": 120,
"[swift]": { "[swift]": {
"editor.defaultFormatter": "sswg.swift-lang" "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
} }
} }

View File

@@ -1,6 +0,0 @@
e2e:
maestro start-device --platform android
maestro test login.yaml
e2e-setup:
curl -fsSL "https://get.maestro.mobile.dev" | bash

View File

@@ -85,9 +85,9 @@ We welcome any help to make Streamyfin better. If you'd like to contribute, plea
1. Use node `>20` 1. Use node `>20`
2. Install dependencies `bun i && bun run submodule-reload` 2. Install dependencies `bun i && bun run submodule-reload`
3. Make sure you have xcode and/or android studio installed. (follow the guides for expo: https://docs.expo.dev/workflow/android-studio-emulator/) 3. Make sure you have xcode and/or android studio installed.
4. run `npm run prebuild` 4. run `npm run prebuild`
5. Create an expo dev build by running `npm run ios` or `npm run android`. This will open a simulator on your computer and run the app. 5. Create an expo dev build by running `npm run ios` or `nom run android`. This will open a simulator on your computer and run the app.
For the TV version suffix the npm commands with `:tv`. For the TV version suffix the npm commands with `:tv`.

View File

@@ -1,14 +1,11 @@
module.exports = ({ config }) => { module.exports = ({ config }) => {
if (process.env.EXPO_TV !== "1") { if (process.env.EXPO_TV != "1") {
config.plugins.push([ config.plugins.push([
"react-native-google-cast", "react-native-google-cast",
{ useDefaultExpandedMediaControls: true }, { useDefaultExpandedMediaControls: true },
]); ]);
} }
return { return {
android: {
googleServicesFile: process.env.GOOGLE_SERVICES_JSON,
},
...config, ...config,
}; };
}; };

View File

@@ -2,7 +2,7 @@
"expo": { "expo": {
"name": "Streamyfin", "name": "Streamyfin",
"slug": "streamyfin", "slug": "streamyfin",
"version": "0.27.0", "version": "0.26.1",
"orientation": "default", "orientation": "default",
"icon": "./assets/images/icon.png", "icon": "./assets/images/icon.png",
"scheme": "streamyfin", "scheme": "streamyfin",
@@ -33,16 +33,14 @@
"jsEngine": "hermes", "jsEngine": "hermes",
"versionCode": 53, "versionCode": 53,
"adaptiveIcon": { "adaptiveIcon": {
"foregroundImage": "./assets/images/adaptive_icon.png", "foregroundImage": "./assets/images/adaptive_icon.png"
"backgroundColor": "#464646"
}, },
"package": "com.fredrikburmester.streamyfin", "package": "com.fredrikburmester.streamyfin",
"permissions": [ "permissions": [
"android.permission.FOREGROUND_SERVICE", "android.permission.FOREGROUND_SERVICE",
"android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK", "android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK",
"android.permission.WRITE_SETTINGS" "android.permission.WRITE_SETTINGS"
], ]
"googleServicesFile": "./google-services.json"
}, },
"plugins": [ "plugins": [
"@react-native-tvos/config-tv", "@react-native-tvos/config-tv",
@@ -120,13 +118,6 @@
"image": "./assets/images/StreamyFinFinal.png", "image": "./assets/images/StreamyFinFinal.png",
"imageWidth": 100 "imageWidth": 100
} }
],
[
"expo-notifications",
{
"icon": "./assets/images/notification.png",
"color": "#9333EA"
}
] ]
], ],
"experiments": { "experiments": {
@@ -140,7 +131,7 @@
"projectId": "e79219d1-797f-4fbe-9fa1-cfd360690a68" "projectId": "e79219d1-797f-4fbe-9fa1-cfd360690a68"
} }
}, },
"owner": "streamyfin", "owner": "fredrikburmester",
"runtimeVersion": { "runtimeVersion": {
"policy": "appVersion" "policy": "appVersion"
}, },

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,498 @@
import { HomeIndex } from "@/components/settings/HomeIndex"; import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { LargeMovieCarousel } from "@/components/home/LargeMovieCarousel";
import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList";
import { Loader } from "@/components/Loader";
import { MediaListSection } from "@/components/medialists/MediaListSection";
import { Colors } from "@/constants/Colors";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { Feather, Ionicons } from "@expo/vector-icons";
import { Api } from "@jellyfin/sdk";
import {
BaseItemDto,
BaseItemKind,
} from "@jellyfin/sdk/lib/generated-client/models";
import {
getItemsApi,
getSuggestionsApi,
getTvShowsApi,
getUserLibraryApi,
getUserViewsApi,
} from "@jellyfin/sdk/lib/utils/api";
import NetInfo from "@react-native-community/netinfo";
import { QueryFunction, useQuery } from "@tanstack/react-query";
import { useNavigation, useRouter } from "expo-router";
import { useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Platform } from "react-native";
import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
RefreshControl,
ScrollView,
TouchableOpacity,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import {
useSplashScreenLoading,
useSplashScreenVisible,
} from "@/providers/SplashScreenProvider";
export default function page() { type ScrollingCollectionListSection = {
return <HomeIndex />; type: "ScrollingCollectionList";
title?: string;
queryKey: (string | undefined | null)[];
queryFn: QueryFunction<BaseItemDto[]>;
orientation?: "horizontal" | "vertical";
};
type MediaListSection = {
type: "MediaListSection";
queryKey: (string | undefined)[];
queryFn: QueryFunction<BaseItemDto>;
};
type Section = ScrollingCollectionListSection | MediaListSection;
export default function index() {
const router = useRouter();
const { t } = useTranslation();
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
const [loading, setLoading] = useState(false);
const [
settings,
updateSettings,
pluginSettings,
setPluginSettings,
refreshStreamyfinPluginSettings,
] = useSettings();
const [isConnected, setIsConnected] = useState<boolean | null>(null);
const [loadingRetry, setLoadingRetry] = useState(false);
const navigation = useNavigation();
const insets = useSafeAreaInsets();
if (!Platform.isTV) {
const { downloadedFiles, cleanCacheDirectory } = useDownload();
useEffect(() => {
const hasDownloads = downloadedFiles && downloadedFiles.length > 0;
navigation.setOptions({
headerLeft: () => (
<TouchableOpacity
onPress={() => {
router.push("/(auth)/downloads");
}}
className="p-2"
>
<Feather
name="download"
color={hasDownloads ? Colors.primary : "white"}
size={22}
/>
</TouchableOpacity>
),
});
}, [downloadedFiles, navigation, router]);
useEffect(() => {
cleanCacheDirectory().catch((e) =>
console.error("Something went wrong cleaning cache directory")
);
}, []);
}
const checkConnection = useCallback(async () => {
setLoadingRetry(true);
const state = await NetInfo.fetch();
setIsConnected(state.isConnected);
setLoadingRetry(false);
}, []);
useEffect(() => {
const unsubscribe = NetInfo.addEventListener((state) => {
if (state.isConnected == false || state.isInternetReachable === false)
setIsConnected(false);
else setIsConnected(true);
});
NetInfo.fetch().then((state) => {
setIsConnected(state.isConnected);
});
// cleanCacheDirectory().catch((e) =>
// console.error("Something went wrong cleaning cache directory")
// );
return () => {
unsubscribe();
};
}, []);
const {
data,
isError: e1,
isLoading: l1,
} = useQuery({
queryKey: ["home", "userViews", user?.Id],
queryFn: async () => {
if (!api || !user?.Id) {
return null;
}
const response = await getUserViewsApi(api).getUserViews({
userId: user.Id,
});
return response.data.Items || null;
},
enabled: !!api && !!user?.Id,
staleTime: 60 * 1000,
});
// show splash screen until query loaded
useSplashScreenLoading(l1);
const splashScreenVisible = useSplashScreenVisible();
const userViews = useMemo(
() => data?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!)),
[data, settings?.hiddenLibraries]
);
const collections = useMemo(() => {
const allow = ["movies", "tvshows"];
return (
userViews?.filter(
(c) => c.CollectionType && allow.includes(c.CollectionType)
) || []
);
}, [userViews]);
const invalidateCache = useInvalidatePlaybackProgressCache();
const refetch = useCallback(async () => {
setLoading(true);
await refreshStreamyfinPluginSettings();
await invalidateCache();
setLoading(false);
}, []);
const createCollectionConfig = useCallback(
(
title: string,
queryKey: string[],
includeItemTypes: BaseItemKind[],
parentId: string | undefined
): ScrollingCollectionListSection => ({
title,
queryKey,
queryFn: async () => {
if (!api) return [];
return (
(
await getUserLibraryApi(api).getLatestMedia({
userId: user?.Id,
limit: 20,
fields: ["PrimaryImageAspectRatio", "Path"],
imageTypeLimit: 1,
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
includeItemTypes,
parentId,
})
).data || []
);
},
type: "ScrollingCollectionList",
}),
[api, user?.Id]
);
let sections: Section[] = [];
if (!settings?.home || !settings?.home?.sections) {
sections = useMemo(() => {
if (!api || !user?.Id) return [];
const latestMediaViews = collections.map((c) => {
const includeItemTypes: BaseItemKind[] =
c.CollectionType === "tvshows" ? ["Series"] : ["Movie"];
const title = t("home.recently_added_in", { libraryName: c.Name });
const queryKey = [
"home",
"recentlyAddedIn" + c.CollectionType,
user?.Id!,
c.Id!,
];
return createCollectionConfig(
title || "",
queryKey,
includeItemTypes,
c.Id
);
});
const ss: Section[] = [
{
title: t("home.continue_watching"),
queryKey: ["home", "resumeItems"],
queryFn: async () =>
(
await getItemsApi(api).getResumeItems({
userId: user.Id,
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
includeItemTypes: ["Movie", "Series", "Episode"],
})
).data.Items || [],
type: "ScrollingCollectionList",
orientation: "horizontal",
},
{
title: t("home.next_up"),
queryKey: ["home", "nextUp-all"],
queryFn: async () =>
(
await getTvShowsApi(api).getNextUp({
userId: user?.Id,
fields: ["MediaSourceCount"],
limit: 20,
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
enableResumable: false,
})
).data.Items || [],
type: "ScrollingCollectionList",
orientation: "horizontal",
},
...latestMediaViews,
// ...(mediaListCollections?.map(
// (ml) =>
// ({
// title: ml.Name,
// queryKey: ["home", "mediaList", ml.Id!],
// queryFn: async () => ml,
// type: "MediaListSection",
// orientation: "vertical",
// } as Section)
// ) || []),
{
title: t("home.suggested_movies"),
queryKey: ["home", "suggestedMovies", user?.Id],
queryFn: async () =>
(
await getSuggestionsApi(api).getSuggestions({
userId: user?.Id,
limit: 10,
mediaType: ["Video"],
type: ["Movie"],
})
).data.Items || [],
type: "ScrollingCollectionList",
orientation: "vertical",
},
{
title: t("home.suggested_episodes"),
queryKey: ["home", "suggestedEpisodes", user?.Id],
queryFn: async () => {
try {
const suggestions = await getSuggestions(api, user.Id);
const nextUpPromises = suggestions.map((series) =>
getNextUp(api, user.Id, series.Id)
);
const nextUpResults = await Promise.all(nextUpPromises);
return nextUpResults.filter((item) => item !== null) || [];
} catch (error) {
console.error("Error fetching data:", error);
return [];
}
},
type: "ScrollingCollectionList",
orientation: "horizontal",
},
];
return ss;
}, [api, user?.Id, collections]);
} else {
sections = useMemo(() => {
if (!api || !user?.Id) return [];
const ss: Section[] = [];
for (const key in settings.home?.sections) {
// @ts-expect-error
const section = settings.home?.sections[key];
const id = section.title || key;
ss.push({
title: id,
queryKey: ["home", id],
queryFn: async () => {
if (section.items) {
const response = await getItemsApi(api).getItems({
userId: user?.Id,
limit: section.items?.limit || 25,
recursive: true,
includeItemTypes: section.items?.includeItemTypes,
sortBy: section.items?.sortBy,
sortOrder: section.items?.sortOrder,
filters: section.items?.filters,
parentId: section.items?.parentId,
});
return response.data.Items || [];
} else if (section.nextUp) {
const response = await getTvShowsApi(api).getNextUp({
userId: user?.Id,
fields: ["MediaSourceCount"],
limit: section.items?.limit || 25,
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
enableResumable: section.items?.enableResumable || false,
enableRewatching: section.items?.enableRewatching || false,
});
return response.data.Items || [];
}
return [];
},
type: "ScrollingCollectionList",
orientation: section?.orientation || "vertical",
});
}
return ss;
}, [api, user?.Id, settings.home?.sections]);
}
if (isConnected === false) {
return (
<View className="flex flex-col items-center justify-center h-full -mt-6 px-8">
<Text className="text-3xl font-bold mb-2">{t("home.no_internet")}</Text>
<Text className="text-center opacity-70">
{t("home.no_internet_message")}
</Text>
<View className="mt-4">
<Button
color="purple"
onPress={() => router.push("/(auth)/downloads")}
justify="center"
iconRight={
<Ionicons name="arrow-forward" size={20} color="white" />
}
>
{t("home.go_to_downloads")}
</Button>
<Button
color="black"
onPress={() => {
checkConnection();
}}
justify="center"
className="mt-2"
iconRight={
loadingRetry ? null : (
<Ionicons name="refresh" size={20} color="white" />
)
}
>
{loadingRetry ? (
<ActivityIndicator size={"small"} color={"white"} />
) : (
"Retry"
)}
</Button>
</View>
</View>
);
}
if (e1)
return (
<View className="flex flex-col items-center justify-center h-full -mt-6">
<Text className="text-3xl font-bold mb-2">{t("home.oops")}</Text>
<Text className="text-center opacity-70">
{t("home.error_message")}
</Text>
</View>
);
// this spinner should only show up, when user navigates here
// on launch the splash screen is used for loading
if (l1 && !splashScreenVisible)
return (
<View className="justify-center items-center h-full">
<Loader />
</View>
);
return (
<ScrollView
nestedScrollEnabled
contentInsetAdjustmentBehavior="automatic"
refreshControl={
<RefreshControl refreshing={loading} onRefresh={refetch} />
}
contentContainerStyle={{
paddingLeft: insets.left,
paddingRight: insets.right,
paddingBottom: 16,
}}
>
<View className="flex flex-col space-y-4">
<LargeMovieCarousel />
{sections.map((section, index) => {
if (section.type === "ScrollingCollectionList") {
return (
<ScrollingCollectionList
key={index}
title={section.title}
queryKey={section.queryKey}
queryFn={section.queryFn}
orientation={section.orientation}
hideIfEmpty
/>
);
} else if (section.type === "MediaListSection") {
return (
<MediaListSection
key={index}
queryKey={section.queryKey}
queryFn={section.queryFn}
/>
);
}
return null;
})}
</View>
</ScrollView>
);
}
// Function to get suggestions
async function getSuggestions(api: Api, userId: string | undefined) {
if (!userId) return [];
const response = await getSuggestionsApi(api).getSuggestions({
userId,
limit: 10,
mediaType: ["Unknown"],
type: ["Series"],
});
return response.data.Items ?? [];
}
// Function to get the next up TV show for a series
async function getNextUp(
api: Api,
userId: string | undefined,
seriesId: string | undefined
) {
if (!userId || !seriesId) return null;
const response = await getTvShowsApi(api).getNextUp({
userId,
seriesId,
limit: 1,
});
return response.data.Items?.[0] ?? null;
} }

View File

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

View File

@@ -1,384 +0,0 @@
import { Badge } from "@/components/Badge";
import { Loader } from "@/components/Loader";
import { Text } from "@/components/common/Text";
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";
export default function page() {
const { sessions, isLoading } = useSessions({} as useSessionsProps);
const { t } = useTranslation();
if (isLoading)
return (
<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>
);
return (
<FlashList
contentInsetAdjustmentBehavior='automatic'
contentContainerStyle={{
paddingTop: 17,
paddingHorizontal: 17,
paddingBottom: 150,
}}
data={sessions}
renderItem={({ item }) => <SessionCard session={item} />}
keyExtractor={(item) => item.Id || ""}
estimatedItemSize={200}
/>
);
}
interface SessionCardProps {
session: SessionInfoDto;
}
const SessionCard = ({ session }: SessionCardProps) => {
const api = useAtomValue(apiAtom);
const [remainingTicks, setRemainingTicks] = useState<number>(0);
const tick = () => {
if (session.PlayState?.IsPaused) return;
setRemainingTicks(remainingTicks - 10000000);
};
const getProgressPercentage = () => {
if (!session.NowPlayingItem || !session.NowPlayingItem.RunTimeTicks) {
return 0;
}
return Math.round(
(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
) {
const remainingTimeTicks = duration - currentTime;
setRemainingTicks(remainingTimeTicks);
}
}, [session]);
const { data: ipInfo } = useQuery({
queryKey: ["ipinfo", session.RemoteEndPoint],
cacheTime: Number.POSITIVE_INFINITY,
queryFn: async () => {
const resp = await api.axiosInstance.get(
`https://freeipapi.com/api/json/${session.RemoteEndPoint}`,
);
return resp.data;
},
});
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>
<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'>
{`S${session.NowPlayingItem.ParentIndexNumber?.toString()}:E${session.NowPlayingItem.IndexNumber?.toString()}`}
{" - "}
{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'>
{session.UserName}
{"\n"}
{session.Client}
{"\n"}
{session.DeviceName}
{"\n"}
{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'>
{!session.PlayState?.IsPaused ? (
<Ionicons name='play' size={14} color='white' />
) : (
<Ionicons name='pause' size={14} color='white' />
)}
</Text>
<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={`bg-purple-600 h-full`}
style={{
width: `${getProgressPercentage()}%`,
}}
/>
</View>
</View>
</View>
</View>
<TranscodingView session={session} />
</View>
);
};
interface TranscodingBadgesProps {
properties: StreamProps;
}
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' />,
} as const;
const icon = (val: string) => {
return (
iconMap[val as keyof typeof iconMap] ?? (
<Ionicons name='layers-outline' size={12} color='white' />
)
);
};
const formatVal = (key: string, val: any) => {
switch (key) {
case "bitrate":
return formatBitrate(val);
case "hwType":
return val === HardwareAccelerationType.None ? "sw" : "hw";
default:
return val;
}
};
return Object.entries(properties)
.filter(([_, value]) => value !== undefined && value !== null)
.map(([key]) => (
<Badge
key={key}
variant='gray'
className='m-0 p-0 pt-0.5 mr-1'
text={formatVal(key, properties[key as keyof StreamProps])}
iconLeft={icon(key)}
/>
));
};
interface StreamProps {
hwType?: HardwareAccelerationType | null | undefined;
resolution?: string | null | undefined;
language?: string | null | undefined;
codec?: string | null | undefined;
bitrate?: number | null | undefined;
videoRange?: string | null | undefined;
audioChannels?: string | null | undefined;
}
interface TranscodingStreamViewProps {
title: string | undefined;
value?: string;
isTranscoding: boolean;
transcodeValue?: string | undefined | null;
properties: StreamProps;
transcodeProperties?: StreamProps;
}
const TranscodingStreamView = ({
title,
isTranscoding,
properties,
transcodeProperties,
value,
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'>
<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'
/>
</Text>
<Text className='flex-1 text-sm mt-1'>
<TranscodingBadges properties={transcodeProperties} />
</Text>
</View>
</>
) : null}
</View>
);
};
const TranscodingView = ({ session }: SessionCardProps) => {
const videoStream = useMemo(() => {
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;
}, [session.PlayState?.AudioStreamIndex]);
const subtitleStream = useMemo(() => {
const index = session.PlayState?.SubtitleStreamIndex;
return index !== null && index !== undefined
? session.NowPlayingItem?.MediaStreams?.[index]
: undefined;
}, [session.PlayState?.SubtitleStreamIndex]);
const isTranscoding = useMemo(() => {
return (
session.PlayState?.PlayMethod == "Transcode" && session.TranscodingInfo
);
}, [session.PlayState?.PlayMethod, session.TranscodingInfo]);
const videoStreamTitle = () => {
return videoStream?.DisplayTitle?.split(" ")[0];
};
return (
<View className='flex flex-col bg-neutral-800 rounded-b-2xl p-4 pt-2'>
<TranscodingStreamView
title='Video'
properties={{
resolution: videoStreamTitle(),
bitrate: videoStream?.BitRate,
codec: videoStream?.Codec,
}}
transcodeProperties={{
hwType: session.TranscodingInfo?.HardwareAccelerationType,
bitrate: session.TranscodingInfo?.Bitrate,
codec: session.TranscodingInfo?.VideoCodec,
}}
isTranscoding={
isTranscoding && !session.TranscodingInfo?.IsVideoDirect
? true
: false
}
/>
<TranscodingStreamView
title='Audio'
properties={{
language: audioStream?.Language,
bitrate: audioStream?.BitRate,
codec: audioStream?.Codec,
audioChannels: audioStream?.ChannelLayout,
}}
transcodeProperties={{
codec: session.TranscodingInfo?.AudioCodec,
audioChannels: session.TranscodingInfo?.AudioChannels?.toString(),
}}
isTranscoding={
isTranscoding && !session.TranscodingInfo?.IsVideoDirect
? true
: false
}
/>
{subtitleStream && (
<>
<TranscodingStreamView
title='Subtitle'
isTranscoding={false}
properties={{
language: subtitleStream?.Language,
codec: subtitleStream?.Codec,
}}
transcodeValue={null}
/>
</>
)}
</View>
);
};

View File

@@ -1,10 +1,8 @@
import { Platform } from "react-native";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { ListGroup } from "@/components/list/ListGroup"; import { ListGroup } from "@/components/list/ListGroup";
import { ListItem } from "@/components/list/ListItem"; import { ListItem } from "@/components/list/ListItem";
import { AppLanguageSelector } from "@/components/settings/AppLanguageSelector";
import { AudioToggles } from "@/components/settings/AudioToggles"; import { AudioToggles } from "@/components/settings/AudioToggles";
import { ChromecastSettings } from "@/components/settings/ChromecastSettings";
import DownloadSettings from "@/components/settings/DownloadSettings";
import { MediaProvider } from "@/components/settings/MediaContext"; import { MediaProvider } from "@/components/settings/MediaContext";
import { MediaToggles } from "@/components/settings/MediaToggles"; import { MediaToggles } from "@/components/settings/MediaToggles";
import { OtherSettings } from "@/components/settings/OtherSettings"; import { OtherSettings } from "@/components/settings/OtherSettings";
@@ -12,23 +10,24 @@ import { PluginSettings } from "@/components/settings/PluginSettings";
import { QuickConnect } from "@/components/settings/QuickConnect"; import { QuickConnect } from "@/components/settings/QuickConnect";
import { StorageSettings } from "@/components/settings/StorageSettings"; import { StorageSettings } from "@/components/settings/StorageSettings";
import { SubtitleToggles } from "@/components/settings/SubtitleToggles"; import { SubtitleToggles } from "@/components/settings/SubtitleToggles";
import { AppLanguageSelector } from "@/components/settings/AppLanguageSelector";
import { UserInfo } from "@/components/settings/UserInfo"; import { UserInfo } from "@/components/settings/UserInfo";
import { useHaptic } from "@/hooks/useHaptic";
import { useJellyfin } from "@/providers/JellyfinProvider"; import { useJellyfin } from "@/providers/JellyfinProvider";
import { userAtom } from "@/providers/JellyfinProvider";
import { clearLogs } from "@/utils/log"; import { clearLogs } from "@/utils/log";
import { storage } from "@/utils/mmkv"; import { useHaptic } from "@/hooks/useHaptic";
import { useNavigation, useRouter } from "expo-router"; import { useNavigation, useRouter } from "expo-router";
import { t } from "i18next"; import { t } from "i18next";
import { useAtom } from "jotai"; import React, { lazy, useEffect } from "react";
import React, { useEffect } from "react"; import { ScrollView, TouchableOpacity, View } from "react-native";
import { ScrollView, Switch, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { storage } from "@/utils/mmkv";
const DownloadSettings = lazy(
() => import("@/components/settings/DownloadSettings")
);
export default function settings() { export default function settings() {
const router = useRouter(); const router = useRouter();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const [user] = useAtom(userAtom);
const { logout } = useJellyfin(); const { logout } = useJellyfin();
const successHapticFeedback = useHaptic("success"); const successHapticFeedback = useHaptic("success");
@@ -46,7 +45,7 @@ export default function settings() {
logout(); logout();
}} }}
> >
<Text className='text-red-600'> <Text className="text-red-600">
{t("home.settings.log_out_button")} {t("home.settings.log_out_button")}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
@@ -61,27 +60,24 @@ export default function settings() {
paddingRight: insets.right, paddingRight: insets.right,
}} }}
> >
<View className='p-4 flex flex-col gap-y-4'> <View className="p-4 flex flex-col gap-y-4">
<UserInfo /> <UserInfo />
<QuickConnect className="mb-4" />
<QuickConnect className='mb-4' />
<MediaProvider> <MediaProvider>
<MediaToggles className='mb-4' /> <MediaToggles className="mb-4" />
<AudioToggles className='mb-4' /> <AudioToggles className="mb-4" />
<SubtitleToggles className='mb-4' /> <SubtitleToggles className="mb-4" />
</MediaProvider> </MediaProvider>
<OtherSettings /> <OtherSettings />
<DownloadSettings /> {!Platform.isTV && <DownloadSettings />}
<PluginSettings /> <PluginSettings />
<AppLanguageSelector /> <AppLanguageSelector />
<ChromecastSettings />
<ListGroup title={"Intro"}> <ListGroup title={"Intro"}>
<ListItem <ListItem
onPress={() => { onPress={() => {
@@ -90,7 +86,7 @@ export default function settings() {
title={t("home.settings.intro.show_intro")} title={t("home.settings.intro.show_intro")}
/> />
<ListItem <ListItem
textColor='red' textColor="red"
onPress={() => { onPress={() => {
storage.set("hasShownIntro", false); storage.set("hasShownIntro", false);
}} }}
@@ -98,7 +94,7 @@ export default function settings() {
/> />
</ListGroup> </ListGroup>
<View className='mb-4'> <View className="mb-4">
<ListGroup title={t("home.settings.logs.logs_title")}> <ListGroup title={t("home.settings.logs.logs_title")}>
<ListItem <ListItem
onPress={() => router.push("/settings/logs/page")} onPress={() => router.push("/settings/logs/page")}
@@ -106,7 +102,7 @@ export default function settings() {
title={t("home.settings.logs.logs_title")} title={t("home.settings.logs.logs_title")}
/> />
<ListItem <ListItem
textColor='red' textColor="red"
onPress={onClearLogsClicked} onPress={onClearLogsClicked}
title={t("home.settings.logs.delete_all_logs")} title={t("home.settings.logs.delete_all_logs")}
/> />

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,43 +1,37 @@
import { Input } from "@/components/common/Input";
import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster"; import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
import { Tag } from "@/components/GenreTags"; import { Tag } from "@/components/GenreTags";
import { ItemCardText } from "@/components/ItemCardText"; import { ItemCardText } from "@/components/ItemCardText";
import { Text } from "@/components/common/Text"; import { JellyserrIndexPage } from "@/components/jellyseerr/JellyseerrIndexPage";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import { FilterButton } from "@/components/filters/FilterButton";
import {
JellyseerrSearchSort,
JellyserrIndexPage,
} from "@/components/jellyseerr/JellyseerrIndexPage";
import MoviePoster from "@/components/posters/MoviePoster"; import MoviePoster from "@/components/posters/MoviePoster";
import SeriesPoster from "@/components/posters/SeriesPoster"; import SeriesPoster from "@/components/posters/SeriesPoster";
import { LoadingSkeleton } from "@/components/search/LoadingSkeleton"; import { LoadingSkeleton } from "@/components/search/LoadingSkeleton";
import { SearchItemWrapper } from "@/components/search/SearchItemWrapper"; import { SearchItemWrapper } from "@/components/search/SearchItemWrapper";
import { useJellyseerr } from "@/hooks/useJellyseerr"; import { useJellyseerr } from "@/hooks/useJellyseerr";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { sortOrderOptions } from "@/utils/atoms/filters";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { eventBus } from "@/utils/eventBus"; import {
import type {
BaseItemDto, BaseItemDto,
BaseItemKind, BaseItemKind,
} from "@jellyfin/sdk/lib/generated-client/models"; } from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi, getSearchApi } from "@jellyfin/sdk/lib/utils/api"; import { getItemsApi, getSearchApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import axios from "axios"; import axios from "axios";
import { router, useLocalSearchParams, useNavigation } from "expo-router"; import { Href, router, useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import React, { import React, {
useCallback, useCallback,
useEffect, useEffect,
useLayoutEffect, useLayoutEffect,
useMemo, useMemo,
useRef,
useState, useState,
} from "react"; } from "react";
import { useTranslation } from "react-i18next";
import { Platform, ScrollView, TouchableOpacity, View } from "react-native"; import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useDebounce } from "use-debounce"; import { useDebounce } from "use-debounce";
import { useTranslation } from "react-i18next";
type SearchType = "Library" | "Discover"; type SearchType = "Library" | "Discover";
@@ -68,15 +62,6 @@ export default function search() {
const [settings] = useSettings(); const [settings] = useSettings();
const { jellyseerrApi } = useJellyseerr(); const { jellyseerrApi } = useJellyseerr();
const [jellyseerrOrderBy, setJellyseerrOrderBy] =
useState<JellyseerrSearchSort>(
JellyseerrSearchSort[
JellyseerrSearchSort.DEFAULT
] as unknown as JellyseerrSearchSort,
);
const [jellyseerrSortOrder, setJellyseerrSortOrder] = useState<
"asc" | "desc"
>("desc");
const searchEngine = useMemo(() => { const searchEngine = useMemo(() => {
return settings?.searchEngine || "Jellyfin"; return settings?.searchEngine || "Jellyfin";
@@ -132,47 +117,24 @@ export default function search() {
return []; // Ensure an empty array is returned in case of an error return []; // Ensure an empty array is returned in case of an error
} }
}, },
[api, searchEngine, settings], [api, searchEngine, settings]
); );
type HeaderSearchBarRef = {
focus: () => void;
blur: () => void;
setText: (text: string) => void;
clearText: () => void;
cancelSearch: () => void;
};
const searchBarRef = useRef<HeaderSearchBarRef>(null);
const navigation = useNavigation(); const navigation = useNavigation();
useLayoutEffect(() => { useLayoutEffect(() => {
navigation.setOptions({ navigation.setOptions({
headerSearchBarOptions: { headerSearchBarOptions: {
ref: searchBarRef,
placeholder: t("search.search"), placeholder: t("search.search"),
onChangeText: (e: any) => { onChangeText: (e: any) => {
router.setParams({ q: "" }); router.setParams({ q: "" });
setSearch(e.nativeEvent.text); setSearch(e.nativeEvent.text);
}, },
hideWhenScrolling: false, hideWhenScrolling: false,
autoFocus: false, autoFocus: true,
}, },
}); });
}, [navigation]); }, [navigation]);
useEffect(() => {
const unsubscribe = eventBus.on("searchTabPressed", () => {
// Screen not actuve
if (!searchBarRef.current) return;
// Screen is active, focus search bar
searchBarRef.current?.focus();
});
return () => {
unsubscribe();
};
}, []);
const { data: movies, isFetching: l1 } = useQuery({ const { data: movies, isFetching: l1 } = useQuery({
queryKey: ["search", "movies", debouncedSearch], queryKey: ["search", "movies", debouncedSearch],
queryFn: () => queryFn: () =>
@@ -240,81 +202,38 @@ export default function search() {
return ( return (
<> <>
<ScrollView <ScrollView
keyboardDismissMode='on-drag' keyboardDismissMode="on-drag"
contentInsetAdjustmentBehavior='automatic' contentInsetAdjustmentBehavior="automatic"
contentContainerStyle={{ contentContainerStyle={{
paddingLeft: insets.left, paddingLeft: insets.left,
paddingRight: insets.right, paddingRight: insets.right,
}} }}
> >
<View <View className="flex flex-col">
className='flex flex-col'
style={{
marginTop: Platform.OS === "android" ? 16 : 0,
}}
>
{jellyseerrApi && ( {jellyseerrApi && (
<> <View className="flex flex-row flex-wrap space-x-2 px-4 mb-2">
<ScrollView <TouchableOpacity onPress={() => setSearchType("Library")}>
horizontal <Tag
className='flex flex-row flex-wrap space-x-2 px-4 mb-2' text={t("search.library")}
> textClass="p-1"
<TouchableOpacity onPress={() => setSearchType("Library")}> className={
<Tag searchType === "Library" ? "bg-purple-600" : undefined
text={t("search.library")} }
textClass='p-1' />
className={ </TouchableOpacity>
searchType === "Library" ? "bg-purple-600" : undefined <TouchableOpacity onPress={() => setSearchType("Discover")}>
} <Tag
/> text={t("search.discover")}
</TouchableOpacity> textClass="p-1"
<TouchableOpacity onPress={() => setSearchType("Discover")}> className={
<Tag searchType === "Discover" ? "bg-purple-600" : undefined
text={t("search.discover")} }
textClass='p-1' />
className={ </TouchableOpacity>
searchType === "Discover" ? "bg-purple-600" : undefined </View>
}
/>
</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>
)}
</ScrollView>
</>
)} )}
<View className='mt-2'> <View className="mt-2">
<LoadingSkeleton isLoading={loading} /> <LoadingSkeleton isLoading={loading} />
</View> </View>
@@ -326,14 +245,14 @@ export default function search() {
renderItem={(item: BaseItemDto) => ( renderItem={(item: BaseItemDto) => (
<TouchableItemRouter <TouchableItemRouter
key={item.Id} key={item.Id}
className='flex flex-col w-28 mr-2' className="flex flex-col w-28 mr-2"
item={item} item={item}
> >
<MoviePoster item={item} key={item.Id} /> <MoviePoster item={item} key={item.Id} />
<Text numberOfLines={2} className='mt-2'> <Text numberOfLines={2} className="mt-2">
{item.Name} {item.Name}
</Text> </Text>
<Text className='opacity-50 text-xs'> <Text className="opacity-50 text-xs">
{item.ProductionYear} {item.ProductionYear}
</Text> </Text>
</TouchableItemRouter> </TouchableItemRouter>
@@ -346,13 +265,13 @@ export default function search() {
<TouchableItemRouter <TouchableItemRouter
key={item.Id} key={item.Id}
item={item} item={item}
className='flex flex-col w-28 mr-2' className="flex flex-col w-28 mr-2"
> >
<SeriesPoster item={item} key={item.Id} /> <SeriesPoster item={item} key={item.Id} />
<Text numberOfLines={2} className='mt-2'> <Text numberOfLines={2} className="mt-2">
{item.Name} {item.Name}
</Text> </Text>
<Text className='opacity-50 text-xs'> <Text className="opacity-50 text-xs">
{item.ProductionYear} {item.ProductionYear}
</Text> </Text>
</TouchableItemRouter> </TouchableItemRouter>
@@ -365,7 +284,7 @@ export default function search() {
<TouchableItemRouter <TouchableItemRouter
item={item} item={item}
key={item.Id} key={item.Id}
className='flex flex-col w-44 mr-2' className="flex flex-col w-44 mr-2"
> >
<ContinueWatchingPoster item={item} /> <ContinueWatchingPoster item={item} />
<ItemCardText item={item} /> <ItemCardText item={item} />
@@ -379,10 +298,10 @@ export default function search() {
<TouchableItemRouter <TouchableItemRouter
key={item.Id} key={item.Id}
item={item} item={item}
className='flex flex-col w-28 mr-2' className="flex flex-col w-28 mr-2"
> >
<MoviePoster item={item} key={item.Id} /> <MoviePoster item={item} key={item.Id} />
<Text numberOfLines={2} className='mt-2'> <Text numberOfLines={2} className="mt-2">
{item.Name} {item.Name}
</Text> </Text>
</TouchableItemRouter> </TouchableItemRouter>
@@ -395,7 +314,7 @@ export default function search() {
<TouchableItemRouter <TouchableItemRouter
item={item} item={item}
key={item.Id} key={item.Id}
className='flex flex-col w-28 mr-2' className="flex flex-col w-28 mr-2"
> >
<MoviePoster item={item} /> <MoviePoster item={item} />
<ItemCardText item={item} /> <ItemCardText item={item} />
@@ -404,33 +323,29 @@ export default function search() {
/> />
</View> </View>
) : ( ) : (
<JellyserrIndexPage <JellyserrIndexPage searchQuery={debouncedSearch} />
searchQuery={debouncedSearch}
sortType={jellyseerrOrderBy}
order={jellyseerrSortOrder}
/>
)} )}
{searchType === "Library" && ( {searchType === "Library" && (
<> <>
{!loading && noResults && debouncedSearch.length > 0 ? ( {!loading && noResults && debouncedSearch.length > 0 ? (
<View> <View>
<Text className='text-center text-lg font-bold mt-4'> <Text className="text-center text-lg font-bold mt-4">
{t("search.no_results_found_for")} {t("search.no_results_found_for")}
</Text> </Text>
<Text className='text-xs text-purple-600 text-center'> <Text className="text-xs text-purple-600 text-center">
"{debouncedSearch}" "{debouncedSearch}"
</Text> </Text>
</View> </View>
) : debouncedSearch.length === 0 ? ( ) : debouncedSearch.length === 0 ? (
<View className='mt-4 flex flex-col items-center space-y-2'> <View className="mt-4 flex flex-col items-center space-y-2">
{exampleSearches.map((e) => ( {exampleSearches.map((e) => (
<TouchableOpacity <TouchableOpacity
onPress={() => setSearch(e)} onPress={() => setSearch(e)}
key={e} key={e}
className='mb-2' className="mb-2"
> >
<Text className='text-purple-600'>{e}</Text> <Text className="text-purple-600">{e}</Text>
</TouchableOpacity> </TouchableOpacity>
))} ))}
</View> </View>

View File

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

View File

@@ -1,39 +1,34 @@
import { Stack } from "expo-router";
import React, { useEffect } from "react";
import { SystemBars } from "react-native-edge-to-edge";
import * as ScreenOrientation from "@/packages/expo-screen-orientation"; import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { Stack } from "expo-router";
import React, { useLayoutEffect } from "react";
import { Platform } from "react-native";
import { SystemBars } from "react-native-edge-to-edge";
export default function Layout() { export default function Layout() {
const [settings] = useSettings(); const [settings] = useSettings();
useLayoutEffect(() => { useEffect(() => {
if (Platform.isTV) return; if (settings.defaultVideoOrientation) {
if (!settings.followDeviceOrientation && settings.defaultVideoOrientation) {
ScreenOrientation.lockAsync(settings.defaultVideoOrientation); ScreenOrientation.lockAsync(settings.defaultVideoOrientation);
} }
return () => { return () => {
if (Platform.isTV) return; if (settings.autoRotate === true) {
if (settings.followDeviceOrientation === true) {
ScreenOrientation.unlockAsync(); ScreenOrientation.unlockAsync();
} else { } else {
ScreenOrientation.lockAsync( ScreenOrientation.lockAsync(
ScreenOrientation.OrientationLock.PORTRAIT_UP, ScreenOrientation.OrientationLock.PORTRAIT_UP
); );
} }
}; };
}); }, [settings]);
return ( return (
<> <>
<SystemBars hidden /> <SystemBars hidden />
<Stack> <Stack>
<Stack.Screen <Stack.Screen
name='direct-player' name="direct-player"
options={{ options={{
headerShown: false, headerShown: false,
autoHideHomeIndicator: true, autoHideHomeIndicator: true,

View File

@@ -1,54 +1,50 @@
import { BITRATES } from "@/components/BitrateSelector"; import { BITRATES } from "@/components/BitrateSelector";
import { Loader } from "@/components/Loader";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader";
import { Controls } from "@/components/video-player/controls/Controls"; import { Controls } from "@/components/video-player/controls/Controls";
import { getDownloadedFileUrl } from "@/hooks/useDownloadedFileOpener"; import { getDownloadedFileUrl } from "@/hooks/useDownloadedFileOpener";
import { useHaptic } from "@/hooks/useHaptic";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache"; import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import { useWebSocket } from "@/hooks/useWebsockets"; import { useWebSocket } from "@/hooks/useWebsockets";
import { VlcPlayerView } from "@/modules"; import { VlcPlayerView } from "@/modules/vlc-player";
import type { import {
PipStartedPayload, PipStartedPayload,
PlaybackStatePayload, PlaybackStatePayload,
ProgressUpdatePayload, ProgressUpdatePayload,
VlcPlayerViewRef, VlcPlayerViewRef,
} from "@/modules/VlcPlayer.types"; } from "@/modules/vlc-player/src/VlcPlayer.types";
// import { useDownload } from "@/providers/DownloadProvider";
const downloadProvider = !Platform.isTV
? require("@/providers/DownloadProvider")
: null;
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl"; import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { writeToLog } from "@/utils/log"; import { writeToLog } from "@/utils/log";
import native from "@/utils/profiles/native"; import native from "@/utils/profiles/native";
import { msToTicks, ticksToSeconds } from "@/utils/time"; import { msToTicks, ticksToSeconds } from "@/utils/time";
import {
type BaseItemDto,
type MediaSourceInfo,
PlaybackOrder,
type PlaybackProgressInfo,
RepeatMode,
} from "@jellyfin/sdk/lib/generated-client";
import { import {
getPlaystateApi, getPlaystateApi,
getUserLibraryApi, getUserLibraryApi,
} from "@jellyfin/sdk/lib/utils/api"; } from "@jellyfin/sdk/lib/utils/api";
import { activateKeepAwakeAsync, deactivateKeepAwake } from "expo-keep-awake"; import { useQuery } from "@tanstack/react-query";
import { useHaptic } from "@/hooks/useHaptic";
import { useGlobalSearchParams, useNavigation } from "expo-router"; import { useGlobalSearchParams, useNavigation } from "expo-router";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import React, { import React, {
useCallback, useCallback,
useEffect,
useMemo, useMemo,
useRef, useRef,
useState, useState,
useEffect,
} from "react"; } from "react";
import { useTranslation } from "react-i18next"; import { Alert, View, AppState, AppStateStatus, Platform } from "react-native";
import { Alert, Platform, View } from "react-native";
import { useSharedValue } from "react-native-reanimated"; import { useSharedValue } from "react-native-reanimated";
import { useSettings } from "@/utils/atoms/settings";
import { useTranslation } from "react-i18next";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
const downloadProvider = !Platform.isTV import { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client";
? require("@/providers/DownloadProvider")
: null;
export default function page() { export default function page() {
console.log("Direct Player");
const videoRef = useRef<VlcPlayerViewRef>(null); const videoRef = useRef<VlcPlayerViewRef>(null);
const user = useAtomValue(userAtom); const user = useAtomValue(userAtom);
const api = useAtomValue(apiAtom); const api = useAtomValue(apiAtom);
@@ -96,124 +92,152 @@ export default function page() {
offline: string; offline: string;
}>(); }>();
const [settings] = useSettings(); const [settings] = useSettings();
const insets = useSafeAreaInsets();
const offline = offlineStr === "true"; const offline = offlineStr === "true";
const audioIndex = audioIndexStr const audioIndex = audioIndexStr ? parseInt(audioIndexStr, 10) : undefined;
? Number.parseInt(audioIndexStr, 10) const subtitleIndex = subtitleIndexStr ? parseInt(subtitleIndexStr, 10) : -1;
: undefined;
const subtitleIndex = subtitleIndexStr
? Number.parseInt(subtitleIndexStr, 10)
: -1;
const bitrateValue = bitrateValueStr const bitrateValue = bitrateValueStr
? Number.parseInt(bitrateValueStr, 10) ? parseInt(bitrateValueStr, 10)
: BITRATES[0].value; : BITRATES[0].value;
const [item, setItem] = useState<BaseItemDto | null>(null); const {
const [itemStatus, setItemStatus] = useState({ data: item,
isLoading: true, isLoading: isLoadingItem,
isError: false, isError: isErrorItem,
}); } = useQuery({
queryKey: ["item", itemId],
useEffect(() => { queryFn: async () => {
const fetchItemData = async () => { if (offline && !Platform.isTV) {
setItemStatus({ isLoading: true, isError: false }); const item = await getDownloadedItem.getDownloadedItem(itemId);
try { if (item) return item.item;
let fetchedItem: BaseItemDto | null = null;
if (offline && !Platform.isTV) {
const data = await getDownloadedItem.getDownloadedItem(itemId);
if (data) fetchedItem = data.item as BaseItemDto;
} else {
const res = await getUserLibraryApi(api!).getItem({
itemId,
userId: user?.Id,
});
fetchedItem = res.data;
}
setItem(fetchedItem);
} catch (error) {
console.error("Failed to fetch item:", error);
setItemStatus({ isLoading: false, isError: true });
} finally {
setItemStatus({ isLoading: false, isError: false });
} }
};
if (itemId) { const res = await getUserLibraryApi(api!).getItem({
fetchItemData(); itemId,
} userId: user?.Id,
}, [itemId, offline, api, user?.Id]); });
interface Stream { return res.data;
mediaSource: MediaSourceInfo; },
sessionId: string; enabled: !!itemId,
url: string; staleTime: 0,
}
const [stream, setStream] = useState<Stream | null>(null);
const [streamStatus, setStreamStatus] = useState({
isLoading: true,
isError: false,
}); });
const [stream, setStream] = useState<{
mediaSource: MediaSourceInfo;
url: string;
sessionId: string | undefined;
} | null>(null);
const [isLoadingStream, setIsLoadingStream] = useState(true);
const [isErrorStream, setIsErrorStream] = useState(false);
useEffect(() => { useEffect(() => {
const fetchStreamData = async () => { const fetchStream = async () => {
setIsLoadingStream(true);
setIsErrorStream(false);
try { try {
let result: Stream | null = null;
if (offline && !Platform.isTV) { if (offline && !Platform.isTV) {
const data = await getDownloadedItem.getDownloadedItem(itemId); const data = await getDownloadedItem.getDownloadedItem(itemId);
if (!data?.mediaSource) return; if (!data?.mediaSource) {
const url = await getDownloadedFileUrl(data.item.Id!); setStream(null);
if (item) { return;
result = { mediaSource: data.mediaSource, sessionId: "", url }; }
}
} else { const url = await getDownloadedFileUrl(data.item.Id!);
const res = await getStreamUrl({
api, if (item) {
item, setStream({
startTimeTicks: item?.UserData?.PlaybackPositionTicks!, mediaSource: data.mediaSource as MediaSourceInfo,
userId: user?.Id, url,
audioStreamIndex: audioIndex, sessionId: undefined,
maxStreamingBitrate: bitrateValue, });
mediaSourceId: mediaSourceId,
subtitleStreamIndex: subtitleIndex,
deviceProfile: native,
});
if (!res) return;
const { mediaSource, sessionId, url } = res;
if (!sessionId || !mediaSource || !url) {
Alert.alert(
t("player.error"),
t("player.failed_to_get_stream_url"),
);
return; return;
} }
result = { mediaSource, sessionId, url };
} }
setStream(result);
const res = await getStreamUrl({
api,
item,
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
userId: user?.Id,
audioStreamIndex: audioIndex,
maxStreamingBitrate: bitrateValue,
mediaSourceId: mediaSourceId,
subtitleStreamIndex: subtitleIndex,
deviceProfile: native,
});
if (!res) {
setStream(null);
return;
}
const { mediaSource, sessionId, url } = res;
if (!sessionId || !mediaSource || !url) {
Alert.alert(t("player.error"), t("player.failed_to_get_stream_url"));
setStream(null);
return;
}
setStream({
mediaSource,
sessionId,
url,
});
} catch (error) { } catch (error) {
console.error("Failed to fetch stream:", error); console.error("Error fetching stream:", error);
setStreamStatus({ isLoading: false, isError: true }); setIsErrorStream(true);
setStream(null);
} finally { } finally {
setStreamStatus({ isLoading: false, isError: false }); setIsLoadingStream(false);
} }
}; };
fetchStreamData();
}, [itemId, mediaSourceId, bitrateValue, api, item, user?.Id]);
const togglePlay = async () => { fetchStream();
}, [itemId, mediaSourceId]);
const togglePlay = useCallback(async () => {
if (!api) return;
lightHapticFeedback(); lightHapticFeedback();
setIsPlaying(!isPlaying);
if (isPlaying) { if (isPlaying) {
await videoRef.current?.pause(); await videoRef.current?.pause();
} else { } else {
videoRef.current?.play(); videoRef.current?.play();
} }
};
if (!offline && stream) {
await getPlaystateApi(api).onPlaybackProgress({
itemId: item?.Id!,
audioStreamIndex: audioIndex ? audioIndex : undefined,
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
mediaSourceId: mediaSourceId,
positionTicks: msToTicks(progress.get()),
isPaused: !isPlaying,
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
playSessionId: stream.sessionId,
});
}
}, [
isPlaying,
api,
item,
stream,
videoRef,
audioIndex,
subtitleIndex,
mediaSourceId,
offline,
progress,
]);
const reportPlaybackStopped = useCallback(async () => { const reportPlaybackStopped = useCallback(async () => {
if (offline) return; if (offline) return;
const currentTimeInTicks = msToTicks(progress.get()); const currentTimeInTicks = msToTicks(progress.get());
await getPlaystateApi(api!).onPlaybackStopped({ await getPlaystateApi(api!).onPlaybackStopped({
itemId: item?.Id!, itemId: item?.Id!,
mediaSourceId: mediaSourceId, mediaSourceId: mediaSourceId,
@@ -230,36 +254,12 @@ export default function page() {
videoRef.current?.stop(); videoRef.current?.stop();
}, [videoRef, reportPlaybackStopped]); }, [videoRef, reportPlaybackStopped]);
useEffect(() => {
const beforeRemoveListener = navigation.addListener("beforeRemove", stop);
return () => {
beforeRemoveListener();
};
}, [navigation, stop]);
const currentPlayStateInfo = () => {
if (!stream) return;
return {
itemId: item?.Id!,
audioStreamIndex: audioIndex ? audioIndex : undefined,
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
mediaSourceId: mediaSourceId,
positionTicks: msToTicks(progress.get()),
isPaused: !isPlaying,
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
playSessionId: stream.sessionId,
isMuted: false,
canSeek: true,
repeatMode: RepeatMode.RepeatNone,
playbackOrder: PlaybackOrder.Default,
};
};
const onProgress = useCallback( const onProgress = useCallback(
async (data: ProgressUpdatePayload) => { async (data: ProgressUpdatePayload) => {
if (isSeeking.get() || isPlaybackStopped) return; if (isSeeking.get() || isPlaybackStopped) return;
const { currentTime } = data.nativeEvent; const { currentTime } = data.nativeEvent;
if (isBuffering) { if (isBuffering) {
setIsBuffering(false); setIsBuffering(false);
} }
@@ -268,52 +268,24 @@ export default function page() {
if (offline) return; if (offline) return;
const currentTimeInTicks = msToTicks(currentTime);
if (!item?.Id || !stream) return; if (!item?.Id || !stream) return;
reportPlaybackProgress(); await getPlaystateApi(api!).onPlaybackProgress({
itemId: item.Id,
audioStreamIndex: audioIndex ? audioIndex : undefined,
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
mediaSourceId: mediaSourceId,
positionTicks: Math.floor(currentTimeInTicks),
isPaused: !isPlaying,
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
playSessionId: stream.sessionId,
});
}, },
[ [item?.Id, isSeeking, api, isPlaybackStopped, audioIndex, subtitleIndex]
item?.Id,
audioIndex,
subtitleIndex,
mediaSourceId,
isPlaying,
stream,
isSeeking,
isPlaybackStopped,
isBuffering,
],
); );
const onPipStarted = useCallback((e: PipStartedPayload) => {
const { pipStarted } = e.nativeEvent;
setIsPipStarted(pipStarted);
}, []);
const reportPlaybackProgress = useCallback(async () => {
if (!api || offline || !stream) return;
await getPlaystateApi(api).reportPlaybackProgress({
playbackProgressInfo: currentPlayStateInfo() as PlaybackProgressInfo,
});
}, [
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;
}, [item]);
useWebSocket({ useWebSocket({
isPlaying: isPlaying, isPlaying: isPlaying,
togglePlay: togglePlay, togglePlay: togglePlay,
@@ -321,60 +293,60 @@ export default function page() {
offline, offline,
}); });
const onPlaybackStateChanged = useCallback( const onPipStarted = useCallback((e: PipStartedPayload) => {
async (e: PlaybackStatePayload) => { const { pipStarted } = e.nativeEvent;
const { state, isBuffering, isPlaying } = e.nativeEvent; setIsPipStarted(pipStarted);
if (state === "Playing") { }, []);
setIsPlaying(true);
reportPlaybackProgress();
if (!Platform.isTV) await activateKeepAwakeAsync();
return;
}
if (state === "Paused") { const onPlaybackStateChanged = useCallback((e: PlaybackStatePayload) => {
setIsPlaying(false); const { state, isBuffering, isPlaying } = e.nativeEvent;
reportPlaybackProgress();
if (!Platform.isTV) await deactivateKeepAwake();
return;
}
if (isPlaying) { if (state === "Playing") {
setIsPlaying(true); setIsPlaying(true);
setIsBuffering(false); return;
} else if (isBuffering) { }
setIsBuffering(true);
} if (state === "Paused") {
}, setIsPlaying(false);
[reportPlaybackProgress], return;
); }
if (isPlaying) {
setIsPlaying(true);
setIsBuffering(false);
} else if (isBuffering) {
setIsBuffering(true);
}
}, []);
const startPosition = useMemo(() => {
if (offline) return 0;
return item?.UserData?.PlaybackPositionTicks
? ticksToSeconds(item.UserData.PlaybackPositionTicks)
: 0;
}, [item]);
// Preselection of audio and subtitle tracks.
if (!settings) return null;
let initOptions = [`--sub-text-scale=${settings.subtitleSize}`];
const allAudio = const allAudio =
stream?.mediaSource.MediaStreams?.filter( stream?.mediaSource.MediaStreams?.filter(
(audio) => audio.Type === "Audio", (audio) => audio.Type === "Audio"
) || []; ) || [];
// Move all the external subtitles last, because vlc places them last.
const allSubs = const allSubs =
stream?.mediaSource.MediaStreams?.filter( stream?.mediaSource.MediaStreams?.filter(
(sub) => sub.Type === "Subtitle", (sub) => sub.Type === "Subtitle"
).sort((a, b) => Number(a.IsExternal) - Number(b.IsExternal)) || []; ) || [];
const externalSubtitles = allSubs
.filter((sub: any) => sub.DeliveryMethod === "External")
.map((sub: any) => ({
name: sub.DisplayTitle,
DeliveryUrl: api?.basePath + sub.DeliveryUrl,
}));
const textSubs = allSubs.filter((sub) => sub.IsTextSubtitleStream); const textSubs = allSubs.filter((sub) => sub.IsTextSubtitleStream);
const chosenSubtitleTrack = allSubs.find( const chosenSubtitleTrack = allSubs.find(
(sub) => sub.Index === subtitleIndex, (sub) => sub.Index === subtitleIndex
); );
const chosenAudioTrack = allAudio.find((audio) => audio.Index === audioIndex); const chosenAudioTrack = allAudio.find((audio) => audio.Index === audioIndex);
const notTranscoding = !stream?.mediaSource.TranscodingUrl; const notTranscoding = !stream?.mediaSource.TranscodingUrl;
const initOptions = [`--sub-text-scale=${settings.subtitleSize}`];
if ( if (
chosenSubtitleTrack && chosenSubtitleTrack &&
(notTranscoding || chosenSubtitleTrack.IsTextSubtitleStream) (notTranscoding || chosenSubtitleTrack.IsTextSubtitleStream)
@@ -389,29 +361,35 @@ export default function page() {
initOptions.push(`--audio-track=${allAudio.indexOf(chosenAudioTrack)}`); initOptions.push(`--audio-track=${allAudio.indexOf(chosenAudioTrack)}`);
} }
const [isMounted, setIsMounted] = useState(false); const insets = useSafeAreaInsets();
// Add useEffect to handle mounting
useEffect(() => { useEffect(() => {
setIsMounted(true); const beforeRemoveListener = navigation.addListener("beforeRemove", stop);
return () => setIsMounted(false); return () => {
}, []); beforeRemoveListener();
};
}, [navigation]);
if (itemStatus.isLoading || streamStatus.isLoading) { if (!item || isLoadingItem || !stream)
return ( return (
<View className='w-screen h-screen flex flex-col items-center justify-center bg-black'> <View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
<Loader /> <Loader />
</View> </View>
); );
}
if (!item || !stream || itemStatus.isError || streamStatus.isError) if (isErrorItem || isErrorStream)
return ( return (
<View className='w-screen h-screen flex flex-col items-center justify-center bg-black'> <View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
<Text className='text-white'>{t("player.error")}</Text> <Text className="text-white">{t("player.error")}</Text>
</View> </View>
); );
const externalSubtitles = allSubs
.filter((sub: any) => sub.DeliveryMethod === "External")
.map((sub: any) => ({
name: sub.DisplayTitle,
DeliveryUrl: api?.basePath + sub.DeliveryUrl,
}));
return ( return (
<View style={{ flex: 1, backgroundColor: "black" }}> <View style={{ flex: 1, backgroundColor: "black" }}>
<View <View
@@ -441,6 +419,7 @@ export default function page() {
progressUpdateInterval={1000} progressUpdateInterval={1000}
onVideoStateChange={onPlaybackStateChanged} onVideoStateChange={onPlaybackStateChanged}
onPipStarted={onPipStarted} onPipStarted={onPipStarted}
onVideoLoadStart={() => {}}
onVideoLoadEnd={() => { onVideoLoadEnd={() => {
setIsVideoLoaded(true); setIsVideoLoaded(true);
}} }}
@@ -448,13 +427,13 @@ export default function page() {
console.error("Video Error:", e.nativeEvent); console.error("Video Error:", e.nativeEvent);
Alert.alert( Alert.alert(
t("player.error"), t("player.error"),
t("player.an_error_occured_while_playing_the_video"), t("player.an_error_occured_while_playing_the_video")
); );
writeToLog("ERROR", "Video Error", e.nativeEvent); writeToLog("ERROR", "Video Error", e.nativeEvent);
}} }}
/> />
</View> </View>
{videoRef.current && !isPipStarted && isMounted === true ? ( {videoRef.current && !isPipStarted && (
<Controls <Controls
mediaSource={stream?.mediaSource} mediaSource={stream?.mediaSource}
item={item} item={item}
@@ -481,9 +460,10 @@ export default function page() {
setSubtitleTrack={videoRef.current.setSubtitleTrack} setSubtitleTrack={videoRef.current.setSubtitleTrack}
setSubtitleURL={videoRef.current.setSubtitleURL} setSubtitleURL={videoRef.current.setSubtitleURL}
setAudioTrack={videoRef.current.setAudioTrack} setAudioTrack={videoRef.current.setAudioTrack}
stop={stop}
isVlc isVlc
/> />
) : null} )}
</View> </View>
); );
} }

View File

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

View File

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

View File

@@ -1,28 +1,28 @@
import "@/augmentations"; import "@/augmentations";
import { Platform } from "react-native";
import { Text } from "@/components/common/Text";
import i18n from "@/i18n"; import i18n from "@/i18n";
import { DownloadProvider } from "@/providers/DownloadProvider"; import { DownloadProvider } from "@/providers/DownloadProvider";
import { import {
JellyfinProvider,
apiAtom,
getOrSetDeviceId, getOrSetDeviceId,
getTokenFromStorage, getTokenFromStorage,
JellyfinProvider,
} from "@/providers/JellyfinProvider"; } from "@/providers/JellyfinProvider";
import { JobQueueProvider } from "@/providers/JobQueueProvider"; import { JobQueueProvider } from "@/providers/JobQueueProvider";
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider"; import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
import { WebSocketProvider } from "@/providers/WebSocketProvider";
import { type Settings, useSettings } from "@/utils/atoms/settings";
import { import {
BACKGROUND_FETCH_TASK, SplashScreenProvider,
BACKGROUND_FETCH_TASK_SESSIONS, useSplashScreenLoading,
registerBackgroundFetchAsyncSessions, } from "@/providers/SplashScreenProvider";
} from "@/utils/background-tasks"; import { WebSocketProvider } from "@/providers/WebSocketProvider";
import { LogProvider, writeErrorLog, writeToLog } from "@/utils/log"; import { Settings, useSettings } from "@/utils/atoms/settings";
import { BACKGROUND_FETCH_TASK } from "@/utils/background-tasks";
import { LogProvider, writeToLog } from "@/utils/log";
import { storage } from "@/utils/mmkv"; import { storage } from "@/utils/mmkv";
import { cancelJobById, getAllJobsByDeviceId } from "@/utils/optimize-server"; import { cancelJobById, getAllJobsByDeviceId } from "@/utils/optimize-server";
import { ActionSheetProvider } from "@expo/react-native-action-sheet"; import { ActionSheetProvider } from "@expo/react-native-action-sheet";
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet"; import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { Platform } from "react-native";
const BackGroundDownloader = !Platform.isTV const BackGroundDownloader = !Platform.isTV
? require("@kesha-antonov/react-native-background-downloader") ? require("@kesha-antonov/react-native-background-downloader")
: null; : null;
@@ -32,29 +32,20 @@ const BackgroundFetch = !Platform.isTV
? require("expo-background-fetch") ? require("expo-background-fetch")
: null; : null;
import * as FileSystem from "expo-file-system"; import * as FileSystem from "expo-file-system";
import { useFonts } from "expo-font";
import { useKeepAwake } from "expo-keep-awake";
const Notifications = !Platform.isTV ? require("expo-notifications") : null; const Notifications = !Platform.isTV ? require("expo-notifications") : null;
import { router, Stack } from "expo-router";
import * as ScreenOrientation from "@/packages/expo-screen-orientation"; import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { Stack, router, useSegments } from "expo-router";
import * as SplashScreen from "expo-splash-screen";
const TaskManager = !Platform.isTV ? require("expo-task-manager") : null; const TaskManager = !Platform.isTV ? require("expo-task-manager") : null;
import { getLocales } from "expo-localization"; import { getLocales } from "expo-localization";
import { Provider as JotaiProvider } from "jotai"; import { Provider as JotaiProvider } from "jotai";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef } from "react";
import { I18nextProvider } from "react-i18next"; import { I18nextProvider, useTranslation } from "react-i18next";
import { AppState, Appearance } from "react-native"; import { Appearance, AppState } from "react-native";
import { SystemBars } from "react-native-edge-to-edge"; import { SystemBars } from "react-native-edge-to-edge";
import { GestureHandlerRootView } from "react-native-gesture-handler"; import { GestureHandlerRootView } from "react-native-gesture-handler";
import "react-native-reanimated"; import "react-native-reanimated";
import { userAtom } from "@/providers/JellyfinProvider";
import { store } from "@/utils/store";
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"; import { Toaster } from "sonner-native";
if (!Platform.isTV) { if (!Platform.isTV) {
@@ -67,15 +58,6 @@ if (!Platform.isTV) {
}); });
} }
// Keep the splash screen visible while we fetch resources
SplashScreen.preventAutoHideAsync();
// Set the animation options. This is optional.
SplashScreen.setOptions({
duration: 500,
fade: true,
});
function useNotificationObserver() { function useNotificationObserver() {
if (Platform.isTV) return; if (Platform.isTV) return;
@@ -95,13 +77,13 @@ function useNotificationObserver() {
return; return;
} }
redirect(response?.notification); redirect(response?.notification);
}, }
); );
const subscription = Notifications.addNotificationResponseReceivedListener( const subscription = Notifications.addNotificationResponseReceivedListener(
(response: { notification: any }) => { (response: { notification: any }) => {
redirect(response.notification); redirect(response.notification);
}, }
); );
return () => { return () => {
@@ -112,22 +94,6 @@ function useNotificationObserver() {
} }
if (!Platform.isTV) { if (!Platform.isTV) {
TaskManager.defineTask(BACKGROUND_FETCH_TASK_SESSIONS, async () => {
console.log("TaskManager ~ sessions trigger");
const api = store.get(apiAtom);
if (api === null || api === undefined) return;
const response = await getSessionApi(api).getSessions({
activeWithinSeconds: 360,
});
const result = response.data.filter((s) => s.NowPlayingItem);
Notifications.setBadgeCountAsync(result.length);
return BackgroundFetch.BackgroundFetchResult.NewData;
});
TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => { TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
console.log("TaskManager ~ trigger"); console.log("TaskManager ~ trigger");
@@ -158,7 +124,7 @@ if (!Platform.isTV) {
console.log("TaskManager ~ Active jobs: ", jobs.length); console.log("TaskManager ~ Active jobs: ", jobs.length);
for (const job of jobs) { for (let job of jobs) {
if (job.status === "completed") { if (job.status === "completed") {
const downloadUrl = url + "download/" + job.id; const downloadUrl = url + "download/" + job.id;
const tasks = await BackGroundDownloader.checkForExistingDownloads(); const tasks = await BackGroundDownloader.checkForExistingDownloads();
@@ -226,7 +192,7 @@ if (!Platform.isTV) {
const checkAndRequestPermissions = async () => { const checkAndRequestPermissions = async () => {
try { try {
const hasAskedBefore = storage.getString( const hasAskedBefore = storage.getString(
"hasAskedForNotificationPermission", "hasAskedForNotificationPermission"
); );
if (hasAskedBefore !== "true") { if (hasAskedBefore !== "true") {
@@ -248,7 +214,7 @@ const checkAndRequestPermissions = async () => {
writeToLog( writeToLog(
"ERROR", "ERROR",
"Error checking/requesting notification permissions:", "Error checking/requesting notification permissions:",
error, error
); );
console.error("Error checking/requesting notification permissions:", error); console.error("Error checking/requesting notification permissions:", error);
} }
@@ -258,15 +224,17 @@ export default function RootLayout() {
Appearance.setColorScheme("dark"); Appearance.setColorScheme("dark");
return ( return (
<GestureHandlerRootView style={{ flex: 1 }}> <SplashScreenProvider>
<JotaiProvider> <GestureHandlerRootView style={{ flex: 1 }}>
<ActionSheetProvider> <JotaiProvider>
<I18nextProvider i18n={i18n}> <ActionSheetProvider>
<Layout /> <I18nextProvider i18n={i18n}>
</I18nextProvider> <Layout />
</ActionSheetProvider> </I18nextProvider>
</JotaiProvider> </ActionSheetProvider>
</GestureHandlerRootView> </JotaiProvider>
</GestureHandlerRootView>
</SplashScreenProvider>
); );
} }
@@ -284,106 +252,35 @@ const queryClient = new QueryClient({
function Layout() { function Layout() {
const [settings] = useSettings(); const [settings] = useSettings();
const [user] = useAtom(userAtom);
const [api] = useAtom(apiAtom);
const appState = useRef(AppState.currentState); const appState = useRef(AppState.currentState);
const segments = useSegments();
useEffect(() => { useEffect(() => {
i18n.changeLanguage( i18n.changeLanguage(
settings?.preferedLanguage ?? getLocales()[0].languageCode ?? "en", settings?.preferedLanguage ?? getLocales()[0].languageCode ?? "en"
); );
}, [settings?.preferedLanguage, i18n]); }, [settings?.preferedLanguage, i18n]);
if (!Platform.isTV) { if (!Platform.isTV) {
useKeepAwake();
useNotificationObserver(); useNotificationObserver();
const [expoPushToken, setExpoPushToken] = useState<ExpoPushToken>(); const { i18n } = useTranslation();
const notificationListener = useRef<EventSubscription>();
const responseListener = useRef<EventSubscription>();
useEffect(() => { useEffect(() => {
if (expoPushToken && api && user) { checkAndRequestPermissions();
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",
});
}
await checkAndRequestPermissions();
if (!Platform.isTV && user && user.Policy?.IsAdministrator) {
await registerBackgroundFetchAsyncSessions();
}
Notifications?.getExpoPushTokenAsync()
.then((token: ExpoPushToken) => token && setExpoPushToken(token))
.catch((reason: any) => console.log("Failed to get token", reason));
}
useEffect(() => {
registerNotifications();
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);
},
);
return () => {
notificationListener.current &&
Notifications?.removeNotificationSubscription(
notificationListener.current,
);
responseListener.current &&
Notifications?.removeNotificationSubscription(
responseListener.current,
);
};
}, []); }, []);
useEffect(() => { useEffect(() => {
if (Platform.isTV) return;
if (segments.includes("direct-player" as never)) {
return;
}
// If the user has auto rotate enabled, unlock the orientation // If the user has auto rotate enabled, unlock the orientation
if (settings.followDeviceOrientation === true) { if (settings.autoRotate === true) {
ScreenOrientation.unlockAsync(); ScreenOrientation.unlockAsync();
} else { } else {
// If the user has auto rotate disabled, lock the orientation to portrait // If the user has auto rotate disabled, lock the orientation to portrait
ScreenOrientation.lockAsync( ScreenOrientation.lockAsync(
ScreenOrientation.OrientationLock.PORTRAIT_UP, ScreenOrientation.OrientationLock.PORTRAIT_UP
); );
} }
}, [settings.followDeviceOrientation, segments]); }, [settings]);
useEffect(() => { useEffect(() => {
const subscription = AppState.addEventListener( const subscription = AppState.addEventListener(
@@ -395,7 +292,7 @@ function Layout() {
) { ) {
BackGroundDownloader.checkForExistingDownloads(); BackGroundDownloader.checkForExistingDownloads();
} }
}, }
); );
BackGroundDownloader.checkForExistingDownloads(); BackGroundDownloader.checkForExistingDownloads();
@@ -406,6 +303,16 @@ function Layout() {
}, []); }, []);
} }
const [loaded] = useFonts({
SpaceMono: require("../assets/fonts/SpaceMono-Regular.ttf"),
});
useSplashScreenLoading(!loaded);
if (!loaded) {
return null;
}
return ( return (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<JobQueueProvider> <JobQueueProvider>
@@ -415,11 +322,11 @@ function Layout() {
<WebSocketProvider> <WebSocketProvider>
<DownloadProvider> <DownloadProvider>
<BottomSheetModalProvider> <BottomSheetModalProvider>
<SystemBars style='light' hidden={false} /> <SystemBars style="light" hidden={false} />
<ThemeProvider value={DarkTheme}> <ThemeProvider value={DarkTheme}>
<Stack initialRouteName='(auth)/(tabs)'> <Stack>
<Stack.Screen <Stack.Screen
name='(auth)/(tabs)' name="(auth)/(tabs)"
options={{ options={{
headerShown: false, headerShown: false,
title: "", title: "",
@@ -427,7 +334,7 @@ function Layout() {
}} }}
/> />
<Stack.Screen <Stack.Screen
name='(auth)/player' name="(auth)/player"
options={{ options={{
headerShown: false, headerShown: false,
title: "", title: "",
@@ -435,14 +342,14 @@ function Layout() {
}} }}
/> />
<Stack.Screen <Stack.Screen
name='login' name="login"
options={{ options={{
headerShown: true, headerShown: true,
title: "", title: "",
headerTransparent: true, headerTransparent: true,
}} }}
/> />
<Stack.Screen name='+not-found' /> <Stack.Screen name="+not-found" />
</Stack> </Stack>
<Toaster <Toaster
duration={4000} duration={4000}
@@ -473,7 +380,7 @@ function Layout() {
function saveDownloadedItemInfo(item: BaseItemDto) { function saveDownloadedItemInfo(item: BaseItemDto) {
try { try {
const downloadedItems = storage.getString("downloadedItems"); const downloadedItems = storage.getString("downloadedItems");
const items: BaseItemDto[] = downloadedItems let items: BaseItemDto[] = downloadedItems
? JSON.parse(downloadedItems) ? JSON.parse(downloadedItems)
: []; : [];

View File

@@ -1,17 +1,16 @@
import { Button } from "@/components/Button"; import { Button } from "@/components/Button";
import JellyfinServerDiscovery from "@/components/JellyfinServerDiscovery";
import { PreviousServersList } from "@/components/PreviousServersList";
import { Input } from "@/components/common/Input"; import { Input } from "@/components/common/Input";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import JellyfinServerDiscovery from "@/components/JellyfinServerDiscovery";
import { PreviousServersList } from "@/components/PreviousServersList";
import { Colors } from "@/constants/Colors"; import { Colors } from "@/constants/Colors";
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider"; import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons"; import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
import type { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client"; import { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation } from "expo-router"; import { useLocalSearchParams, useNavigation } from "expo-router";
import { useAtomValue } from "jotai"; import { useAtom } from "jotai";
import type React from "react"; import React, { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { import {
Alert, Alert,
KeyboardAvoidingView, KeyboardAvoidingView,
@@ -20,20 +19,17 @@ import {
TouchableOpacity, TouchableOpacity,
View, View,
} from "react-native"; } from "react-native";
import { Keyboard } from "react-native";
import { t } from "i18next";
import { z } from "zod"; import { z } from "zod";
import { t } from 'i18next';
const CredentialsSchema = z.object({ const CredentialsSchema = z.object({
username: z.string().min(1, t("login.username_required")), username: z.string().min(1, t("login.username_required")),});
});
const Login: React.FC = () => { const Login: React.FC = () => {
const api = useAtomValue(apiAtom);
const navigation = useNavigation();
const params = useLocalSearchParams();
const { setServer, login, removeServer, initiateQuickConnect } = const { setServer, login, removeServer, initiateQuickConnect } =
useJellyfin(); useJellyfin();
const [api] = useAtom(apiAtom);
const params = useLocalSearchParams();
const { const {
apiUrl: _apiUrl, apiUrl: _apiUrl,
@@ -41,8 +37,6 @@ const Login: React.FC = () => {
password: _password, password: _password,
} = params as { apiUrl: string; username: string; password: string }; } = params as { apiUrl: string; username: string; password: string };
const [loadingServerCheck, setLoadingServerCheck] = useState<boolean>(false);
const [loading, setLoading] = useState<boolean>(false);
const [serverURL, setServerURL] = useState<string>(_apiUrl); const [serverURL, setServerURL] = useState<string>(_apiUrl);
const [serverName, setServerName] = useState<string>(""); const [serverName, setServerName] = useState<string>("");
const [credentials, setCredentials] = useState<{ const [credentials, setCredentials] = useState<{
@@ -53,13 +47,12 @@ const Login: React.FC = () => {
password: _password, password: _password,
}); });
/**
* A way to auto login based on a link
*/
useEffect(() => { useEffect(() => {
(async () => { (async () => {
// we might re-use the checkUrl function here to check the url as well
// however, I don't think it should be necessary for now
if (_apiUrl) { if (_apiUrl) {
await setServer({ setServer({
address: _apiUrl, address: _apiUrl,
}); });
@@ -73,6 +66,7 @@ const Login: React.FC = () => {
})(); })();
}, [_apiUrl, _username, _password]); }, [_apiUrl, _username, _password]);
const navigation = useNavigation();
useEffect(() => { useEffect(() => {
navigation.setOptions({ navigation.setOptions({
headerTitle: serverName, headerTitle: serverName,
@@ -82,20 +76,18 @@ const Login: React.FC = () => {
onPress={() => { onPress={() => {
removeServer(); removeServer();
}} }}
className='flex flex-row items-center' className="flex flex-row items-center"
> >
<Ionicons name='chevron-back' size={18} color={Colors.primary} /> <Ionicons name="chevron-back" size={18} color={Colors.primary} />
<Text className='ml-2 text-purple-600'> <Text className="ml-2 text-purple-600">{t("login.change_server")}</Text>
{t("login.change_server")}
</Text>
</TouchableOpacity> </TouchableOpacity>
) : null, ) : null,
}); });
}, [serverName, navigation, api?.basePath]); }, [serverName, navigation, api?.basePath]);
const handleLogin = async () => { const [loading, setLoading] = useState<boolean>(false);
Keyboard.dismiss();
const handleLogin = async () => {
setLoading(true); setLoading(true);
try { try {
const result = CredentialsSchema.safeParse(credentials); const result = CredentialsSchema.safeParse(credentials);
@@ -106,16 +98,15 @@ const Login: React.FC = () => {
if (error instanceof Error) { if (error instanceof Error) {
Alert.alert(t("login.connection_failed"), error.message); Alert.alert(t("login.connection_failed"), error.message);
} else { } else {
Alert.alert( Alert.alert(t("login.connection_failed"), t("login.an_unexpected_error_occured"));
t("login.connection_failed"),
t("login.an_unexpected_error_occured"),
);
} }
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
const [loadingServerCheck, setLoadingServerCheck] = useState<boolean>(false);
/** /**
* Checks the availability and validity of a Jellyfin server URL. * Checks the availability and validity of a Jellyfin server URL.
* *
@@ -177,33 +168,26 @@ const Login: React.FC = () => {
if (result === undefined) { if (result === undefined) {
Alert.alert( Alert.alert(
t("login.connection_failed"), t("login.connection_failed"),
t("login.could_not_connect_to_server"), t("login.could_not_connect_to_server")
); );
return; return;
} }
await setServer({ address: url }); setServer({ address: url });
}, []); }, []);
const handleQuickConnect = async () => { const handleQuickConnect = async () => {
try { try {
const code = await initiateQuickConnect(); const code = await initiateQuickConnect();
if (code) { if (code) {
Alert.alert( Alert.alert(t("login.quick_connect"), t("login.enter_code_to_login", {code: code}), [
t("login.quick_connect"), {
t("login.enter_code_to_login", { code: code }), text: t("login.got_it"),
[ },
{ ]);
text: t("login.got_it"),
},
],
);
} }
} catch (error) { } catch (error) {
Alert.alert( Alert.alert(t("login.error_title"), t("login.failed_to_initiate_quick_connect"));
t("login.error_title"),
t("login.failed_to_initiate_quick_connect"),
);
} }
}; };
@@ -214,22 +198,20 @@ const Login: React.FC = () => {
> >
{api?.basePath ? ( {api?.basePath ? (
<> <>
<View className='flex flex-col h-full relative items-center justify-center'> <View className="flex flex-col h-full relative items-center justify-center">
<View className='px-4 -mt-20 w-full'> <View className="px-4 -mt-20 w-full">
<View className='flex flex-col space-y-2'> <View className="flex flex-col space-y-2">
<Text className='text-2xl font-bold -mb-2'> <Text className="text-2xl font-bold -mb-2">
<> <>
{serverName ? ( {serverName ? (
<> <>
{t("login.login_to_title") + " "} {t("login.login_to_title") + " "}
<Text className='text-purple-600'>{serverName}</Text> <Text className="text-purple-600">{serverName}</Text>
</> </>
) : ( ) : t("login.login_title")}
t("login.login_title") </>
)} </Text>
</> <Text className="text-xs text-neutral-400">
</Text>
<Text className='text-xs text-neutral-400'>
{api.basePath} {api.basePath}
</Text> </Text>
<Input <Input
@@ -238,13 +220,13 @@ const Login: React.FC = () => {
setCredentials({ ...credentials, username: text }) setCredentials({ ...credentials, username: text })
} }
value={credentials.username} value={credentials.username}
keyboardType='default' autoFocus
returnKeyType='done' secureTextEntry={false}
autoCapitalize='none' keyboardType="default"
// Changed from username to oneTimeCode because it is a known issue in RN returnKeyType="done"
// https://github.com/facebook/react-native/issues/47106#issuecomment-2521270037 autoCapitalize="none"
textContentType='oneTimeCode' textContentType="username"
clearButtonMode='while-editing' clearButtonMode="while-editing"
maxLength={500} maxLength={500}
/> />
@@ -255,42 +237,42 @@ const Login: React.FC = () => {
} }
value={credentials.password} value={credentials.password}
secureTextEntry secureTextEntry
keyboardType='default' keyboardType="default"
returnKeyType='done' returnKeyType="done"
autoCapitalize='none' autoCapitalize="none"
textContentType='password' textContentType="password"
clearButtonMode='while-editing' clearButtonMode="while-editing"
maxLength={500} maxLength={500}
/> />
<View className='flex flex-row items-center justify-between'> <View className="flex flex-row items-center justify-between">
<Button <Button
onPress={handleLogin} onPress={handleLogin}
loading={loading} loading={loading}
className='flex-1 mr-2' className="flex-1 mr-2"
> >
{t("login.login_button")} {t("login.login_button")}
</Button> </Button>
<TouchableOpacity <TouchableOpacity
onPress={handleQuickConnect} onPress={handleQuickConnect}
className='p-2 bg-neutral-900 rounded-xl h-12 w-12 flex items-center justify-center' className="p-2 bg-neutral-900 rounded-xl h-12 w-12 flex items-center justify-center"
> >
<MaterialCommunityIcons <MaterialCommunityIcons
name='cellphone-lock' name="cellphone-lock"
size={24} size={24}
color='white' color="white"
/> />
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</View> </View>
</View> </View>
<View className='absolute bottom-0 left-0 w-full px-4 mb-2'></View> <View className="absolute bottom-0 left-0 w-full px-4 mb-2"></View>
</View> </View>
</> </>
) : ( ) : (
<> <>
<View className='flex flex-col h-full items-center justify-center w-full'> <View className="flex flex-col h-full items-center justify-center w-full">
<View className='flex flex-col gap-y-2 px-4 w-full -mt-36'> <View className="flex flex-col gap-y-2 px-4 w-full -mt-36">
<Image <Image
style={{ style={{
width: 100, width: 100,
@@ -300,43 +282,41 @@ const Login: React.FC = () => {
}} }}
source={require("@/assets/images/StreamyFinFinal.png")} source={require("@/assets/images/StreamyFinFinal.png")}
/> />
<Text className='text-3xl font-bold'>Streamyfin</Text> <Text className="text-3xl font-bold">Streamyfin</Text>
<Text className='text-neutral-500'> <Text className="text-neutral-500">
{t("server.enter_url_to_jellyfin_server")} {t("server.enter_url_to_jellyfin_server")}
</Text> </Text>
<Input <Input
aria-label='Server URL' aria-label="Server URL"
placeholder={t("server.server_url_placeholder")} placeholder={t("server.server_url_placeholder")}
onChangeText={setServerURL} onChangeText={setServerURL}
value={serverURL} value={serverURL}
keyboardType='url' keyboardType="url"
returnKeyType='done' returnKeyType="done"
autoCapitalize='none' autoCapitalize="none"
textContentType='URL' textContentType="URL"
maxLength={500} maxLength={500}
/> />
<Button <Button
loading={loadingServerCheck} loading={loadingServerCheck}
disabled={loadingServerCheck} disabled={loadingServerCheck}
onPress={async () => { onPress={async () => await handleConnect(serverURL)}
await handleConnect(serverURL); className="w-full grow"
}}
className='w-full grow'
> >
{t("server.connect_button")} {t("server.connect_button")}
</Button> </Button>
<JellyfinServerDiscovery <JellyfinServerDiscovery
onServerSelect={async (server) => { onServerSelect={(server) => {
setServerURL(server.address); setServerURL(server.address);
if (server.serverName) { if (server.serverName) {
setServerName(server.serverName); setServerName(server.serverName);
} }
await handleConnect(server.address); handleConnect(server.address);
}} }}
/> />
<PreviousServersList <PreviousServersList
onServerSelect={async (s) => { onServerSelect={(s) => {
await handleConnect(s.address); handleConnect(s.address);
}} }}
/> />
</View> </View>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,54 +0,0 @@
{
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
"organizeImports": {
"enabled": true
},
"files": {
"ignore": [
"node_modules",
"ios",
"android",
"Streamyfin.app",
"utils/jellyseerr"
]
},
"linter": {
"enabled": true,
"rules": {
"style": {
"useImportType": "off",
"noNonNullAssertion": "off"
},
"recommended": true,
"correctness": { "useExhaustiveDependencies": "off" },
"suspicious": {
"noExplicitAny": "off"
}
}
},
"formatter": {
"enabled": true,
"formatWithErrors": true,
"attributePosition": "auto",
"indentStyle": "space",
"indentWidth": 2,
"lineEnding": "lf",
"lineWidth": 80
},
"javascript": {
"formatter": {
"arrowParentheses": "always",
"bracketSameLine": false,
"bracketSpacing": true,
"jsxQuoteStyle": "single",
"quoteProperties": "asNeeded",
"semicolons": "always",
"lineWidth": 80
}
},
"json": {
"formatter": {
"trailingCommas": "none"
}
}
}

256
bun.lock
View File

@@ -13,6 +13,7 @@
"@gorhom/bottom-sheet": "^5.1.0", "@gorhom/bottom-sheet": "^5.1.0",
"@jellyfin/sdk": "^0.11.0", "@jellyfin/sdk": "^0.11.0",
"@kesha-antonov/react-native-background-downloader": "3.2.6", "@kesha-antonov/react-native-background-downloader": "3.2.6",
"@react-native-async-storage/async-storage": "1.23.1",
"@react-native-community/netinfo": "11.4.1", "@react-native-community/netinfo": "11.4.1",
"@react-native-menu/menu": "^1.2.2", "@react-native-menu/menu": "^1.2.2",
"@react-navigation/bottom-tabs": "^7.2.0", "@react-navigation/bottom-tabs": "^7.2.0",
@@ -20,6 +21,9 @@
"@react-navigation/native": "^7.0.14", "@react-navigation/native": "^7.0.14",
"@shopify/flash-list": "1.7.3", "@shopify/flash-list": "1.7.3",
"@tanstack/react-query": "^5.66.0", "@tanstack/react-query": "^5.66.0",
"@types/lodash": "^4.17.15",
"@types/react-native-vector-icons": "^6.4.18",
"@types/uuid": "^10.0.0",
"add": "^2.0.6", "add": "^2.0.6",
"axios": "^1.7.9", "axios": "^1.7.9",
"expo": "^52.0.31", "expo": "^52.0.31",
@@ -44,7 +48,7 @@
"expo-router": "~4.0.17", "expo-router": "~4.0.17",
"expo-screen-orientation": "~8.0.4", "expo-screen-orientation": "~8.0.4",
"expo-sensors": "~14.0.2", "expo-sensors": "~14.0.2",
"expo-splash-screen": "~0.29.22", "expo-splash-screen": "~0.29.21",
"expo-status-bar": "~2.0.1", "expo-status-bar": "~2.0.1",
"expo-system-ui": "~4.0.8", "expo-system-ui": "~4.0.8",
"expo-task-manager": "~12.0.5", "expo-task-manager": "~12.0.5",
@@ -98,17 +102,11 @@
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.26.8", "@babel/core": "^7.26.8",
"@biomejs/biome": "^1.9.4",
"@react-native-community/cli": "15.1.3", "@react-native-community/cli": "15.1.3",
"@react-native-tvos/config-tv": "^0.1.1", "@react-native-tvos/config-tv": "^0.1.1",
"@types/jest": "^29.5.14", "@types/jest": "^29.5.14",
"@types/lodash": "^4.17.15",
"@types/react": "~18.3.12", "@types/react": "~18.3.12",
"@types/react-native-vector-icons": "^6.4.18",
"@types/react-test-renderer": "^19.0.0", "@types/react-test-renderer": "^19.0.0",
"@types/uuid": "^10.0.0",
"husky": "^9.1.7",
"lint-staged": "^15.5.0",
"patch-package": "^8.0.0", "patch-package": "^8.0.0",
"postinstall-postinstall": "^2.1.0", "postinstall-postinstall": "^2.1.0",
"react-test-renderer": "19.0.0", "react-test-renderer": "19.0.0",
@@ -379,24 +377,6 @@
"@babel/types": ["@babel/types@7.26.9", "", { "dependencies": { "@babel/helper-string-parser": "^7.25.9", "@babel/helper-validator-identifier": "^7.25.9" } }, "sha512-Y3IR1cRnOxOCDvMmNiym7XpXQ93iGDDPHx+Zj+NM+rg0fBaShfQLkg+hKPaZCEvg5N/LeCo4+Rj/i3FuJsIQaw=="], "@babel/types": ["@babel/types@7.26.9", "", { "dependencies": { "@babel/helper-string-parser": "^7.25.9", "@babel/helper-validator-identifier": "^7.25.9" } }, "sha512-Y3IR1cRnOxOCDvMmNiym7XpXQ93iGDDPHx+Zj+NM+rg0fBaShfQLkg+hKPaZCEvg5N/LeCo4+Rj/i3FuJsIQaw=="],
"@biomejs/biome": ["@biomejs/biome@1.9.4", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "1.9.4", "@biomejs/cli-darwin-x64": "1.9.4", "@biomejs/cli-linux-arm64": "1.9.4", "@biomejs/cli-linux-arm64-musl": "1.9.4", "@biomejs/cli-linux-x64": "1.9.4", "@biomejs/cli-linux-x64-musl": "1.9.4", "@biomejs/cli-win32-arm64": "1.9.4", "@biomejs/cli-win32-x64": "1.9.4" }, "bin": { "biome": "bin/biome" } }, "sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog=="],
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@1.9.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bFBsPWrNvkdKrNCYeAp+xo2HecOGPAy9WyNyB/jKnnedgzl4W4Hb9ZMzYNbf8dMCGmUdSavlYHiR01QaYR58cw=="],
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@1.9.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-ngYBh/+bEedqkSevPVhLP4QfVPCpb+4BBe2p7Xs32dBgs7rh9nY2AIYUL6BgLw1JVXV8GlpKmb/hNiuIxfPfZg=="],
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@1.9.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-fJIW0+LYujdjUgJJuwesP4EjIBl/N/TcOX3IvIHJQNsAqvV2CHIogsmA94BPG6jZATS4Hi+xv4SkBBQSt1N4/g=="],
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@1.9.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-v665Ct9WCRjGa8+kTr0CzApU0+XXtRgwmzIf1SeKSGAv+2scAlW6JR5PMFo6FzqqZ64Po79cKODKf3/AAmECqA=="],
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@1.9.4", "", { "os": "linux", "cpu": "x64" }, "sha512-lRCJv/Vi3Vlwmbd6K+oQ0KhLHMAysN8lXoCI7XeHlxaajk06u7G+UsFSO01NAs5iYuWKmVZjmiOzJ0OJmGsMwg=="],
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@1.9.4", "", { "os": "linux", "cpu": "x64" }, "sha512-gEhi/jSBhZ2m6wjV530Yy8+fNqG8PAinM3oV7CyO+6c3CEh16Eizm21uHVsyVBEB6RIM8JHIl6AGYCv6Q6Q9Tg=="],
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@1.9.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-tlbhLk+WXZmgwoIKwHIHEBZUwxml7bRJgk0X2sPyNR3S93cdRq6XulAZRQJ17FYGGzWne0fgrXBKpl7l4M87Hg=="],
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@1.9.4", "", { "os": "win32", "cpu": "x64" }, "sha512-8Y5wMhVIPaWe6jw2H+KlEm4wP/f7EW3810ZLmDlrEEy5KvBsb9ECEfu/kMWD484ijfQ8+nIi0giMgu9g1UAuuA=="],
"@bottom-tabs/react-navigation": ["@bottom-tabs/react-navigation@0.8.6", "", { "dependencies": { "color": "^4.2.3" }, "peerDependencies": { "@react-navigation/native": ">=7", "react": "*", "react-native": "*", "react-native-bottom-tabs": "*" } }, "sha512-hLlyBAUz4ahaVK2Op2VcJeAkCSpm3KKho4IojkPyXsos4WEHtO44EYWC71TDbVGeOP5HQ9k7FSwAW3IiZs0wHw=="], "@bottom-tabs/react-navigation": ["@bottom-tabs/react-navigation@0.8.6", "", { "dependencies": { "color": "^4.2.3" }, "peerDependencies": { "@react-navigation/native": ">=7", "react": "*", "react-native": "*", "react-native-bottom-tabs": "*" } }, "sha512-hLlyBAUz4ahaVK2Op2VcJeAkCSpm3KKho4IojkPyXsos4WEHtO44EYWC71TDbVGeOP5HQ9k7FSwAW3IiZs0wHw=="],
"@config-plugins/ffmpeg-kit-react-native": ["@config-plugins/ffmpeg-kit-react-native@9.0.0", "", { "dependencies": { "semver": "^7.3.5" }, "peerDependencies": { "expo": "^52" } }, "sha512-04bXwdq7pmUPoGqYV0YGsrW/8Db+TNicn2Hznb5t+Dl740z9QkNGP4A38y1Mdz7mCU2EW0riASwl/JTH+6rBvw=="], "@config-plugins/ffmpeg-kit-react-native": ["@config-plugins/ffmpeg-kit-react-native@9.0.0", "", { "dependencies": { "semver": "^7.3.5" }, "peerDependencies": { "expo": "^52" } }, "sha512-04bXwdq7pmUPoGqYV0YGsrW/8Db+TNicn2Hznb5t+Dl740z9QkNGP4A38y1Mdz7mCU2EW0riASwl/JTH+6rBvw=="],
@@ -407,7 +387,7 @@
"@expo/bunyan": ["@expo/bunyan@4.0.1", "", { "dependencies": { "uuid": "^8.0.0" } }, "sha512-+Lla7nYSiHZirgK+U/uYzsLv/X+HaJienbD5AKX1UQZHYfWaP+9uuQluRB4GrEVWF0GZ7vEVp/jzaOT9k/SQlg=="], "@expo/bunyan": ["@expo/bunyan@4.0.1", "", { "dependencies": { "uuid": "^8.0.0" } }, "sha512-+Lla7nYSiHZirgK+U/uYzsLv/X+HaJienbD5AKX1UQZHYfWaP+9uuQluRB4GrEVWF0GZ7vEVp/jzaOT9k/SQlg=="],
"@expo/cli": ["@expo/cli@0.22.18", "", { "dependencies": { "@0no-co/graphql.web": "^1.0.8", "@babel/runtime": "^7.20.0", "@expo/code-signing-certificates": "^0.0.5", "@expo/config": "~10.0.10", "@expo/config-plugins": "~9.0.15", "@expo/devcert": "^1.1.2", "@expo/env": "~0.4.2", "@expo/image-utils": "^0.6.5", "@expo/json-file": "^9.0.2", "@expo/metro-config": "~0.19.11", "@expo/osascript": "^2.1.6", "@expo/package-manager": "^1.7.2", "@expo/plist": "^0.2.2", "@expo/prebuild-config": "^8.0.28", "@expo/rudder-sdk-node": "^1.1.1", "@expo/spawn-async": "^1.7.2", "@expo/ws-tunnel": "^1.0.1", "@expo/xcpretty": "^4.3.0", "@react-native/dev-middleware": "0.76.7", "@urql/core": "^5.0.6", "@urql/exchange-retry": "^1.3.0", "accepts": "^1.3.8", "arg": "^5.0.2", "better-opn": "~3.0.2", "bplist-creator": "0.0.7", "bplist-parser": "^0.3.1", "cacache": "^18.0.2", "chalk": "^4.0.0", "ci-info": "^3.3.0", "compression": "^1.7.4", "connect": "^3.7.0", "debug": "^4.3.4", "env-editor": "^0.4.1", "fast-glob": "^3.3.2", "form-data": "^3.0.1", "freeport-async": "^2.0.0", "fs-extra": "~8.1.0", "getenv": "^1.0.0", "glob": "^10.4.2", "internal-ip": "^4.3.0", "is-docker": "^2.0.0", "is-wsl": "^2.1.1", "lodash.debounce": "^4.0.8", "minimatch": "^3.0.4", "node-forge": "^1.3.1", "npm-package-arg": "^11.0.0", "ora": "^3.4.0", "picomatch": "^3.0.1", "pretty-bytes": "^5.6.0", "pretty-format": "^29.7.0", "progress": "^2.0.3", "prompts": "^2.3.2", "qrcode-terminal": "0.11.0", "require-from-string": "^2.0.2", "requireg": "^0.2.2", "resolve": "^1.22.2", "resolve-from": "^5.0.0", "resolve.exports": "^2.0.3", "semver": "^7.6.0", "send": "^0.19.0", "slugify": "^1.3.4", "source-map-support": "~0.5.21", "stacktrace-parser": "^0.1.10", "structured-headers": "^0.4.1", "tar": "^6.2.1", "temp-dir": "^2.0.0", "tempy": "^0.7.1", "terminal-link": "^2.1.1", "undici": "^6.18.2", "unique-string": "~2.0.0", "wrap-ansi": "^7.0.0", "ws": "^8.12.1" }, "bin": { "expo-internal": "build/bin/cli" } }, "sha512-TWGKHWTYU9xE7YETPk2zQzLPl+bldpzZCa0Cqg0QeENpu03ZEnMxUqrgHwrbWGTf7ONTYC1tODBkFCFw/qgPGA=="], "@expo/cli": ["@expo/cli@0.22.16", "", { "dependencies": { "@0no-co/graphql.web": "^1.0.8", "@babel/runtime": "^7.20.0", "@expo/code-signing-certificates": "^0.0.5", "@expo/config": "~10.0.10", "@expo/config-plugins": "~9.0.15", "@expo/devcert": "^1.1.2", "@expo/env": "~0.4.2", "@expo/image-utils": "^0.6.5", "@expo/json-file": "^9.0.2", "@expo/metro-config": "~0.19.10", "@expo/osascript": "^2.1.6", "@expo/package-manager": "^1.7.2", "@expo/plist": "^0.2.2", "@expo/prebuild-config": "^8.0.27", "@expo/rudder-sdk-node": "^1.1.1", "@expo/spawn-async": "^1.7.2", "@expo/ws-tunnel": "^1.0.1", "@expo/xcpretty": "^4.3.0", "@react-native/dev-middleware": "0.76.7", "@urql/core": "^5.0.6", "@urql/exchange-retry": "^1.3.0", "accepts": "^1.3.8", "arg": "^5.0.2", "better-opn": "~3.0.2", "bplist-creator": "0.0.7", "bplist-parser": "^0.3.1", "cacache": "^18.0.2", "chalk": "^4.0.0", "ci-info": "^3.3.0", "compression": "^1.7.4", "connect": "^3.7.0", "debug": "^4.3.4", "env-editor": "^0.4.1", "fast-glob": "^3.3.2", "form-data": "^3.0.1", "freeport-async": "^2.0.0", "fs-extra": "~8.1.0", "getenv": "^1.0.0", "glob": "^10.4.2", "internal-ip": "^4.3.0", "is-docker": "^2.0.0", "is-wsl": "^2.1.1", "lodash.debounce": "^4.0.8", "minimatch": "^3.0.4", "node-forge": "^1.3.1", "npm-package-arg": "^11.0.0", "ora": "^3.4.0", "picomatch": "^3.0.1", "pretty-bytes": "^5.6.0", "pretty-format": "^29.7.0", "progress": "^2.0.3", "prompts": "^2.3.2", "qrcode-terminal": "0.11.0", "require-from-string": "^2.0.2", "requireg": "^0.2.2", "resolve": "^1.22.2", "resolve-from": "^5.0.0", "resolve.exports": "^2.0.3", "semver": "^7.6.0", "send": "^0.19.0", "slugify": "^1.3.4", "source-map-support": "~0.5.21", "stacktrace-parser": "^0.1.10", "structured-headers": "^0.4.1", "tar": "^6.2.1", "temp-dir": "^2.0.0", "tempy": "^0.7.1", "terminal-link": "^2.1.1", "undici": "^6.18.2", "unique-string": "~2.0.0", "wrap-ansi": "^7.0.0", "ws": "^8.12.1" }, "bin": { "expo-internal": "build/bin/cli" } }, "sha512-a8Ulbnji9kFatnOtsWGCRs6nMUj9UNC0/WhE74HQdXGDGMn5Pl8eNe3cLMy9G54DdqAmEZmRZpgXmcudT78fEQ=="],
"@expo/code-signing-certificates": ["@expo/code-signing-certificates@0.0.5", "", { "dependencies": { "node-forge": "^1.2.1", "nullthrows": "^1.1.1" } }, "sha512-BNhXkY1bblxKZpltzAx98G2Egj9g1Q+JRcvR7E99DOj862FTCX+ZPsAUtPTr7aHxwtrL7+fL3r0JSmM9kBm+Bw=="], "@expo/code-signing-certificates": ["@expo/code-signing-certificates@0.0.5", "", { "dependencies": { "node-forge": "^1.2.1", "nullthrows": "^1.1.1" } }, "sha512-BNhXkY1bblxKZpltzAx98G2Egj9g1Q+JRcvR7E99DOj862FTCX+ZPsAUtPTr7aHxwtrL7+fL3r0JSmM9kBm+Bw=="],
@@ -421,13 +401,13 @@
"@expo/env": ["@expo/env@0.4.2", "", { "dependencies": { "chalk": "^4.0.0", "debug": "^4.3.4", "dotenv": "~16.4.5", "dotenv-expand": "~11.0.6", "getenv": "^1.0.0" } }, "sha512-TgbCgvSk0Kq0e2fLoqHwEBL4M0ztFjnBEz0YCDm5boc1nvkV1VMuIMteVdeBwnTh8Z0oPJTwHCD49vhMEt1I6A=="], "@expo/env": ["@expo/env@0.4.2", "", { "dependencies": { "chalk": "^4.0.0", "debug": "^4.3.4", "dotenv": "~16.4.5", "dotenv-expand": "~11.0.6", "getenv": "^1.0.0" } }, "sha512-TgbCgvSk0Kq0e2fLoqHwEBL4M0ztFjnBEz0YCDm5boc1nvkV1VMuIMteVdeBwnTh8Z0oPJTwHCD49vhMEt1I6A=="],
"@expo/fingerprint": ["@expo/fingerprint@0.11.11", "", { "dependencies": { "@expo/spawn-async": "^1.7.2", "arg": "^5.0.2", "chalk": "^4.1.2", "debug": "^4.3.4", "find-up": "^5.0.0", "getenv": "^1.0.0", "minimatch": "^3.0.4", "p-limit": "^3.1.0", "resolve-from": "^5.0.0", "semver": "^7.6.0" }, "bin": { "fingerprint": "bin/cli.js" } }, "sha512-gNyn1KnAOpEa8gSNsYqXMTcq0fSwqU/vit6fP5863vLSKxHm/dNt/gm/uZJxrRZxKq71KUJWF6I7d3z8qIfq5g=="], "@expo/fingerprint": ["@expo/fingerprint@0.11.10", "", { "dependencies": { "@expo/spawn-async": "^1.7.2", "arg": "^5.0.2", "chalk": "^4.1.2", "debug": "^4.3.4", "find-up": "^5.0.0", "getenv": "^1.0.0", "minimatch": "^3.0.4", "p-limit": "^3.1.0", "resolve-from": "^5.0.0", "semver": "^7.6.0" }, "bin": { "fingerprint": "bin/cli.js" } }, "sha512-34ZwPjbnnD7KHSyceaxcLQbClCkYHbEp6wBDe+aqimvQw25m2LnliN1cMCVQnpOHkBFRTcbKlowby0fIxAm2bQ=="],
"@expo/image-utils": ["@expo/image-utils@0.6.5", "", { "dependencies": { "@expo/spawn-async": "^1.7.2", "chalk": "^4.0.0", "fs-extra": "9.0.0", "getenv": "^1.0.0", "jimp-compact": "0.16.1", "parse-png": "^2.1.0", "resolve-from": "^5.0.0", "semver": "^7.6.0", "temp-dir": "~2.0.0", "unique-string": "~2.0.0" } }, "sha512-RsS/1CwJYzccvlprYktD42KjyfWZECH6PPIEowvoSmXfGLfdViwcUEI4RvBfKX5Jli6P67H+6YmHvPTbGOboew=="], "@expo/image-utils": ["@expo/image-utils@0.6.5", "", { "dependencies": { "@expo/spawn-async": "^1.7.2", "chalk": "^4.0.0", "fs-extra": "9.0.0", "getenv": "^1.0.0", "jimp-compact": "0.16.1", "parse-png": "^2.1.0", "resolve-from": "^5.0.0", "semver": "^7.6.0", "temp-dir": "~2.0.0", "unique-string": "~2.0.0" } }, "sha512-RsS/1CwJYzccvlprYktD42KjyfWZECH6PPIEowvoSmXfGLfdViwcUEI4RvBfKX5Jli6P67H+6YmHvPTbGOboew=="],
"@expo/json-file": ["@expo/json-file@9.0.2", "", { "dependencies": { "@babel/code-frame": "~7.10.4", "json5": "^2.2.3", "write-file-atomic": "^2.3.0" } }, "sha512-yAznIUrybOIWp3Uax7yRflB0xsEpvIwIEqIjao9SGi2Gaa+N0OamWfe0fnXBSWF+2zzF4VvqwT4W5zwelchfgw=="], "@expo/json-file": ["@expo/json-file@9.0.2", "", { "dependencies": { "@babel/code-frame": "~7.10.4", "json5": "^2.2.3", "write-file-atomic": "^2.3.0" } }, "sha512-yAznIUrybOIWp3Uax7yRflB0xsEpvIwIEqIjao9SGi2Gaa+N0OamWfe0fnXBSWF+2zzF4VvqwT4W5zwelchfgw=="],
"@expo/metro-config": ["@expo/metro-config@0.19.11", "", { "dependencies": { "@babel/core": "^7.20.0", "@babel/generator": "^7.20.5", "@babel/parser": "^7.20.0", "@babel/types": "^7.20.0", "@expo/config": "~10.0.10", "@expo/env": "~0.4.2", "@expo/json-file": "~9.0.2", "@expo/spawn-async": "^1.7.2", "chalk": "^4.1.0", "debug": "^4.3.2", "fs-extra": "^9.1.0", "getenv": "^1.0.0", "glob": "^10.4.2", "jsc-safe-url": "^0.2.4", "lightningcss": "~1.27.0", "minimatch": "^3.0.4", "postcss": "~8.4.32", "resolve-from": "^5.0.0" } }, "sha512-XaobHTcsoHQdKEH7PI/DIpr2QiugkQmPYolbfzkpSJMplNWfSh+cTRjrm4//mS2Sb78qohtu0u2CGJnFqFUGag=="], "@expo/metro-config": ["@expo/metro-config@0.19.10", "", { "dependencies": { "@babel/core": "^7.20.0", "@babel/generator": "^7.20.5", "@babel/parser": "^7.20.0", "@babel/types": "^7.20.0", "@expo/config": "~10.0.9", "@expo/env": "~0.4.1", "@expo/json-file": "~9.0.1", "@expo/spawn-async": "^1.7.2", "chalk": "^4.1.0", "debug": "^4.3.2", "fs-extra": "^9.1.0", "getenv": "^1.0.0", "glob": "^10.4.2", "jsc-safe-url": "^0.2.4", "lightningcss": "~1.27.0", "minimatch": "^3.0.4", "postcss": "~8.4.32", "resolve-from": "^5.0.0" } }, "sha512-/CtsMLhELJRJjAllM4EUnlPUAixn8Q2YhorKBa4uXZ6FvTEZWHJjqsXnQD39gWSEuAIVwLfJ1qgJi8666+dW2w=="],
"@expo/metro-runtime": ["@expo/metro-runtime@4.0.1", "", { "peerDependencies": { "react-native": "*" } }, "sha512-CRpbLvdJ1T42S+lrYa1iZp1KfDeBp4oeZOK3hdpiS5n0vR0nhD6sC1gGF0sTboCTp64tLteikz5Y3j53dvgOIw=="], "@expo/metro-runtime": ["@expo/metro-runtime@4.0.1", "", { "peerDependencies": { "react-native": "*" } }, "sha512-CRpbLvdJ1T42S+lrYa1iZp1KfDeBp4oeZOK3hdpiS5n0vR0nhD6sC1gGF0sTboCTp64tLteikz5Y3j53dvgOIw=="],
@@ -437,7 +417,7 @@
"@expo/plist": ["@expo/plist@0.2.2", "", { "dependencies": { "@xmldom/xmldom": "~0.7.7", "base64-js": "^1.2.3", "xmlbuilder": "^14.0.0" } }, "sha512-ZZGvTO6vEWq02UAPs3LIdja+HRO18+LRI5QuDl6Hs3Ps7KX7xU6Y6kjahWKY37Rx2YjNpX07dGpBFzzC+vKa2g=="], "@expo/plist": ["@expo/plist@0.2.2", "", { "dependencies": { "@xmldom/xmldom": "~0.7.7", "base64-js": "^1.2.3", "xmlbuilder": "^14.0.0" } }, "sha512-ZZGvTO6vEWq02UAPs3LIdja+HRO18+LRI5QuDl6Hs3Ps7KX7xU6Y6kjahWKY37Rx2YjNpX07dGpBFzzC+vKa2g=="],
"@expo/prebuild-config": ["@expo/prebuild-config@8.0.28", "", { "dependencies": { "@expo/config": "~10.0.10", "@expo/config-plugins": "~9.0.15", "@expo/config-types": "^52.0.4", "@expo/image-utils": "^0.6.5", "@expo/json-file": "^9.0.2", "@react-native/normalize-colors": "0.76.7", "debug": "^4.3.1", "fs-extra": "^9.0.0", "resolve-from": "^5.0.0", "semver": "^7.6.0", "xml2js": "0.6.0" } }, "sha512-SDDgCKKS1wFNNm3de2vBP8Q5bnxcabuPDE9Mnk9p7Gb4qBavhwMbAtrLcAyZB+WRb4QM+yan3z3K95vvCfI/+A=="], "@expo/prebuild-config": ["@expo/prebuild-config@8.0.27", "", { "dependencies": { "@expo/config": "~10.0.9", "@expo/config-plugins": "~9.0.15", "@expo/config-types": "^52.0.4", "@expo/image-utils": "^0.6.4", "@expo/json-file": "^9.0.1", "@react-native/normalize-colors": "0.76.7", "debug": "^4.3.1", "fs-extra": "^9.0.0", "resolve-from": "^5.0.0", "semver": "^7.6.0", "xml2js": "0.6.0" } }, "sha512-UFGOx4TfiT2gOde8RylwmXctp/WvqBQ4TN7z1YL0WWXfG9TWfO7HdsUnqQhGMW+CDDc7FOJMEo8q1a6xiikfYA=="],
"@expo/react-native-action-sheet": ["@expo/react-native-action-sheet@4.1.0", "", { "dependencies": { "@types/hoist-non-react-statics": "^3.3.1", "hoist-non-react-statics": "^3.3.0" }, "peerDependencies": { "react": ">=18.0.0" } }, "sha512-RILoWhREgjMdr1NUSmZa/cHg8onV2YPDAMOy0iIP1c3H7nT9QQZf5dQNHK8ehcLM82sarVxriBJyYSSHAx7j6w=="], "@expo/react-native-action-sheet": ["@expo/react-native-action-sheet@4.1.0", "", { "dependencies": { "@types/hoist-non-react-statics": "^3.3.1", "hoist-non-react-statics": "^3.3.0" }, "peerDependencies": { "react": ">=18.0.0" } }, "sha512-RILoWhREgjMdr1NUSmZa/cHg8onV2YPDAMOy0iIP1c3H7nT9QQZf5dQNHK8ehcLM82sarVxriBJyYSSHAx7j6w=="],
@@ -451,7 +431,7 @@
"@expo/vector-icons": ["@expo/vector-icons@14.0.4", "", { "dependencies": { "prop-types": "^15.8.1" } }, "sha512-+yKshcbpDfbV4zoXOgHxCwh7lkE9VVTT5T03OUlBsqfze1PLy6Hi4jp1vSb1GVbY6eskvMIivGVc9SKzIv0oEQ=="], "@expo/vector-icons": ["@expo/vector-icons@14.0.4", "", { "dependencies": { "prop-types": "^15.8.1" } }, "sha512-+yKshcbpDfbV4zoXOgHxCwh7lkE9VVTT5T03OUlBsqfze1PLy6Hi4jp1vSb1GVbY6eskvMIivGVc9SKzIv0oEQ=="],
"@expo/ws-tunnel": ["@expo/ws-tunnel@1.0.5", "", {}, "sha512-Ta9KzslHAIbw2ZoyZ7Ud7/QImucy+K4YvOqo9AhGfUfH76hQzaffQreOySzYusDfW8Y+EXh0ZNWE68dfCumFFw=="], "@expo/ws-tunnel": ["@expo/ws-tunnel@1.0.4", "", {}, "sha512-spXCVXxbeKOe8YZ9igd+MDfXZe6LeDvFAdILijeTSG+XcxGrZLmqMWWkFKR0nV8lTWZ+NugUT3CoiXmEuKKQ7w=="],
"@expo/xcpretty": ["@expo/xcpretty@4.3.2", "", { "dependencies": { "@babel/code-frame": "7.10.4", "chalk": "^4.1.0", "find-up": "^5.0.0", "js-yaml": "^4.1.0" }, "bin": { "excpretty": "build/cli.js" } }, "sha512-ReZxZ8pdnoI3tP/dNnJdnmAk7uLT4FjsKDGW7YeDdvdOMz2XCQSmSCM9IWlrXuWtMF9zeSB6WJtEhCQ41gQOfw=="], "@expo/xcpretty": ["@expo/xcpretty@4.3.2", "", { "dependencies": { "@babel/code-frame": "7.10.4", "chalk": "^4.1.0", "find-up": "^5.0.0", "js-yaml": "^4.1.0" }, "bin": { "excpretty": "build/cli.js" } }, "sha512-ReZxZ8pdnoI3tP/dNnJdnmAk7uLT4FjsKDGW7YeDdvdOMz2XCQSmSCM9IWlrXuWtMF9zeSB6WJtEhCQ41gQOfw=="],
@@ -595,6 +575,8 @@
"@radix-ui/rect": ["@radix-ui/rect@1.1.0", "", {}, "sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg=="], "@radix-ui/rect": ["@radix-ui/rect@1.1.0", "", {}, "sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg=="],
"@react-native-async-storage/async-storage": ["@react-native-async-storage/async-storage@1.23.1", "", { "dependencies": { "merge-options": "^3.0.4" }, "peerDependencies": { "react-native": "^0.0.0-0 || >=0.60 <1.0" } }, "sha512-Qd2kQ3yi6Y3+AcUlrHxSLlnBvpdCEMVGFlVBneVOjaFaPU61g1huc38g339ysXspwY1QZA2aNhrk/KlHGO+ewA=="],
"@react-native-community/cli": ["@react-native-community/cli@15.1.3", "", { "dependencies": { "@react-native-community/cli-clean": "15.1.3", "@react-native-community/cli-config": "15.1.3", "@react-native-community/cli-debugger-ui": "15.1.3", "@react-native-community/cli-doctor": "15.1.3", "@react-native-community/cli-server-api": "15.1.3", "@react-native-community/cli-tools": "15.1.3", "@react-native-community/cli-types": "15.1.3", "chalk": "^4.1.2", "commander": "^9.4.1", "deepmerge": "^4.3.0", "execa": "^5.0.0", "find-up": "^5.0.0", "fs-extra": "^8.1.0", "graceful-fs": "^4.1.3", "prompts": "^2.4.2", "semver": "^7.5.2" }, "bin": { "rnc-cli": "build/bin.js" } }, "sha512-+ih/WYUkJsEV2CMAnOHvVoSIz/Ahg5UJk+sqSIOmY79mWAglQzfLP71o7b0neJCnJWLmWiO6G6/S+kmULefD5g=="], "@react-native-community/cli": ["@react-native-community/cli@15.1.3", "", { "dependencies": { "@react-native-community/cli-clean": "15.1.3", "@react-native-community/cli-config": "15.1.3", "@react-native-community/cli-debugger-ui": "15.1.3", "@react-native-community/cli-doctor": "15.1.3", "@react-native-community/cli-server-api": "15.1.3", "@react-native-community/cli-tools": "15.1.3", "@react-native-community/cli-types": "15.1.3", "chalk": "^4.1.2", "commander": "^9.4.1", "deepmerge": "^4.3.0", "execa": "^5.0.0", "find-up": "^5.0.0", "fs-extra": "^8.1.0", "graceful-fs": "^4.1.3", "prompts": "^2.4.2", "semver": "^7.5.2" }, "bin": { "rnc-cli": "build/bin.js" } }, "sha512-+ih/WYUkJsEV2CMAnOHvVoSIz/Ahg5UJk+sqSIOmY79mWAglQzfLP71o7b0neJCnJWLmWiO6G6/S+kmULefD5g=="],
"@react-native-community/cli-clean": ["@react-native-community/cli-clean@15.1.3", "", { "dependencies": { "@react-native-community/cli-tools": "15.1.3", "chalk": "^4.1.2", "execa": "^5.0.0", "fast-glob": "^3.3.2" } }, "sha512-3s9NGapIkONFoCUN2s77NYI987GPSCdr74rTf0TWyGIDf4vTYgKoWKKR+Ml3VTa1BCj51r4cYuHEKE1pjUSc0w=="], "@react-native-community/cli-clean": ["@react-native-community/cli-clean@15.1.3", "", { "dependencies": { "@react-native-community/cli-tools": "15.1.3", "chalk": "^4.1.2", "execa": "^5.0.0", "fast-glob": "^3.3.2" } }, "sha512-3s9NGapIkONFoCUN2s77NYI987GPSCdr74rTf0TWyGIDf4vTYgKoWKKR+Ml3VTa1BCj51r4cYuHEKE1pjUSc0w=="],
@@ -697,9 +679,9 @@
"@sinonjs/fake-timers": ["@sinonjs/fake-timers@10.3.0", "", { "dependencies": { "@sinonjs/commons": "^3.0.0" } }, "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA=="], "@sinonjs/fake-timers": ["@sinonjs/fake-timers@10.3.0", "", { "dependencies": { "@sinonjs/commons": "^3.0.0" } }, "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA=="],
"@tanstack/query-core": ["@tanstack/query-core@5.66.4", "", {}, "sha512-skM/gzNX4shPkqmdTCSoHtJAPMTtmIJNS0hE+xwTTUVYwezArCT34NMermABmBVUg5Ls5aiUXEDXfqwR1oVkcA=="], "@tanstack/query-core": ["@tanstack/query-core@5.66.0", "", {}, "sha512-J+JeBtthiKxrpzUu7rfIPDzhscXF2p5zE/hVdrqkACBP8Yu0M96mwJ5m/8cPPYQE9aRNvXztXHlNwIh4FEeMZw=="],
"@tanstack/react-query": ["@tanstack/react-query@5.66.9", "", { "dependencies": { "@tanstack/query-core": "5.66.4" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-NRI02PHJsP5y2gAuWKP+awamTIBFBSKMnO6UVzi03GTclmHHHInH5UzVgzi5tpu4+FmGfsdT7Umqegobtsp23A=="], "@tanstack/react-query": ["@tanstack/react-query@5.66.0", "", { "dependencies": { "@tanstack/query-core": "5.66.0" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-z3sYixFQJe8hndFnXgWu7C79ctL+pI0KAelYyW+khaNJ1m22lWrhJU2QrsTcRKMuVPtoZvfBYrTStIdKo+x0Xw=="],
"@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="], "@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="],
@@ -775,7 +757,7 @@
"aggregate-error": ["aggregate-error@3.1.0", "", { "dependencies": { "clean-stack": "^2.0.0", "indent-string": "^4.0.0" } }, "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA=="], "aggregate-error": ["aggregate-error@3.1.0", "", { "dependencies": { "clean-stack": "^2.0.0", "indent-string": "^4.0.0" } }, "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA=="],
"ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="], "ajv": ["ajv@8.11.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2", "uri-js": "^4.2.2" } }, "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg=="],
"ajv-formats": ["ajv-formats@2.1.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA=="], "ajv-formats": ["ajv-formats@2.1.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA=="],
@@ -783,7 +765,7 @@
"anser": ["anser@1.4.10", "", {}, "sha512-hCv9AqTQ8ycjpSd3upOJd7vFwW1JaoYQ7tpham03GJ1ca8/65rqn0RpaWpItOAd6ylW9wAw6luXYPJIyPFVOww=="], "anser": ["anser@1.4.10", "", {}, "sha512-hCv9AqTQ8ycjpSd3upOJd7vFwW1JaoYQ7tpham03GJ1ca8/65rqn0RpaWpItOAd6ylW9wAw6luXYPJIyPFVOww=="],
"ansi-escapes": ["ansi-escapes@7.0.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-GdYO7a61mR0fOlAsvC9/rIHf7L96sBc6dEWzeOu+KAea5bZyQRPIpojrVoI4AXGJS/ycu/fBTdLrUkA4ODrvjw=="], "ansi-escapes": ["ansi-escapes@4.3.2", "", { "dependencies": { "type-fest": "^0.21.3" } }, "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ=="],
"ansi-fragments": ["ansi-fragments@0.2.1", "", { "dependencies": { "colorette": "^1.0.7", "slice-ansi": "^2.0.0", "strip-ansi": "^5.0.0" } }, "sha512-DykbNHxuXQwUDRv5ibc2b0x7uw7wmwOGLBUd5RmaQ5z8Lhx19vwvKV+FAsM5rEA6dEcHxX+/Ad5s9eF2k2bB+w=="], "ansi-fragments": ["ansi-fragments@0.2.1", "", { "dependencies": { "colorette": "^1.0.7", "slice-ansi": "^2.0.0", "strip-ansi": "^5.0.0" } }, "sha512-DykbNHxuXQwUDRv5ibc2b0x7uw7wmwOGLBUd5RmaQ5z8Lhx19vwvKV+FAsM5rEA6dEcHxX+/Ad5s9eF2k2bB+w=="],
@@ -849,7 +831,7 @@
"babel-preset-current-node-syntax": ["babel-preset-current-node-syntax@1.1.0", "", { "dependencies": { "@babel/plugin-syntax-async-generators": "^7.8.4", "@babel/plugin-syntax-bigint": "^7.8.3", "@babel/plugin-syntax-class-properties": "^7.12.13", "@babel/plugin-syntax-class-static-block": "^7.14.5", "@babel/plugin-syntax-import-attributes": "^7.24.7", "@babel/plugin-syntax-import-meta": "^7.10.4", "@babel/plugin-syntax-json-strings": "^7.8.3", "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", "@babel/plugin-syntax-numeric-separator": "^7.10.4", "@babel/plugin-syntax-object-rest-spread": "^7.8.3", "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", "@babel/plugin-syntax-optional-chaining": "^7.8.3", "@babel/plugin-syntax-private-property-in-object": "^7.14.5", "@babel/plugin-syntax-top-level-await": "^7.14.5" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-ldYss8SbBlWva1bs28q78Ju5Zq1F+8BrqBZZ0VFhLBvhh6lCpC2o3gDJi/5DRLs9FgYZCnmPYIVFU4lRXCkyUw=="], "babel-preset-current-node-syntax": ["babel-preset-current-node-syntax@1.1.0", "", { "dependencies": { "@babel/plugin-syntax-async-generators": "^7.8.4", "@babel/plugin-syntax-bigint": "^7.8.3", "@babel/plugin-syntax-class-properties": "^7.12.13", "@babel/plugin-syntax-class-static-block": "^7.14.5", "@babel/plugin-syntax-import-attributes": "^7.24.7", "@babel/plugin-syntax-import-meta": "^7.10.4", "@babel/plugin-syntax-json-strings": "^7.8.3", "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", "@babel/plugin-syntax-numeric-separator": "^7.10.4", "@babel/plugin-syntax-object-rest-spread": "^7.8.3", "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", "@babel/plugin-syntax-optional-chaining": "^7.8.3", "@babel/plugin-syntax-private-property-in-object": "^7.14.5", "@babel/plugin-syntax-top-level-await": "^7.14.5" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-ldYss8SbBlWva1bs28q78Ju5Zq1F+8BrqBZZ0VFhLBvhh6lCpC2o3gDJi/5DRLs9FgYZCnmPYIVFU4lRXCkyUw=="],
"babel-preset-expo": ["babel-preset-expo@12.0.9", "", { "dependencies": { "@babel/plugin-proposal-decorators": "^7.12.9", "@babel/plugin-transform-export-namespace-from": "^7.22.11", "@babel/plugin-transform-object-rest-spread": "^7.12.13", "@babel/plugin-transform-parameters": "^7.22.15", "@babel/preset-react": "^7.22.15", "@babel/preset-typescript": "^7.23.0", "@react-native/babel-preset": "0.76.7", "babel-plugin-react-native-web": "~0.19.13", "react-refresh": "^0.14.2" }, "peerDependencies": { "babel-plugin-react-compiler": "^19.0.0-beta-9ee70a1-20241017", "react-compiler-runtime": "^19.0.0-beta-8a03594-20241020" }, "optionalPeers": ["babel-plugin-react-compiler", "react-compiler-runtime"] }, "sha512-1c+ysrTavT49WgVAj0OX/TEzt1kU2mfPhDaDajstshNHXFKPenMPWSViA/DHrJKVIMwaqr+z3GbUOD9GtKgpdg=="], "babel-preset-expo": ["babel-preset-expo@12.0.8", "", { "dependencies": { "@babel/plugin-proposal-decorators": "^7.12.9", "@babel/plugin-transform-export-namespace-from": "^7.22.11", "@babel/plugin-transform-object-rest-spread": "^7.12.13", "@babel/plugin-transform-parameters": "^7.22.15", "@babel/preset-react": "^7.22.15", "@babel/preset-typescript": "^7.23.0", "@react-native/babel-preset": "0.76.7", "babel-plugin-react-native-web": "~0.19.13", "react-refresh": "^0.14.2" }, "peerDependencies": { "babel-plugin-react-compiler": "^19.0.0-beta-9ee70a1-20241017", "react-compiler-runtime": "^19.0.0-beta-8a03594-20241020" }, "optionalPeers": ["babel-plugin-react-compiler", "react-compiler-runtime"] }, "sha512-bojAddWZJusLs3NVdF+jN3WweTYVEZXBKIeO0sOhqOg7UPh5w1bnMkx7SDua0FgQMGBxb13qM31Y46yeZnmXjw=="],
"babel-preset-jest": ["babel-preset-jest@29.6.3", "", { "dependencies": { "babel-plugin-jest-hoist": "^29.6.3", "babel-preset-current-node-syntax": "^1.0.0" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA=="], "babel-preset-jest": ["babel-preset-jest@29.6.3", "", { "dependencies": { "babel-plugin-jest-hoist": "^29.6.3", "babel-preset-current-node-syntax": "^1.0.0" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA=="],
@@ -917,7 +899,7 @@
"camelize": ["camelize@1.0.1", "", {}, "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ=="], "camelize": ["camelize@1.0.1", "", {}, "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ=="],
"caniuse-lite": ["caniuse-lite@1.0.30001700", "", {}, "sha512-2S6XIXwaE7K7erT8dY+kLQcpa5ms63XlRkMkReXjle+kf6c5g38vyMl+Z5y8dSxOFDhcFe+nxnn261PLxBSQsQ=="], "caniuse-lite": ["caniuse-lite@1.0.30001699", "", {}, "sha512-b+uH5BakXZ9Do9iK+CkDmctUSEqZl+SP056vc5usa0PL+ev5OHw003rZXcnjNDv3L8P5j6rwT6C0BPKSikW08w=="],
"centra": ["centra@2.7.0", "", { "dependencies": { "follow-redirects": "^1.15.6" } }, "sha512-PbFMgMSrmgx6uxCdm57RUos9Tc3fclMvhLSATYN39XsDV29B89zZ3KA89jmY0vwSGazyU+uerqwa6t+KaodPcg=="], "centra": ["centra@2.7.0", "", { "dependencies": { "follow-redirects": "^1.15.6" } }, "sha512-PbFMgMSrmgx6uxCdm57RUos9Tc3fclMvhLSATYN39XsDV29B89zZ3KA89jmY0vwSGazyU+uerqwa6t+KaodPcg=="],
@@ -941,8 +923,6 @@
"cli-spinners": ["cli-spinners@2.9.2", "", {}, "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg=="], "cli-spinners": ["cli-spinners@2.9.2", "", {}, "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg=="],
"cli-truncate": ["cli-truncate@4.0.0", "", { "dependencies": { "slice-ansi": "^5.0.0", "string-width": "^7.0.0" } }, "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA=="],
"client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="], "client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="],
"cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
@@ -959,7 +939,7 @@
"color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="], "color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="],
"colorette": ["colorette@2.0.20", "", {}, "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w=="], "colorette": ["colorette@1.4.0", "", {}, "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g=="],
"combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
@@ -1079,7 +1059,7 @@
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
"electron-to-chromium": ["electron-to-chromium@1.5.103", "", {}, "sha512-P6+XzIkfndgsrjROJWfSvVEgNHtPgbhVyTkwLjUM2HU/h7pZRORgaTlHqfAikqxKmdJMLW8fftrdGWbd/Ds0FA=="], "electron-to-chromium": ["electron-to-chromium@1.5.101", "", {}, "sha512-L0ISiQrP/56Acgu4/i/kfPwWSgrzYZUnQrC0+QPFuhqlLP1Ir7qzPPDVS9BcKIyWTRU8+o6CC8dKw38tSWhYIA=="],
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
@@ -1095,8 +1075,6 @@
"envinfo": ["envinfo@7.14.0", "", { "bin": { "envinfo": "dist/cli.js" } }, "sha512-CO40UI41xDQzhLB1hWyqUKgFhs250pNcGbyGKe1l/e4FSaI/+YE4IMG76GDt0In67WLPACIITC+sOi08x4wIvg=="], "envinfo": ["envinfo@7.14.0", "", { "bin": { "envinfo": "dist/cli.js" } }, "sha512-CO40UI41xDQzhLB1hWyqUKgFhs250pNcGbyGKe1l/e4FSaI/+YE4IMG76GDt0In67WLPACIITC+sOi08x4wIvg=="],
"environment": ["environment@1.1.0", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="],
"eol": ["eol@0.9.1", "", {}, "sha512-Ds/TEoZjwggRoz/Q2O7SE3i4Jm66mqTDfmdHdq/7DKVk3bro9Q8h6WdXKdPqFLMoqxrDK5SVRzHVPOS6uuGtrg=="], "eol": ["eol@0.9.1", "", {}, "sha512-Ds/TEoZjwggRoz/Q2O7SE3i4Jm66mqTDfmdHdq/7DKVk3bro9Q8h6WdXKdPqFLMoqxrDK5SVRzHVPOS6uuGtrg=="],
"error-ex": ["error-ex@1.3.2", "", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g=="], "error-ex": ["error-ex@1.3.2", "", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g=="],
@@ -1127,8 +1105,6 @@
"event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="], "event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="],
"eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="],
"events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="], "events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="],
"exec-async": ["exec-async@2.2.0", "", {}, "sha512-87OpwcEiMia/DeiKFzaQNBNFeN3XkkpYIh9FyOqq5mS2oKv3CBE67PXoEKcr6nodWdXNogTiQ0jE2NGuoffXPw=="], "exec-async": ["exec-async@2.2.0", "", {}, "sha512-87OpwcEiMia/DeiKFzaQNBNFeN3XkkpYIh9FyOqq5mS2oKv3CBE67PXoEKcr6nodWdXNogTiQ0jE2NGuoffXPw=="],
@@ -1139,11 +1115,11 @@
"expect": ["expect@29.7.0", "", { "dependencies": { "@jest/expect-utils": "^29.7.0", "jest-get-type": "^29.6.3", "jest-matcher-utils": "^29.7.0", "jest-message-util": "^29.7.0", "jest-util": "^29.7.0" } }, "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw=="], "expect": ["expect@29.7.0", "", { "dependencies": { "@jest/expect-utils": "^29.7.0", "jest-get-type": "^29.6.3", "jest-matcher-utils": "^29.7.0", "jest-message-util": "^29.7.0", "jest-util": "^29.7.0" } }, "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw=="],
"expo": ["expo@52.0.37", "", { "dependencies": { "@babel/runtime": "^7.20.0", "@expo/cli": "0.22.18", "@expo/config": "~10.0.10", "@expo/config-plugins": "~9.0.15", "@expo/fingerprint": "0.11.11", "@expo/metro-config": "0.19.11", "@expo/vector-icons": "^14.0.0", "babel-preset-expo": "~12.0.9", "expo-asset": "~11.0.4", "expo-constants": "~17.0.7", "expo-file-system": "~18.0.11", "expo-font": "~13.0.4", "expo-keep-awake": "~14.0.3", "expo-modules-autolinking": "2.0.8", "expo-modules-core": "2.2.2", "fbemitter": "^3.0.0", "web-streams-polyfill": "^3.3.2", "whatwg-url-without-unicode": "8.0.0-3" }, "peerDependencies": { "@expo/dom-webview": "*", "@expo/metro-runtime": "*", "react": "*", "react-native": "*", "react-native-webview": "*" }, "optionalPeers": ["@expo/dom-webview", "@expo/metro-runtime", "react-native-webview"], "bin": { "expo": "bin/cli" } }, "sha512-fo37ClqjNLOVInerm7BU27H8lfPfeTC7Pmu72roPzq46DnJfs+KzTxTzE34GcJ0b6hMUx9FRSSGyTQqxzo2TVQ=="], "expo": ["expo@52.0.35", "", { "dependencies": { "@babel/runtime": "^7.20.0", "@expo/cli": "0.22.16", "@expo/config": "~10.0.10", "@expo/config-plugins": "~9.0.15", "@expo/fingerprint": "0.11.10", "@expo/metro-config": "0.19.10", "@expo/vector-icons": "^14.0.0", "babel-preset-expo": "~12.0.8", "expo-asset": "~11.0.3", "expo-constants": "~17.0.6", "expo-file-system": "~18.0.10", "expo-font": "~13.0.3", "expo-keep-awake": "~14.0.2", "expo-modules-autolinking": "2.0.7", "expo-modules-core": "2.2.2", "fbemitter": "^3.0.0", "web-streams-polyfill": "^3.3.2", "whatwg-url-without-unicode": "8.0.0-3" }, "peerDependencies": { "@expo/dom-webview": "*", "@expo/metro-runtime": "*", "react": "*", "react-native": "*", "react-native-webview": "*" }, "optionalPeers": ["@expo/dom-webview", "@expo/metro-runtime", "react-native-webview"], "bin": { "expo": "bin/cli" } }, "sha512-VagwS6MJbU0Eky18i4amkkSy7FTi0v31B0W+qoEcsU4x5OurA381rxw4qGsQE+8pmSD/Gf3DGb8ygJw+HoAsXw=="],
"expo-application": ["expo-application@6.0.2", "", { "peerDependencies": { "expo": "*" } }, "sha512-qcj6kGq3mc7x5yIb5KxESurFTJCoEKwNEL34RdPEvTB/xhl7SeVZlu05sZBqxB1V4Ryzq/LsCb7NHNfBbb3L7A=="], "expo-application": ["expo-application@6.0.2", "", { "peerDependencies": { "expo": "*" } }, "sha512-qcj6kGq3mc7x5yIb5KxESurFTJCoEKwNEL34RdPEvTB/xhl7SeVZlu05sZBqxB1V4Ryzq/LsCb7NHNfBbb3L7A=="],
"expo-asset": ["expo-asset@11.0.4", "", { "dependencies": { "@expo/image-utils": "^0.6.5", "expo-constants": "~17.0.7", "invariant": "^2.2.4", "md5-file": "^3.2.3" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-CdIywU0HrR3wsW5c3n0cT3jW9hccZdnqGsRqY+EY/RWzJbDXtDfAQVEiFHO3mDK7oveUwrP2jK/6ZRNek41/sg=="], "expo-asset": ["expo-asset@11.0.3", "", { "dependencies": { "@expo/image-utils": "^0.6.4", "expo-constants": "~17.0.5", "invariant": "^2.2.4", "md5-file": "^3.2.3" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-vgJnC82IooAVMy5PxbdFIMNJhW4hKAUyxc5VIiAPPf10vFYw6CqHm+hrehu4ST1I4bvg5PV4uKdPxliebcbgLg=="],
"expo-background-fetch": ["expo-background-fetch@13.0.5", "", { "dependencies": { "expo-task-manager": "~12.0.5" }, "peerDependencies": { "expo": "*" } }, "sha512-rLRM+rYDRT0fA0Oaet5ibJK3nKVRkfdjXjISHxjUvIE4ktD9pE+UjAPPdjTXZ5CkNb3JyNNhQGJEGpdJC2HLKw=="], "expo-background-fetch": ["expo-background-fetch@13.0.5", "", { "dependencies": { "expo-task-manager": "~12.0.5" }, "peerDependencies": { "expo": "*" } }, "sha512-rLRM+rYDRT0fA0Oaet5ibJK3nKVRkfdjXjISHxjUvIE4ktD9pE+UjAPPdjTXZ5CkNb3JyNNhQGJEGpdJC2HLKw=="],
@@ -1153,7 +1129,7 @@
"expo-build-properties": ["expo-build-properties@0.13.2", "", { "dependencies": { "ajv": "^8.11.0", "semver": "^7.6.0" }, "peerDependencies": { "expo": "*" } }, "sha512-ML2GwBgn0Bo4yPgnSGb7h3XVxCigS/KFdid3xPC2HldEioTP3UewB/2Qa4WBsam9Fb7lAuRyVHAfRoA3swpDzg=="], "expo-build-properties": ["expo-build-properties@0.13.2", "", { "dependencies": { "ajv": "^8.11.0", "semver": "^7.6.0" }, "peerDependencies": { "expo": "*" } }, "sha512-ML2GwBgn0Bo4yPgnSGb7h3XVxCigS/KFdid3xPC2HldEioTP3UewB/2Qa4WBsam9Fb7lAuRyVHAfRoA3swpDzg=="],
"expo-constants": ["expo-constants@17.0.7", "", { "dependencies": { "@expo/config": "~10.0.10", "@expo/env": "~0.4.2" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-sp5NUiV17I3JblVPIBDgoxgt7JIZS30vcyydCYHxsEoo+aKaeRYXxGYilCvb9lgI6BBwSL24sQ6ZjWsCWoF1VA=="], "expo-constants": ["expo-constants@17.0.6", "", { "dependencies": { "@expo/config": "~10.0.9", "@expo/env": "~0.4.1" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-rl3/hBIIkh4XDkCEMzGpmY6kWj2G1TA4Mq2joeyzoFBepJuGjqnGl7phf/71sTTgamQ1hmhKCLRNXMpRqzzqxw=="],
"expo-crypto": ["expo-crypto@14.0.2", "", { "dependencies": { "base64-js": "^1.3.0" }, "peerDependencies": { "expo": "*" } }, "sha512-WRc9PBpJraJN29VD5Ef7nCecxJmZNyRKcGkNiDQC1nhY5agppzwhqh7zEzNFarE/GqDgSiaDHS8yd5EgFhP9AQ=="], "expo-crypto": ["expo-crypto@14.0.2", "", { "dependencies": { "base64-js": "^1.3.0" }, "peerDependencies": { "expo": "*" } }, "sha512-WRc9PBpJraJN29VD5Ef7nCecxJmZNyRKcGkNiDQC1nhY5agppzwhqh7zEzNFarE/GqDgSiaDHS8yd5EgFhP9AQ=="],
@@ -1169,17 +1145,17 @@
"expo-eas-client": ["expo-eas-client@0.13.2", "", {}, "sha512-2RAAGtkO9vseoJZuW4mhJkiNQ6+FfLrX66OTMq4Qj9mRKZV2Uq/ZquxUGIeJyYqBy4vNYeKbuPd2oJtsV9LBGQ=="], "expo-eas-client": ["expo-eas-client@0.13.2", "", {}, "sha512-2RAAGtkO9vseoJZuW4mhJkiNQ6+FfLrX66OTMq4Qj9mRKZV2Uq/ZquxUGIeJyYqBy4vNYeKbuPd2oJtsV9LBGQ=="],
"expo-file-system": ["expo-file-system@18.0.11", "", { "dependencies": { "web-streams-polyfill": "^3.3.2" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-yDwYfEzWgPXsBZHJW2RJ8Q66ceiFN9Wa5D20pp3fjXVkzPBDwxnYwiPWk4pVmCa5g4X5KYMoMne1pUrsL4OEpg=="], "expo-file-system": ["expo-file-system@18.0.10", "", { "dependencies": { "web-streams-polyfill": "^3.3.2" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-+GnxkI+J9tOzUQMx+uIOLBEBsO2meyoYHxd87m9oT9M//BpepYqI1AvYBH8YM4dgr9HaeaeLr7z5XFVqfL8tWg=="],
"expo-font": ["expo-font@13.0.4", "", { "dependencies": { "fontfaceobserver": "^2.1.0" }, "peerDependencies": { "expo": "*", "react": "*" } }, "sha512-eAP5hyBgC8gafFtprsz0HMaB795qZfgJWqTmU0NfbSin1wUuVySFMEPMOrTkTgmazU73v4Cb4x7p86jY1XXYUw=="], "expo-font": ["expo-font@13.0.3", "", { "dependencies": { "fontfaceobserver": "^2.1.0" }, "peerDependencies": { "expo": "*", "react": "*" } }, "sha512-9IdYz+A+b3KvuCYP7DUUXF4VMZjPU+IsvAnLSVJ2TfP6zUD2JjZFx3jeo/cxWRkYk/aLj5+53Te7elTAScNl4Q=="],
"expo-haptics": ["expo-haptics@14.0.1", "", { "peerDependencies": { "expo": "*" } }, "sha512-V81FZ7xRUfqM6uSI6FA1KnZ+QpEKnISqafob/xEfcx1ymwhm4V3snuLWWFjmAz+XaZQTqlYa8z3QbqEXz7G63w=="], "expo-haptics": ["expo-haptics@14.0.1", "", { "peerDependencies": { "expo": "*" } }, "sha512-V81FZ7xRUfqM6uSI6FA1KnZ+QpEKnISqafob/xEfcx1ymwhm4V3snuLWWFjmAz+XaZQTqlYa8z3QbqEXz7G63w=="],
"expo-image": ["expo-image@2.0.6", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-NHpIZmGnrPbyDadil6eK+sUgyFMQfapEVb7YaGgxSFWBUQ1rSpjqdIQrCD24IZTO9uSH8V+hMh2ROxrAjAixzQ=="], "expo-image": ["expo-image@2.0.5", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-FAq7uyaTAfLWER3lN+KVAtep7IfGPZN9ygnVKW4GvgnvR4hKhTtZ5WNxiJ18KKLVb4nUKuHOpQeJNnljy3dtmA=="],
"expo-json-utils": ["expo-json-utils@0.14.0", "", {}, "sha512-xjGfK9dL0B1wLnOqNkX0jM9p48Y0I5xEPzHude28LY67UmamUyAACkqhZGaPClyPNfdzczk7Ej6WaRMT3HfXvw=="], "expo-json-utils": ["expo-json-utils@0.14.0", "", {}, "sha512-xjGfK9dL0B1wLnOqNkX0jM9p48Y0I5xEPzHude28LY67UmamUyAACkqhZGaPClyPNfdzczk7Ej6WaRMT3HfXvw=="],
"expo-keep-awake": ["expo-keep-awake@14.0.3", "", { "peerDependencies": { "expo": "*", "react": "*" } }, "sha512-6Jh94G6NvTZfuLnm2vwIpKe3GdOiVBuISl7FI8GqN0/9UOg9E0WXXp5cDcfAG8bn80RfgLJS8P7EPUGTZyOvhg=="], "expo-keep-awake": ["expo-keep-awake@14.0.2", "", { "peerDependencies": { "expo": "*", "react": "*" } }, "sha512-71XAMnoWjKZrN8J7Q3+u0l9Ytp4OfhNAYz8BCWF1/9aFUw09J3I7Z5DuI3MUsVMa/KWi+XhG+eDUFP8cVA19Uw=="],
"expo-linear-gradient": ["expo-linear-gradient@14.0.2", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-nvac1sPUfFFJ4mY25UkvubpUV/olrBH+uQw5k+beqSvQaVQiUfFtYzfRr+6HhYBNb4AEsOtpsCRkpDww3M2iGQ=="], "expo-linear-gradient": ["expo-linear-gradient@14.0.2", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-nvac1sPUfFFJ4mY25UkvubpUV/olrBH+uQw5k+beqSvQaVQiUfFtYzfRr+6HhYBNb4AEsOtpsCRkpDww3M2iGQ=="],
@@ -1189,7 +1165,7 @@
"expo-manifests": ["expo-manifests@0.15.6", "", { "dependencies": { "@expo/config": "~10.0.9", "expo-json-utils": "~0.14.0" }, "peerDependencies": { "expo": "*" } }, "sha512-z+TFICrijMaqBvcJkVx8WzgmOsV6ZJGvaPNQKZr4DA6uqugFMtvAQVikDjIq7SEc3n7IgPk0GR4ZN3/KnnkeVA=="], "expo-manifests": ["expo-manifests@0.15.6", "", { "dependencies": { "@expo/config": "~10.0.9", "expo-json-utils": "~0.14.0" }, "peerDependencies": { "expo": "*" } }, "sha512-z+TFICrijMaqBvcJkVx8WzgmOsV6ZJGvaPNQKZr4DA6uqugFMtvAQVikDjIq7SEc3n7IgPk0GR4ZN3/KnnkeVA=="],
"expo-modules-autolinking": ["expo-modules-autolinking@2.0.8", "", { "dependencies": { "@expo/spawn-async": "^1.7.2", "chalk": "^4.1.0", "commander": "^7.2.0", "fast-glob": "^3.2.5", "find-up": "^5.0.0", "fs-extra": "^9.1.0", "require-from-string": "^2.0.2", "resolve-from": "^5.0.0" }, "bin": { "expo-modules-autolinking": "bin/expo-modules-autolinking.js" } }, "sha512-DezgnEYFQYic8hKGhkbztBA3QUmSftjaNDIKNAtS2iGJmzCcNIkatjN2slFDSWjSTNo8gOvPQyMKfyHWFvLpOQ=="], "expo-modules-autolinking": ["expo-modules-autolinking@2.0.7", "", { "dependencies": { "@expo/spawn-async": "^1.7.2", "chalk": "^4.1.0", "commander": "^7.2.0", "fast-glob": "^3.2.5", "find-up": "^5.0.0", "fs-extra": "^9.1.0", "require-from-string": "^2.0.2", "resolve-from": "^5.0.0" }, "bin": { "expo-modules-autolinking": "bin/expo-modules-autolinking.js" } }, "sha512-rkGc6a/90AC3q8wSy4V+iIpq6Fd0KXmQICKrvfmSWwrMgJmLfwP4QTrvLYPYOOMjFwNJcTaohcH8vzW/wYKrMg=="],
"expo-modules-core": ["expo-modules-core@2.2.2", "", { "dependencies": { "invariant": "^2.2.4" } }, "sha512-SgjK86UD89gKAscRK3bdpn6Ojfs/KU4GujtuFx1wm4JaBjmXH4aakWkItkPlAV2pjIiHJHWQbENL9xjbw/Qr/g=="], "expo-modules-core": ["expo-modules-core@2.2.2", "", { "dependencies": { "invariant": "^2.2.4" } }, "sha512-SgjK86UD89gKAscRK3bdpn6Ojfs/KU4GujtuFx1wm4JaBjmXH4aakWkItkPlAV2pjIiHJHWQbENL9xjbw/Qr/g=="],
@@ -1213,7 +1189,7 @@
"expo-task-manager": ["expo-task-manager@12.0.5", "", { "dependencies": { "unimodules-app-loader": "~5.0.1" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-tDHOBYORA6wuO32NWwz/Egrvn+N6aANHAa0DFs+01VK/IJZfU9D05ZN6M5XYIlZv5ll4GSX1wJZyTCY0HZGapw=="], "expo-task-manager": ["expo-task-manager@12.0.5", "", { "dependencies": { "unimodules-app-loader": "~5.0.1" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-tDHOBYORA6wuO32NWwz/Egrvn+N6aANHAa0DFs+01VK/IJZfU9D05ZN6M5XYIlZv5ll4GSX1wJZyTCY0HZGapw=="],
"expo-updates": ["expo-updates@0.26.19", "", { "dependencies": { "@expo/code-signing-certificates": "0.0.5", "@expo/config": "~10.0.10", "@expo/config-plugins": "~9.0.15", "@expo/spawn-async": "^1.7.2", "arg": "4.1.0", "chalk": "^4.1.2", "expo-eas-client": "~0.13.2", "expo-manifests": "~0.15.6", "expo-structured-headers": "~4.0.0", "expo-updates-interface": "~1.0.0", "fast-glob": "^3.3.2", "fbemitter": "^3.0.0", "ignore": "^5.3.1", "resolve-from": "^5.0.0" }, "peerDependencies": { "expo": "*", "react": "*" }, "bin": { "expo-updates": "bin/cli.js" } }, "sha512-h40UrG0n1nCb2na1ffz+mNQtsnr7/BxxK+EtXJSqCaD9PIGaTGe20tasmo1oVskv3s37zfv0x93+6uTjanieQg=="], "expo-updates": ["expo-updates@0.26.18", "", { "dependencies": { "@expo/code-signing-certificates": "0.0.5", "@expo/config": "~10.0.9", "@expo/config-plugins": "~9.0.15", "@expo/spawn-async": "^1.7.2", "arg": "4.1.0", "chalk": "^4.1.2", "expo-eas-client": "~0.13.2", "expo-manifests": "~0.15.5", "expo-structured-headers": "~4.0.0", "expo-updates-interface": "~1.0.0", "fast-glob": "^3.3.2", "fbemitter": "^3.0.0", "ignore": "^5.3.1", "resolve-from": "^5.0.0" }, "peerDependencies": { "expo": "*", "react": "*" }, "bin": { "expo-updates": "bin/cli.js" } }, "sha512-i9on8jMLrDxtr3Jwpmqj14oa4PWxSKYrHhJYK40xATV6qrauTija9R7BkN0hQjD4LpElt5UJW2/YUP30UsTFqA=="],
"expo-updates-interface": ["expo-updates-interface@1.0.0", "", { "peerDependencies": { "expo": "*" } }, "sha512-93oWtvULJOj+Pp+N/lpTcFfuREX1wNeHtp7Lwn8EbzYYmdn37MvZU3TPW2tYYCZuhzmKEXnUblYcruYoDu7IrQ=="], "expo-updates-interface": ["expo-updates-interface@1.0.0", "", { "peerDependencies": { "expo": "*" } }, "sha512-93oWtvULJOj+Pp+N/lpTcFfuREX1wNeHtp7Lwn8EbzYYmdn37MvZU3TPW2tYYCZuhzmKEXnUblYcruYoDu7IrQ=="],
@@ -1233,9 +1209,7 @@
"fast-loops": ["fast-loops@1.1.4", "", {}, "sha512-8dbd3XWoKCTms18ize6JmQF1SFnnfj5s0B7rRry22EofgMu7B6LKHVh+XfFqFGsqnbH54xgeO83PzpKI+ODhlg=="], "fast-loops": ["fast-loops@1.1.4", "", {}, "sha512-8dbd3XWoKCTms18ize6JmQF1SFnnfj5s0B7rRry22EofgMu7B6LKHVh+XfFqFGsqnbH54xgeO83PzpKI+ODhlg=="],
"fast-uri": ["fast-uri@3.0.6", "", {}, "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw=="], "fast-xml-parser": ["fast-xml-parser@4.5.1", "", { "dependencies": { "strnum": "^1.0.5" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-y655CeyUQ+jj7KBbYMc4FG01V8ZQqjN+gDYGJ50RtfsUB8iG9AmwmwoAgeKLJdmueKKMrH1RJ7yXHTSoczdv5w=="],
"fast-xml-parser": ["fast-xml-parser@4.5.3", "", { "dependencies": { "strnum": "^1.1.1" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-RKihhV+SHsIUGXObeVy9AXiBbFwkVk7Syp8XgwN5U3JV416+Gwp/GO9i0JYKmikykgz/UHRrrV4ROuZEo/T0ig=="],
"fastq": ["fastq@1.19.0", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-7SFSRCNjBQIZH/xZR3iy5iQYR8aGBE0h3VG6/cwlbrpdciNYBMotQav8c1XI3HjHH+NikUpP53nPdlZSdWmFzA=="], "fastq": ["fastq@1.19.0", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-7SFSRCNjBQIZH/xZR3iy5iQYR8aGBE0h3VG6/cwlbrpdciNYBMotQav8c1XI3HjHH+NikUpP53nPdlZSdWmFzA=="],
@@ -1267,7 +1241,7 @@
"flow-enums-runtime": ["flow-enums-runtime@0.0.6", "", {}, "sha512-3PYnM29RFXwvAN6Pc/scUfkI7RwhQ/xqyLUyPNlXUp9S40zI8nup9tUSrTLSVnWGBN38FNiGWbwZOB6uR4OGdw=="], "flow-enums-runtime": ["flow-enums-runtime@0.0.6", "", {}, "sha512-3PYnM29RFXwvAN6Pc/scUfkI7RwhQ/xqyLUyPNlXUp9S40zI8nup9tUSrTLSVnWGBN38FNiGWbwZOB6uR4OGdw=="],
"flow-parser": ["flow-parser@0.261.2", "", {}, "sha512-RtunoakA3YjtpAxPSOBVW6lmP5NYmETwkpAfNkdr8Ovf86ENkbD3mtPWnswFTIUtRvjwv0i8ZSkHK+AzsUg1JA=="], "flow-parser": ["flow-parser@0.261.1", "", {}, "sha512-2l5bBKeVtT+d+1CYSsTLJ+iP2FuoR7zjbDQI/v6dDRiBpx3Lb20Z/tLS37ReX/lcodyGSHC2eA/Nk63hB+mkYg=="],
"follow-redirects": ["follow-redirects@1.15.9", "", {}, "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ=="], "follow-redirects": ["follow-redirects@1.15.9", "", {}, "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ=="],
@@ -1297,8 +1271,6 @@
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
"get-east-asian-width": ["get-east-asian-width@1.3.0", "", {}, "sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ=="],
"get-intrinsic": ["get-intrinsic@1.2.7", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "function-bind": "^1.1.2", "get-proto": "^1.0.0", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-VW6Pxhsrk0KAOqs3WEd0klDiF/+V7gQOpAvY1jVU/LHmaD/kQO4523aiJuikX/QAKYiW6x8Jh+RJej1almdtCA=="], "get-intrinsic": ["get-intrinsic@1.2.7", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "function-bind": "^1.1.2", "get-proto": "^1.0.0", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-VW6Pxhsrk0KAOqs3WEd0klDiF/+V7gQOpAvY1jVU/LHmaD/kQO4523aiJuikX/QAKYiW6x8Jh+RJej1almdtCA=="],
"get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="], "get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
@@ -1353,8 +1325,6 @@
"human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="], "human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="],
"husky": ["husky@9.1.7", "", { "bin": { "husky": "bin.js" } }, "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA=="],
"hyphenate-style-name": ["hyphenate-style-name@1.1.0", "", {}, "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw=="], "hyphenate-style-name": ["hyphenate-style-name@1.1.0", "", {}, "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw=="],
"i18next": ["i18next@24.2.2", "", { "dependencies": { "@babel/runtime": "^7.23.2" }, "peerDependencies": { "typescript": "^5" }, "optionalPeers": ["typescript"] }, "sha512-NE6i86lBCKRYZa5TaUDkU5S4HFgLIEJRLr3Whf2psgaxBleQ2LC1YW1Vc+SCgkAW7VEzndT6al6+CzegSUHcTQ=="], "i18next": ["i18next@24.2.2", "", { "dependencies": { "@babel/runtime": "^7.23.2" }, "peerDependencies": { "typescript": "^5" }, "optionalPeers": ["typescript"] }, "sha512-NE6i86lBCKRYZa5TaUDkU5S4HFgLIEJRLr3Whf2psgaxBleQ2LC1YW1Vc+SCgkAW7VEzndT6al6+CzegSUHcTQ=="],
@@ -1425,6 +1395,8 @@
"is-path-inside": ["is-path-inside@3.0.3", "", {}, "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ=="], "is-path-inside": ["is-path-inside@3.0.3", "", {}, "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ=="],
"is-plain-obj": ["is-plain-obj@2.1.0", "", {}, "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA=="],
"is-plain-object": ["is-plain-object@2.0.4", "", { "dependencies": { "isobject": "^3.0.1" } }, "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og=="], "is-plain-object": ["is-plain-object@2.0.4", "", { "dependencies": { "isobject": "^3.0.1" } }, "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og=="],
"is-regex": ["is-regex@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="], "is-regex": ["is-regex@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="],
@@ -1479,7 +1451,7 @@
"join-component": ["join-component@1.1.0", "", {}, "sha512-bF7vcQxbODoGK1imE2P9GS9aw4zD0Sd+Hni68IMZLj7zRnquH7dXUmMw9hDI5S/Jzt7q+IyTXN0rSg2GI0IKhQ=="], "join-component": ["join-component@1.1.0", "", {}, "sha512-bF7vcQxbODoGK1imE2P9GS9aw4zD0Sd+Hni68IMZLj7zRnquH7dXUmMw9hDI5S/Jzt7q+IyTXN0rSg2GI0IKhQ=="],
"jotai": ["jotai@2.12.1", "", { "peerDependencies": { "@types/react": ">=17.0.0", "react": ">=17.0.0" }, "optionalPeers": ["@types/react", "react"] }, "sha512-VUW0nMPYIru5g89tdxwr9ftiVdc/nGV9jvHISN8Ucx+m1vI9dBeHemfqYzEuw5XSkmYjD/MEyApN9k6yrATsZQ=="], "jotai": ["jotai@2.12.0", "", { "peerDependencies": { "@types/react": ">=17.0.0", "react": ">=17.0.0" }, "optionalPeers": ["@types/react", "react"] }, "sha512-j5B4NmUw8gbuN7AG4NufWw00rfpm6hexL2CVhKD7juoP2YyD9FEUV5ar921JMvadyrxQhU1NpuKUL3QfsAlVpA=="],
"jpeg-js": ["jpeg-js@0.4.4", "", {}, "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg=="], "jpeg-js": ["jpeg-js@0.4.4", "", {}, "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg=="],
@@ -1541,14 +1513,10 @@
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.27.0", "", { "os": "win32", "cpu": "x64" }, "sha512-/OJLj94Zm/waZShL8nB5jsNj3CfNATLCTyFxZyouilfTmSoLDX7VlVAmhPHoZWVFp4vdmoiEbPEYC8HID3m6yw=="], "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.27.0", "", { "os": "win32", "cpu": "x64" }, "sha512-/OJLj94Zm/waZShL8nB5jsNj3CfNATLCTyFxZyouilfTmSoLDX7VlVAmhPHoZWVFp4vdmoiEbPEYC8HID3m6yw=="],
"lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="], "lilconfig": ["lilconfig@2.1.0", "", {}, "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ=="],
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
"lint-staged": ["lint-staged@15.5.0", "", { "dependencies": { "chalk": "^5.4.1", "commander": "^13.1.0", "debug": "^4.4.0", "execa": "^8.0.1", "lilconfig": "^3.1.3", "listr2": "^8.2.5", "micromatch": "^4.0.8", "pidtree": "^0.6.0", "string-argv": "^0.3.2", "yaml": "^2.7.0" }, "bin": { "lint-staged": "bin/lint-staged.js" } }, "sha512-WyCzSbfYGhK7cU+UuDDkzUiytbfbi0ZdPy2orwtM75P3WTtQBzmG40cCxIa8Ii2+XjfxzLH6Be46tUfWS85Xfg=="],
"listr2": ["listr2@8.2.5", "", { "dependencies": { "cli-truncate": "^4.0.0", "colorette": "^2.0.20", "eventemitter3": "^5.0.1", "log-update": "^6.1.0", "rfdc": "^1.4.1", "wrap-ansi": "^9.0.0" } }, "sha512-iyAZCeyD+c1gPyE9qpFu8af0Y+MRtmKOncdGoA2S5EY8iFq99dmmvkNnHiWo+pj0s7yH7l3KPIgee77tKpXPWQ=="],
"load-bmfont": ["load-bmfont@1.4.2", "", { "dependencies": { "buffer-equal": "0.0.1", "mime": "^1.3.4", "parse-bmfont-ascii": "^1.0.3", "parse-bmfont-binary": "^1.0.5", "parse-bmfont-xml": "^1.1.4", "phin": "^3.7.1", "xhr": "^2.0.1", "xtend": "^4.0.0" } }, "sha512-qElWkmjW9Oq1F9EI5Gt7aD9zcdHb9spJCW1L/dmPf7KzCCEJxq8nhHz5eCgI9aMf7vrG/wyaCqdsI+Iy9ZTlog=="], "load-bmfont": ["load-bmfont@1.4.2", "", { "dependencies": { "buffer-equal": "0.0.1", "mime": "^1.3.4", "parse-bmfont-ascii": "^1.0.3", "parse-bmfont-binary": "^1.0.5", "parse-bmfont-xml": "^1.1.4", "phin": "^3.7.1", "xhr": "^2.0.1", "xtend": "^4.0.0" } }, "sha512-qElWkmjW9Oq1F9EI5Gt7aD9zcdHb9spJCW1L/dmPf7KzCCEJxq8nhHz5eCgI9aMf7vrG/wyaCqdsI+Iy9ZTlog=="],
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
@@ -1561,8 +1529,6 @@
"log-symbols": ["log-symbols@4.1.0", "", { "dependencies": { "chalk": "^4.1.0", "is-unicode-supported": "^0.1.0" } }, "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg=="], "log-symbols": ["log-symbols@4.1.0", "", { "dependencies": { "chalk": "^4.1.0", "is-unicode-supported": "^0.1.0" } }, "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg=="],
"log-update": ["log-update@6.1.0", "", { "dependencies": { "ansi-escapes": "^7.0.0", "cli-cursor": "^5.0.0", "slice-ansi": "^7.1.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w=="],
"logkitty": ["logkitty@0.7.1", "", { "dependencies": { "ansi-fragments": "^0.2.1", "dayjs": "^1.8.15", "yargs": "^15.1.0" }, "bin": { "logkitty": "bin/logkitty.js" } }, "sha512-/3ER20CTTbahrCrpYfPn7Xavv9diBROZpoXGVZDWMw4b/X4uuUwAC0ki85tgsdMRONURyIJbcOvS94QsUBYPbQ=="], "logkitty": ["logkitty@0.7.1", "", { "dependencies": { "ansi-fragments": "^0.2.1", "dayjs": "^1.8.15", "yargs": "^15.1.0" }, "bin": { "logkitty": "bin/logkitty.js" } }, "sha512-/3ER20CTTbahrCrpYfPn7Xavv9diBROZpoXGVZDWMw4b/X4uuUwAC0ki85tgsdMRONURyIJbcOvS94QsUBYPbQ=="],
"loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
@@ -1585,6 +1551,8 @@
"memoize-one": ["memoize-one@5.2.1", "", {}, "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q=="], "memoize-one": ["memoize-one@5.2.1", "", {}, "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q=="],
"merge-options": ["merge-options@3.0.4", "", { "dependencies": { "is-plain-obj": "^2.1.0" } }, "sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ=="],
"merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="], "merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="],
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
@@ -1627,8 +1595,6 @@
"mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], "mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="],
"mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="],
"min-document": ["min-document@2.19.0", "", { "dependencies": { "dom-walk": "^0.1.0" } }, "sha512-9Wy1B3m3f66bPPmU5hdA4DR4PB2OfDU/+GS3yAB7IQozE3tqXaVv2zOjgla7MEGSRv95+ILmOuvhLkOK6wJtCQ=="], "min-document": ["min-document@2.19.0", "", { "dependencies": { "dom-walk": "^0.1.0" } }, "sha512-9Wy1B3m3f66bPPmU5hdA4DR4PB2OfDU/+GS3yAB7IQozE3tqXaVv2zOjgla7MEGSRv95+ILmOuvhLkOK6wJtCQ=="],
"minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], "minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
@@ -1773,9 +1739,7 @@
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], "picomatch": ["picomatch@3.0.1", "", {}, "sha512-I3EurrIQMlRc9IaAZnqRR044Phh2DXY+55o7uJ0V+hYZAcQYSuFWsc9q5PvyDHUSCe1Qxn/iBz+78s86zWnGag=="],
"pidtree": ["pidtree@0.6.0", "", { "bin": { "pidtree": "bin/pidtree.js" } }, "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g=="],
"pify": ["pify@2.3.0", "", {}, "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog=="], "pify": ["pify@2.3.0", "", {}, "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog=="],
@@ -1791,7 +1755,7 @@
"possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="],
"postcss": ["postcss@8.5.3", "", { "dependencies": { "nanoid": "^3.3.8", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A=="], "postcss": ["postcss@8.5.2", "", { "dependencies": { "nanoid": "^3.3.8", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-MjOadfU3Ys9KYoX0AdkBlFEF1Vx37uCCeN4ZHnmwm9FfpbsGWMZeBLMmmpY+6Ocqod7mkdZ0DT31OlbsFrLlkA=="],
"postcss-calc": ["postcss-calc@8.2.4", "", { "dependencies": { "postcss-selector-parser": "^6.0.9", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.2.2" } }, "sha512-SmWMSJmB8MRnnULldx0lQIyhSNvuDl9HfrZkaqqE/WHAhToYsAvDq+yAsA/kIyINDszOp3Rh0GFoNuH5Ypsm3Q=="], "postcss-calc": ["postcss-calc@8.2.4", "", { "dependencies": { "postcss-selector-parser": "^6.0.9", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.2.2" } }, "sha512-SmWMSJmB8MRnnULldx0lQIyhSNvuDl9HfrZkaqqE/WHAhToYsAvDq+yAsA/kIyINDszOp3Rh0GFoNuH5Ypsm3Q=="],
@@ -1861,7 +1825,7 @@
"react-helmet-async": ["react-helmet-async@1.3.0", "", { "dependencies": { "@babel/runtime": "^7.12.5", "invariant": "^2.2.4", "prop-types": "^15.7.2", "react-fast-compare": "^3.2.0", "shallowequal": "^1.1.0" }, "peerDependencies": { "react": "^16.6.0 || ^17.0.0 || ^18.0.0", "react-dom": "^16.6.0 || ^17.0.0 || ^18.0.0" } }, "sha512-9jZ57/dAn9t3q6hneQS0wukqC2ENOBgMNVEhb/ZG9ZSxUetzVIw4iAmEU38IaVg3QGYauQPhSeUTuIUtFglWpg=="], "react-helmet-async": ["react-helmet-async@1.3.0", "", { "dependencies": { "@babel/runtime": "^7.12.5", "invariant": "^2.2.4", "prop-types": "^15.7.2", "react-fast-compare": "^3.2.0", "shallowequal": "^1.1.0" }, "peerDependencies": { "react": "^16.6.0 || ^17.0.0 || ^18.0.0", "react-dom": "^16.6.0 || ^17.0.0 || ^18.0.0" } }, "sha512-9jZ57/dAn9t3q6hneQS0wukqC2ENOBgMNVEhb/ZG9ZSxUetzVIw4iAmEU38IaVg3QGYauQPhSeUTuIUtFglWpg=="],
"react-i18next": ["react-i18next@15.4.1", "", { "dependencies": { "@babel/runtime": "^7.25.0", "html-parse-stringify": "^3.0.1" }, "peerDependencies": { "i18next": ">= 23.2.3", "react": ">= 16.8.0" } }, "sha512-ahGab+IaSgZmNPYXdV1n+OYky95TGpFwnKRflX/16dY04DsYYKHtVLjeny7sBSCREEcoMbAgSkFiGLF5g5Oofw=="], "react-i18next": ["react-i18next@15.4.0", "", { "dependencies": { "@babel/runtime": "^7.25.0", "html-parse-stringify": "^3.0.1" }, "peerDependencies": { "i18next": ">= 23.2.3", "react": ">= 16.8.0" } }, "sha512-Py6UkX3zV08RTvL6ZANRoBh9sL/ne6rQq79XlkHEdd82cZr2H9usbWpUNVadJntIZP2pu3M2rL1CN+5rQYfYFw=="],
"react-is": ["react-is@19.0.0", "", {}, "sha512-H91OHcwjZsbq3ClIDHMzBShc1rotbfACdWENsmEf0IFvZ3FgGPtdHMcsv45bQ1hAbgdfiA8SnxTKfDS+x/8m2g=="], "react-is": ["react-is@19.0.0", "", {}, "sha512-H91OHcwjZsbq3ClIDHMzBShc1rotbfACdWENsmEf0IFvZ3FgGPtdHMcsv45bQ1hAbgdfiA8SnxTKfDS+x/8m2g=="],
@@ -1873,7 +1837,7 @@
"react-native-circular-progress": ["react-native-circular-progress@1.4.1", "", { "dependencies": { "prop-types": "^15.8.1" }, "peerDependencies": { "react": ">=16.0.0", "react-native": ">=0.50.0", "react-native-svg": ">=7.0.0" } }, "sha512-HEzvI0WPuWvsCgWE3Ff2HBTMgAEQB2GvTFw0KHyD/t1STAlDDRiolu0mEGhVvihKR3jJu3v3V4qzvSklY/7XzQ=="], "react-native-circular-progress": ["react-native-circular-progress@1.4.1", "", { "dependencies": { "prop-types": "^15.8.1" }, "peerDependencies": { "react": ">=16.0.0", "react-native": ">=0.50.0", "react-native-svg": ">=7.0.0" } }, "sha512-HEzvI0WPuWvsCgWE3Ff2HBTMgAEQB2GvTFw0KHyD/t1STAlDDRiolu0mEGhVvihKR3jJu3v3V4qzvSklY/7XzQ=="],
"react-native-compressor": ["react-native-compressor@1.10.4", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-58gbmJ+8IvsKP8JKK1E8XW5trfQY3dNuH7S0hYw0tSRQc6l0GZ3k8TYtoUbySOc1xcQSrUo51o0Chwe8x7mUTg=="], "react-native-compressor": ["react-native-compressor@1.10.3", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-i51DfTwfLcKorWbTXtnPOcQC4SQDuC+DqKkSl9wF9qAUmNS9PtipYZCXOvWShYFnX0mmcWw5vwEp2b2V73PaDQ=="],
"react-native-country-flag": ["react-native-country-flag@2.0.2", "", {}, "sha512-5LMWxS79ZQ0Q9ntYgDYzWp794+HcQGXQmzzZNBR1AT7z5HcJHtX7rlk8RHi7RVzfp5gW6plWSZ4dKjRpu/OafQ=="], "react-native-country-flag": ["react-native-country-flag@2.0.2", "", {}, "sha512-5LMWxS79ZQ0Q9ntYgDYzWp794+HcQGXQmzzZNBR1AT7z5HcJHtX7rlk8RHi7RVzfp5gW6plWSZ4dKjRpu/OafQ=="],
@@ -1993,8 +1957,6 @@
"reusify": ["reusify@1.0.4", "", {}, "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw=="], "reusify": ["reusify@1.0.4", "", {}, "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw=="],
"rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="],
"rimraf": ["rimraf@2.7.1", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "./bin.js" } }, "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w=="], "rimraf": ["rimraf@2.7.1", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "./bin.js" } }, "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w=="],
"rtl-detect": ["rtl-detect@1.1.2", "", {}, "sha512-PGMBq03+TTG/p/cRB7HCLKJ1MgDIi07+QU1faSjiYRfmY5UsAttV9Hs08jDAHVwcOwmVLcSJkpwyfXszVjWfIQ=="], "rtl-detect": ["rtl-detect@1.1.2", "", {}, "sha512-PGMBq03+TTG/p/cRB7HCLKJ1MgDIi07+QU1faSjiYRfmY5UsAttV9Hs08jDAHVwcOwmVLcSJkpwyfXszVjWfIQ=="],
@@ -2015,7 +1977,7 @@
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"send": ["send@0.19.0", "", { "dependencies": { "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "encodeurl": "~1.0.2", "escape-html": "~1.0.3", "etag": "~1.8.1", "fresh": "0.5.2", "http-errors": "2.0.0", "mime": "1.6.0", "ms": "2.1.3", "on-finished": "2.4.1", "range-parser": "~1.2.1", "statuses": "2.0.1" } }, "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw=="], "send": ["send@0.19.1", "", { "dependencies": { "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "fresh": "0.5.2", "http-errors": "2.0.0", "mime": "1.6.0", "ms": "2.1.3", "on-finished": "2.4.1", "range-parser": "~1.2.1", "statuses": "2.0.1" } }, "sha512-p4rRk4f23ynFEfcD9LA0xRYngj+IyGiEYyqqOak8kaN0TvNmuxC2dcVeBn62GpCeR2CpWqyHCNScTP91QbAVFg=="],
"serialize-error": ["serialize-error@2.1.0", "", {}, "sha512-ghgmKt5o4Tly5yEG/UJp8qTd0AN7Xalw4XBtDEKP655B699qMEtra1WlXeE6WIvdEG481JvRxULKsInq/iNysw=="], "serialize-error": ["serialize-error@2.1.0", "", {}, "sha512-ghgmKt5o4Tly5yEG/UJp8qTd0AN7Xalw4XBtDEKP655B699qMEtra1WlXeE6WIvdEG481JvRxULKsInq/iNysw=="],
@@ -2063,7 +2025,7 @@
"slash": ["slash@2.0.0", "", {}, "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A=="], "slash": ["slash@2.0.0", "", {}, "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A=="],
"slice-ansi": ["slice-ansi@5.0.0", "", { "dependencies": { "ansi-styles": "^6.0.0", "is-fullwidth-code-point": "^4.0.0" } }, "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ=="], "slice-ansi": ["slice-ansi@2.1.0", "", { "dependencies": { "ansi-styles": "^3.2.0", "astral-regex": "^1.0.0", "is-fullwidth-code-point": "^2.0.0" } }, "sha512-Qu+VC3EwYLldKa1fCxuuvULvSJOKEgk9pi8dZeCVK7TqBfUNTH4sFkk4joj8afVSfAYgJoSOetjx9QWOJ5mYoQ=="],
"slugify": ["slugify@1.6.6", "", {}, "sha512-h+z7HKHYXj6wJU+AnS/+IH8Uh9fdcX1Lrhg1/VMdf9PwoBQXFcXiAdsy2tSK0P6gKwJLXp02r90ahUCqHk9rrw=="], "slugify": ["slugify@1.6.6", "", {}, "sha512-h+z7HKHYXj6wJU+AnS/+IH8Uh9fdcX1Lrhg1/VMdf9PwoBQXFcXiAdsy2tSK0P6gKwJLXp02r90ahUCqHk9rrw=="],
@@ -2087,7 +2049,7 @@
"stackframe": ["stackframe@1.3.4", "", {}, "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw=="], "stackframe": ["stackframe@1.3.4", "", {}, "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw=="],
"stacktrace-parser": ["stacktrace-parser@0.1.11", "", { "dependencies": { "type-fest": "^0.7.1" } }, "sha512-WjlahMgHmCJpqzU8bIBy4qtsZdU9lRlcZE3Lvyej6t4tuOuv1vk57OW3MBrj6hXBFx/nNoC9MPMTcr5YA7NQbg=="], "stacktrace-parser": ["stacktrace-parser@0.1.10", "", { "dependencies": { "type-fest": "^0.7.1" } }, "sha512-KJP1OCML99+8fhOHxwwzyWrlUuVX5GQ0ZpJTd1DFXhdkrvg1szxfHhawXUZ3g9TkXORQd4/WG68jMlQZ2p8wlg=="],
"statuses": ["statuses@2.0.1", "", {}, "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ=="], "statuses": ["statuses@2.0.1", "", {}, "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ=="],
@@ -2097,8 +2059,6 @@
"strict-uri-encode": ["strict-uri-encode@2.0.0", "", {}, "sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ=="], "strict-uri-encode": ["strict-uri-encode@2.0.0", "", {}, "sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ=="],
"string-argv": ["string-argv@0.3.2", "", {}, "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q=="],
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], "string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
@@ -2115,7 +2075,7 @@
"strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="], "strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="],
"strnum": ["strnum@1.1.1", "", {}, "sha512-O7aCHfYCamLCctjAiaucmE+fHf2DYHkus2OKCn4Wv03sykfFtgeECn505X6K4mPl8CRNd/qurC9guq+ynoN4pw=="], "strnum": ["strnum@1.0.5", "", {}, "sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA=="],
"strtok3": ["strtok3@6.3.0", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "peek-readable": "^4.1.0" } }, "sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw=="], "strtok3": ["strtok3@6.3.0", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "peek-readable": "^4.1.0" } }, "sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw=="],
@@ -2239,7 +2199,7 @@
"utils-merge": ["utils-merge@1.0.1", "", {}, "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="], "utils-merge": ["utils-merge@1.0.1", "", {}, "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="],
"uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="], "uuid": ["uuid@11.0.5", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-508e6IcKLrhxKdBbcA2b4KQZlLVp2+J5UwQ6F7Drckkc5N9ZJwFa4TgWtsww9UG8fGHbm6gbV19TdM5pQ4GaIA=="],
"validate-npm-package-name": ["validate-npm-package-name@5.0.1", "", {}, "sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ=="], "validate-npm-package-name": ["validate-npm-package-name@5.0.1", "", {}, "sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ=="],
@@ -2275,7 +2235,7 @@
"wonka": ["wonka@6.3.4", "", {}, "sha512-CjpbqNtBGNAeyNS/9W6q3kSkKE52+FjIj7AkFlLr11s/VWGUu6a2CdYSdGxocIhIVjaW/zchesBQUKPVU69Cqg=="], "wonka": ["wonka@6.3.4", "", {}, "sha512-CjpbqNtBGNAeyNS/9W6q3kSkKE52+FjIj7AkFlLr11s/VWGUu6a2CdYSdGxocIhIVjaW/zchesBQUKPVU69Cqg=="],
"wrap-ansi": ["wrap-ansi@9.0.0", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q=="], "wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
"wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], "wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
@@ -2339,13 +2299,9 @@
"@expo/cli/ora": ["ora@3.4.0", "", { "dependencies": { "chalk": "^2.4.2", "cli-cursor": "^2.1.0", "cli-spinners": "^2.0.0", "log-symbols": "^2.2.0", "strip-ansi": "^5.2.0", "wcwidth": "^1.0.1" } }, "sha512-eNwHudNbO1folBP3JsZ19v9azXWtQZjICdr3Q0TDPIaeBQ3mXLrh54wM+er0+hSp+dWKf+Z8KM58CYzEyIYxYg=="], "@expo/cli/ora": ["ora@3.4.0", "", { "dependencies": { "chalk": "^2.4.2", "cli-cursor": "^2.1.0", "cli-spinners": "^2.0.0", "log-symbols": "^2.2.0", "strip-ansi": "^5.2.0", "wcwidth": "^1.0.1" } }, "sha512-eNwHudNbO1folBP3JsZ19v9azXWtQZjICdr3Q0TDPIaeBQ3mXLrh54wM+er0+hSp+dWKf+Z8KM58CYzEyIYxYg=="],
"@expo/cli/picomatch": ["picomatch@3.0.1", "", {}, "sha512-I3EurrIQMlRc9IaAZnqRR044Phh2DXY+55o7uJ0V+hYZAcQYSuFWsc9q5PvyDHUSCe1Qxn/iBz+78s86zWnGag=="],
"@expo/cli/semver": ["semver@7.7.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA=="], "@expo/cli/semver": ["semver@7.7.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA=="],
"@expo/cli/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], "@expo/cli/ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="],
"@expo/cli/ws": ["ws@8.18.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w=="],
"@expo/config/@babel/code-frame": ["@babel/code-frame@7.10.4", "", { "dependencies": { "@babel/highlight": "^7.10.4" } }, "sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg=="], "@expo/config/@babel/code-frame": ["@babel/code-frame@7.10.4", "", { "dependencies": { "@babel/highlight": "^7.10.4" } }, "sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg=="],
@@ -2461,9 +2417,9 @@
"accepts/negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="], "accepts/negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="],
"ansi-fragments/colorette": ["colorette@1.4.0", "", {}, "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g=="], "ansi-escapes/type-fest": ["type-fest@0.21.3", "", {}, "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w=="],
"ansi-fragments/slice-ansi": ["slice-ansi@2.1.0", "", { "dependencies": { "ansi-styles": "^3.2.0", "astral-regex": "^1.0.0", "is-fullwidth-code-point": "^2.0.0" } }, "sha512-Qu+VC3EwYLldKa1fCxuuvULvSJOKEgk9pi8dZeCVK7TqBfUNTH4sFkk4joj8afVSfAYgJoSOetjx9QWOJ5mYoQ=="], "anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"babel-jest/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="], "babel-jest/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="],
@@ -2479,12 +2435,8 @@
"chromium-edge-launcher/rimraf": ["rimraf@3.0.2", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "bin.js" } }, "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA=="], "chromium-edge-launcher/rimraf": ["rimraf@3.0.2", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "bin.js" } }, "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA=="],
"cli-truncate/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
"cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], "cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
"compressible/mime-db": ["mime-db@1.53.0", "", {}, "sha512-oHlN/w+3MQ3rba9rqFr6V/ypF10LSkdwUysQL7GkXoTgIWeV+tcXGA852TBxH+gsh8UWoyhR1hKcoMJTuWflpg=="], "compressible/mime-db": ["mime-db@1.53.0", "", {}, "sha512-oHlN/w+3MQ3rba9rqFr6V/ypF10LSkdwUysQL7GkXoTgIWeV+tcXGA852TBxH+gsh8UWoyhR1hKcoMJTuWflpg=="],
"compression/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], "compression/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
@@ -2503,8 +2455,6 @@
"expo-build-properties/semver": ["semver@7.7.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA=="], "expo-build-properties/semver": ["semver@7.7.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA=="],
"expo-dev-launcher/ajv": ["ajv@8.11.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2", "uri-js": "^4.2.2" } }, "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg=="],
"expo-modules-autolinking/commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="], "expo-modules-autolinking/commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="],
"expo-modules-autolinking/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="], "expo-modules-autolinking/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="],
@@ -2539,6 +2489,8 @@
"jest-message-util/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="], "jest-message-util/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="],
"jest-util/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"jest-worker/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], "jest-worker/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="],
"jscodeshift/tmp": ["tmp@0.2.3", "", {}, "sha512-nZD7m9iCPC5g0pYmcaxogYKggSfLsdxl8of3Q/oIbqCqLLIO9IAF0GWjX1z9NZRHPiXv8Wex4yDCaZsgEw0Y8w=="], "jscodeshift/tmp": ["tmp@0.2.3", "", {}, "sha512-nZD7m9iCPC5g0pYmcaxogYKggSfLsdxl8of3Q/oIbqCqLLIO9IAF0GWjX1z9NZRHPiXv8Wex4yDCaZsgEw0Y8w=="],
@@ -2547,22 +2499,10 @@
"lighthouse-logger/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], "lighthouse-logger/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
"lint-staged/chalk": ["chalk@5.4.1", "", {}, "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w=="],
"lint-staged/commander": ["commander@13.1.0", "", {}, "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw=="],
"lint-staged/execa": ["execa@8.0.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^8.0.1", "human-signals": "^5.0.0", "is-stream": "^3.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^5.1.0", "onetime": "^6.0.0", "signal-exit": "^4.1.0", "strip-final-newline": "^3.0.0" } }, "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg=="],
"load-bmfont/mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="], "load-bmfont/mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="],
"load-bmfont/phin": ["phin@3.7.1", "", { "dependencies": { "centra": "^2.7.0" } }, "sha512-GEazpTWwTZaEQ9RhL7Nyz0WwqilbqgLahDM3D0hxWwmVDI52nXEybHqiN6/elwpkJBhcuj+WbBu+QfT0uhPGfQ=="], "load-bmfont/phin": ["phin@3.7.1", "", { "dependencies": { "centra": "^2.7.0" } }, "sha512-GEazpTWwTZaEQ9RhL7Nyz0WwqilbqgLahDM3D0hxWwmVDI52nXEybHqiN6/elwpkJBhcuj+WbBu+QfT0uhPGfQ=="],
"log-update/cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="],
"log-update/slice-ansi": ["slice-ansi@7.1.0", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-bSiSngZ/jWeX93BqeIAbImyTbEihizcwNjFoRUIY/T1wWQsfsm2Vw1agPKylXvQTU7iASGdHhyqRlqQzfz+Htg=="],
"log-update/strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="],
"logkitty/yargs": ["yargs@15.4.1", "", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="], "logkitty/yargs": ["yargs@15.4.1", "", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="],
"make-dir/pify": ["pify@4.0.1", "", {}, "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g=="], "make-dir/pify": ["pify@4.0.1", "", {}, "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g=="],
@@ -2579,6 +2519,8 @@
"metro-file-map/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], "metro-file-map/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
"micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"minipass-flush/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], "minipass-flush/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
"minipass-pipeline/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], "minipass-pipeline/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
@@ -2599,8 +2541,6 @@
"parse-bmfont-xml/xml2js": ["xml2js@0.5.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA=="], "parse-bmfont-xml/xml2js": ["xml2js@0.5.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA=="],
"password-prompt/ansi-escapes": ["ansi-escapes@4.3.2", "", { "dependencies": { "type-fest": "^0.21.3" } }, "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ=="],
"patch-package/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="], "patch-package/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="],
"patch-package/semver": ["semver@7.7.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA=="], "patch-package/semver": ["semver@7.7.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA=="],
@@ -2615,6 +2555,8 @@
"postcss-css-variables/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="], "postcss-css-variables/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="],
"postcss-load-config/lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="],
"pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], "pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
"pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], "pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
@@ -2651,6 +2593,8 @@
"readable-web-to-node-stream/readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="], "readable-web-to-node-stream/readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="],
"readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], "recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
"regjsparser/jsesc": ["jsesc@3.0.2", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g=="], "regjsparser/jsesc": ["jsesc@3.0.2", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g=="],
@@ -2661,17 +2605,17 @@
"send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], "send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
"send/encodeurl": ["encodeurl@1.0.2", "", {}, "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w=="],
"send/mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="], "send/mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="],
"serve-static/send": ["send@0.19.0", "", { "dependencies": { "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "encodeurl": "~1.0.2", "escape-html": "~1.0.3", "etag": "~1.8.1", "fresh": "0.5.2", "http-errors": "2.0.0", "mime": "1.6.0", "ms": "2.1.3", "on-finished": "2.4.1", "range-parser": "~1.2.1", "statuses": "2.0.1" } }, "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw=="],
"simple-plist/bplist-creator": ["bplist-creator@0.1.0", "", { "dependencies": { "stream-buffers": "2.2.x" } }, "sha512-sXaHZicyEEmY86WyueLTQesbeoH/mquvarJaQNbjuOQO+7gbFcDEWqKmcWA4cOTLzFlfgvkiVxolk1k5bBIpmg=="], "simple-plist/bplist-creator": ["bplist-creator@0.1.0", "", { "dependencies": { "stream-buffers": "2.2.x" } }, "sha512-sXaHZicyEEmY86WyueLTQesbeoH/mquvarJaQNbjuOQO+7gbFcDEWqKmcWA4cOTLzFlfgvkiVxolk1k5bBIpmg=="],
"simple-plist/bplist-parser": ["bplist-parser@0.3.1", "", { "dependencies": { "big-integer": "1.6.x" } }, "sha512-PyJxiNtA5T2PlLIeBot4lbp7rj4OadzjnMZD/G5zuBNt8ei/yCU7+wW0h2bag9vr8c+/WuRWmSxbqAl9hL1rBA=="], "simple-plist/bplist-parser": ["bplist-parser@0.3.1", "", { "dependencies": { "big-integer": "1.6.x" } }, "sha512-PyJxiNtA5T2PlLIeBot4lbp7rj4OadzjnMZD/G5zuBNt8ei/yCU7+wW0h2bag9vr8c+/WuRWmSxbqAl9hL1rBA=="],
"slice-ansi/ansi-styles": ["ansi-styles@6.2.1", "", {}, "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug=="], "slice-ansi/ansi-styles": ["ansi-styles@3.2.1", "", { "dependencies": { "color-convert": "^1.9.0" } }, "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA=="],
"slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@4.0.0", "", {}, "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ=="], "slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@2.0.0", "", {}, "sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w=="],
"source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], "source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
@@ -2687,8 +2631,6 @@
"tailwindcss/arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="], "tailwindcss/arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="],
"tailwindcss/lilconfig": ["lilconfig@2.1.0", "", {}, "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ=="],
"tailwindcss/postcss-nested": ["postcss-nested@6.2.0", "", { "dependencies": { "postcss-selector-parser": "^6.1.1" }, "peerDependencies": { "postcss": "^8.2.14" } }, "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ=="], "tailwindcss/postcss-nested": ["postcss-nested@6.2.0", "", { "dependencies": { "postcss-selector-parser": "^6.1.1" }, "peerDependencies": { "postcss": "^8.2.14" } }, "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ=="],
"tar/fs-minipass": ["fs-minipass@2.1.0", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg=="], "tar/fs-minipass": ["fs-minipass@2.1.0", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg=="],
@@ -2703,8 +2645,6 @@
"tempy/type-fest": ["type-fest@0.16.0", "", {}, "sha512-eaBzG6MxNzEn9kiwvtre90cXaNLkmadMWa1zQMs3XORCXNbsH/OewwbxC5ia9dCxIxnTAsSxXJaa/p5y8DlvJg=="], "tempy/type-fest": ["type-fest@0.16.0", "", {}, "sha512-eaBzG6MxNzEn9kiwvtre90cXaNLkmadMWa1zQMs3XORCXNbsH/OewwbxC5ia9dCxIxnTAsSxXJaa/p5y8DlvJg=="],
"terminal-link/ansi-escapes": ["ansi-escapes@4.3.2", "", { "dependencies": { "type-fest": "^0.21.3" } }, "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ=="],
"terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], "terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],
"test-exclude/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], "test-exclude/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
@@ -2715,11 +2655,7 @@
"whatwg-url/webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], "whatwg-url/webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
"wrap-ansi/ansi-styles": ["ansi-styles@6.2.1", "", {}, "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug=="], "wrap-ansi/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
"wrap-ansi/strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="],
"wrap-ansi-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], "wrap-ansi-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
@@ -2741,8 +2677,6 @@
"@expo/cli/ora/log-symbols": ["log-symbols@2.2.0", "", { "dependencies": { "chalk": "^2.0.1" } }, "sha512-VeIAFslyIerEJLXHziedo2basKbMKtTw3vfn5IzG0XTjhAVEJyNHnL2p7vc+wBDSdQuUpNw3M2u6xb9QsAY5Eg=="], "@expo/cli/ora/log-symbols": ["log-symbols@2.2.0", "", { "dependencies": { "chalk": "^2.0.1" } }, "sha512-VeIAFslyIerEJLXHziedo2basKbMKtTw3vfn5IzG0XTjhAVEJyNHnL2p7vc+wBDSdQuUpNw3M2u6xb9QsAY5Eg=="],
"@expo/cli/wrap-ansi/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"@expo/fingerprint/minimatch/brace-expansion": ["brace-expansion@1.1.11", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA=="], "@expo/fingerprint/minimatch/brace-expansion": ["brace-expansion@1.1.11", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA=="],
"@expo/image-utils/fs-extra/jsonfile": ["jsonfile@6.1.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ=="], "@expo/image-utils/fs-extra/jsonfile": ["jsonfile@6.1.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ=="],
@@ -2801,16 +2735,8 @@
"@react-native/metro-babel-transformer/@react-native/babel-preset/@react-native/babel-plugin-codegen": ["@react-native/babel-plugin-codegen@0.77.0", "", { "dependencies": { "@babel/traverse": "^7.25.3", "@react-native/codegen": "0.77.0" } }, "sha512-5TYPn1k+jdDOZJU4EVb1kZ0p9TCVICXK3uplRev5Gul57oWesAaiWGZOzfRS3lonWeuR4ij8v8PFfIHOaq0vmA=="], "@react-native/metro-babel-transformer/@react-native/babel-preset/@react-native/babel-plugin-codegen": ["@react-native/babel-plugin-codegen@0.77.0", "", { "dependencies": { "@babel/traverse": "^7.25.3", "@react-native/codegen": "0.77.0" } }, "sha512-5TYPn1k+jdDOZJU4EVb1kZ0p9TCVICXK3uplRev5Gul57oWesAaiWGZOzfRS3lonWeuR4ij8v8PFfIHOaq0vmA=="],
"ansi-fragments/slice-ansi/ansi-styles": ["ansi-styles@3.2.1", "", { "dependencies": { "color-convert": "^1.9.0" } }, "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA=="],
"ansi-fragments/slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@2.0.0", "", {}, "sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w=="],
"chromium-edge-launcher/rimraf/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], "chromium-edge-launcher/rimraf/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
"cli-truncate/string-width/emoji-regex": ["emoji-regex@10.4.0", "", {}, "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw=="],
"cli-truncate/string-width/strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="],
"compression/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], "compression/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
"connect/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], "connect/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
@@ -2835,28 +2761,6 @@
"lighthouse-logger/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], "lighthouse-logger/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
"lint-staged/execa/get-stream": ["get-stream@8.0.1", "", {}, "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA=="],
"lint-staged/execa/human-signals": ["human-signals@5.0.0", "", {}, "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ=="],
"lint-staged/execa/is-stream": ["is-stream@3.0.0", "", {}, "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA=="],
"lint-staged/execa/npm-run-path": ["npm-run-path@5.3.0", "", { "dependencies": { "path-key": "^4.0.0" } }, "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ=="],
"lint-staged/execa/onetime": ["onetime@6.0.0", "", { "dependencies": { "mimic-fn": "^4.0.0" } }, "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ=="],
"lint-staged/execa/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
"lint-staged/execa/strip-final-newline": ["strip-final-newline@3.0.0", "", {}, "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw=="],
"log-update/cli-cursor/restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="],
"log-update/slice-ansi/ansi-styles": ["ansi-styles@6.2.1", "", {}, "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug=="],
"log-update/slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@5.0.0", "", { "dependencies": { "get-east-asian-width": "^1.0.0" } }, "sha512-OVa3u9kkBbw7b8Xw5F9P+D/T9X+Z4+JruYVNapTjPYZYUznQ5YfWeFkOj606XYYW8yugTfC8Pj0hYqvi4ryAhA=="],
"log-update/strip-ansi/ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="],
"logkitty/yargs/cliui": ["cliui@6.0.0", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^6.2.0" } }, "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ=="], "logkitty/yargs/cliui": ["cliui@6.0.0", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^6.2.0" } }, "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ=="],
"logkitty/yargs/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="], "logkitty/yargs/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
@@ -2883,8 +2787,6 @@
"parse-bmfont-xml/xml2js/xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="], "parse-bmfont-xml/xml2js/xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="],
"password-prompt/ansi-escapes/type-fest": ["type-fest@0.21.3", "", {}, "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w=="],
"patch-package/fs-extra/jsonfile": ["jsonfile@6.1.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ=="], "patch-package/fs-extra/jsonfile": ["jsonfile@6.1.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ=="],
"patch-package/fs-extra/universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], "patch-package/fs-extra/universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],
@@ -2901,18 +2803,20 @@
"send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], "send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
"serve-static/send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
"serve-static/send/encodeurl": ["encodeurl@1.0.2", "", {}, "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w=="],
"serve-static/send/mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="],
"slice-ansi/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="],
"tar/fs-minipass/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], "tar/fs-minipass/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
"temp/rimraf/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], "temp/rimraf/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
"terminal-link/ansi-escapes/type-fest": ["type-fest@0.21.3", "", {}, "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w=="],
"test-exclude/minimatch/brace-expansion": ["brace-expansion@1.1.11", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA=="], "test-exclude/minimatch/brace-expansion": ["brace-expansion@1.1.11", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA=="],
"wrap-ansi/string-width/emoji-regex": ["emoji-regex@10.4.0", "", {}, "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw=="],
"wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="],
"@babel/highlight/chalk/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="], "@babel/highlight/chalk/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="],
"@babel/highlight/chalk/supports-color/has-flag": ["has-flag@3.0.0", "", {}, "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw=="], "@babel/highlight/chalk/supports-color/has-flag": ["has-flag@3.0.0", "", {}, "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw=="],
@@ -2947,12 +2851,8 @@
"@react-native/codegen/glob/minimatch/brace-expansion": ["brace-expansion@1.1.11", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA=="], "@react-native/codegen/glob/minimatch/brace-expansion": ["brace-expansion@1.1.11", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA=="],
"ansi-fragments/slice-ansi/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="],
"chromium-edge-launcher/rimraf/glob/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], "chromium-edge-launcher/rimraf/glob/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
"cli-truncate/string-width/strip-ansi/ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="],
"default-gateway/execa/cross-spawn/path-key": ["path-key@2.0.1", "", {}, "sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw=="], "default-gateway/execa/cross-spawn/path-key": ["path-key@2.0.1", "", {}, "sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw=="],
"default-gateway/execa/cross-spawn/semver": ["semver@5.7.2", "", { "bin": { "semver": "bin/semver" } }, "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g=="], "default-gateway/execa/cross-spawn/semver": ["semver@5.7.2", "", { "bin": { "semver": "bin/semver" } }, "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g=="],
@@ -2965,14 +2865,6 @@
"del/rimraf/glob/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], "del/rimraf/glob/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
"lint-staged/execa/npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="],
"lint-staged/execa/onetime/mimic-fn": ["mimic-fn@4.0.0", "", {}, "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw=="],
"log-update/cli-cursor/restore-cursor/onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="],
"log-update/cli-cursor/restore-cursor/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
"logkitty/yargs/cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], "logkitty/yargs/cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"logkitty/yargs/cliui/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], "logkitty/yargs/cliui/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],
@@ -2995,6 +2887,10 @@
"rimraf/glob/minimatch/brace-expansion": ["brace-expansion@1.1.11", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA=="], "rimraf/glob/minimatch/brace-expansion": ["brace-expansion@1.1.11", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA=="],
"serve-static/send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
"slice-ansi/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="],
"temp/rimraf/glob/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], "temp/rimraf/glob/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
"@babel/highlight/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="], "@babel/highlight/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="],
@@ -3019,8 +2915,6 @@
"@react-native/babel-plugin-codegen/@react-native/codegen/jscodeshift/recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], "@react-native/babel-plugin-codegen/@react-native/codegen/jscodeshift/recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
"ansi-fragments/slice-ansi/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="],
"chromium-edge-launcher/rimraf/glob/minimatch/brace-expansion": ["brace-expansion@1.1.11", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA=="], "chromium-edge-launcher/rimraf/glob/minimatch/brace-expansion": ["brace-expansion@1.1.11", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA=="],
"default-gateway/execa/cross-spawn/shebang-command/shebang-regex": ["shebang-regex@1.0.0", "", {}, "sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ=="], "default-gateway/execa/cross-spawn/shebang-command/shebang-regex": ["shebang-regex@1.0.0", "", {}, "sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ=="],

View File

@@ -1,23 +1,113 @@
import { RoundButton } from "@/components/RoundButton"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useFavorite } from "@/hooks/useFavorite"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
import type { FC } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { View, type ViewProps } from "react-native"; import { useAtom } from "jotai";
import { useMemo } from "react";
import { TouchableOpacityProps, View, ViewProps } from "react-native";
import { RoundButton } from "./RoundButton";
interface Props extends ViewProps { interface Props extends ViewProps {
item: BaseItemDto; item: BaseItemDto;
type: "item" | "series";
} }
export const AddToFavorites: FC<Props> = ({ item, ...props }) => { export const AddToFavorites: React.FC<Props> = ({ item, type, ...props }) => {
const { isFavorite, toggleFavorite } = useFavorite(item); const queryClient = useQueryClient();
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const isFavorite = useMemo(() => {
return item.UserData?.IsFavorite;
}, [item.UserData?.IsFavorite]);
const updateItemInQueries = (newData: Partial<BaseItemDto>) => {
queryClient.setQueryData<BaseItemDto | undefined>(
[type, item.Id],
(old) => {
if (!old) return old;
return {
...old,
...newData,
UserData: { ...old.UserData, ...newData.UserData },
};
}
);
};
const markFavoriteMutation = useMutation({
mutationFn: async () => {
if (api && user) {
await getUserLibraryApi(api).markFavoriteItem({
userId: user.Id,
itemId: item.Id!,
});
}
},
onMutate: async () => {
await queryClient.cancelQueries({ queryKey: [type, item.Id] });
const previousItem = queryClient.getQueryData<BaseItemDto>([
type,
item.Id,
]);
updateItemInQueries({ UserData: { IsFavorite: true } });
return { previousItem };
},
onError: (err, variables, context) => {
if (context?.previousItem) {
queryClient.setQueryData([type, item.Id], context.previousItem);
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: [type, item.Id] });
queryClient.invalidateQueries({ queryKey: ["home", "favorites"] });
},
});
const unmarkFavoriteMutation = useMutation({
mutationFn: async () => {
if (api && user) {
await getUserLibraryApi(api).unmarkFavoriteItem({
userId: user.Id,
itemId: item.Id!,
});
}
},
onMutate: async () => {
await queryClient.cancelQueries({ queryKey: [type, item.Id] });
const previousItem = queryClient.getQueryData<BaseItemDto>([
type,
item.Id,
]);
updateItemInQueries({ UserData: { IsFavorite: false } });
return { previousItem };
},
onError: (err, variables, context) => {
if (context?.previousItem) {
queryClient.setQueryData([type, item.Id], context.previousItem);
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: [type, item.Id] });
queryClient.invalidateQueries({ queryKey: ["home", "favorites"] });
},
});
return ( return (
<View {...props}> <View {...props}>
<RoundButton <RoundButton
size='large' size="large"
icon={isFavorite ? "heart" : "heart-outline"} icon={isFavorite ? "heart" : "heart-outline"}
fillColor={isFavorite ? "primary" : undefined} fillColor={isFavorite ? "primary" : undefined}
onPress={toggleFavorite} onPress={() => {
if (isFavorite) {
unmarkFavoriteMutation.mutate();
} else {
markFavoriteMutation.mutate();
}
}}
/> />
</View> </View>
); );

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,7 +2,7 @@ import { useRemuxHlsToMp4 } from "@/hooks/useRemuxHlsToMp4";
import { useDownload } from "@/providers/DownloadProvider"; import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { queueActions, queueAtom } from "@/utils/atoms/queue"; import { queueActions, queueAtom } from "@/utils/atoms/queue";
import { DownloadMethod, useSettings } from "@/utils/atoms/settings"; import {DownloadMethod, useSettings} from "@/utils/atoms/settings";
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings"; import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl"; import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { saveDownloadItemInfoToDiskTmp } from "@/utils/optimize-server"; import { saveDownloadItemInfoToDiskTmp } from "@/utils/optimize-server";
@@ -10,30 +10,29 @@ import download from "@/utils/profiles/download";
import Ionicons from "@expo/vector-icons/Ionicons"; import Ionicons from "@expo/vector-icons/Ionicons";
import { import {
BottomSheetBackdrop, BottomSheetBackdrop,
type BottomSheetBackdropProps, BottomSheetBackdropProps,
BottomSheetModal, BottomSheetModal,
BottomSheetView, BottomSheetView,
} from "@gorhom/bottom-sheet"; } from "@gorhom/bottom-sheet";
import type { import {
BaseItemDto, BaseItemDto,
MediaSourceInfo, MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models"; } from "@jellyfin/sdk/lib/generated-client/models";
import { type Href, router, useFocusEffect } from "expo-router"; import { Href, router, useFocusEffect } from "expo-router";
import { t } from "i18next";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import type React from "react"; import React, { useCallback, useMemo, useRef, useState } from "react";
import { useCallback, useMemo, useRef, useState } from "react"; import { Alert, View, ViewProps } from "react-native";
import { Alert, Platform, View, type ViewProps } from "react-native";
import { toast } from "sonner-native"; import { toast } from "sonner-native";
import { AudioTrackSelector } from "./AudioTrackSelector"; import { AudioTrackSelector } from "./AudioTrackSelector";
import { type Bitrate, BitrateSelector } from "./BitrateSelector"; import { Bitrate, BitrateSelector } from "./BitrateSelector";
import { Button } from "./Button"; import { Button } from "./Button";
import { Text } from "./common/Text";
import { Loader } from "./Loader"; import { Loader } from "./Loader";
import { MediaSourceSelector } from "./MediaSourceSelector"; import { MediaSourceSelector } from "./MediaSourceSelector";
import ProgressCircle from "./ProgressCircle"; import ProgressCircle from "./ProgressCircle";
import { RoundButton } from "./RoundButton"; import { RoundButton } from "./RoundButton";
import { SubtitleTrackSelector } from "./SubtitleTrackSelector"; import { SubtitleTrackSelector } from "./SubtitleTrackSelector";
import { Text } from "./common/Text"; import { t } from "i18next";
interface DownloadProps extends ViewProps { interface DownloadProps extends ViewProps {
items: BaseItemDto[]; items: BaseItemDto[];
@@ -67,20 +66,18 @@ export const DownloadItems: React.FC<DownloadProps> = ({
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1); const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
const [selectedSubtitleStream, setSelectedSubtitleStream] = const [selectedSubtitleStream, setSelectedSubtitleStream] =
useState<number>(0); useState<number>(0);
const [maxBitrate, setMaxBitrate] = useState<Bitrate>( const [maxBitrate, setMaxBitrate] = useState<Bitrate>(settings?.defaultBitrate ?? {
settings?.defaultBitrate ?? { key: "Max",
key: "Max", value: undefined,
value: undefined, });
},
);
const userCanDownload = useMemo( const userCanDownload = useMemo(
() => user?.Policy?.EnableContentDownloading, () => user?.Policy?.EnableContentDownloading,
[user], [user]
); );
const usingOptimizedServer = useMemo( const usingOptimizedServer = useMemo(
() => settings?.downloadMethod === DownloadMethod.Optimized, () => settings?.downloadMethod === DownloadMethod.Optimized,
[settings], [settings]
); );
const bottomSheetModalRef = useRef<BottomSheetModal>(null); const bottomSheetModalRef = useRef<BottomSheetModal>(null);
@@ -100,7 +97,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
const itemsNotDownloaded = useMemo( const itemsNotDownloaded = useMemo(
() => () =>
items.filter((i) => !downloadedFiles?.some((f) => f.item.Id === i.Id)), items.filter((i) => !downloadedFiles?.some((f) => f.item.Id === i.Id)),
[items, downloadedFiles], [items, downloadedFiles]
); );
const allItemsDownloaded = useMemo(() => { const allItemsDownloaded = useMemo(() => {
@@ -109,7 +106,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
}, [items, itemsNotDownloaded]); }, [items, itemsNotDownloaded]);
const itemsProcesses = useMemo( const itemsProcesses = useMemo(
() => processes?.filter((p) => itemIds.includes(p.item.Id)), () => processes?.filter((p) => itemIds.includes(p.item.Id)),
[processes, itemIds], [processes, itemIds]
); );
const progress = useMemo(() => { const progress = useMemo(() => {
@@ -141,7 +138,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
params: { params: {
episodeSeasonIndex: firstItem.ParentIndexNumber, episodeSeasonIndex: firstItem.ParentIndexNumber,
}, },
} as Href), } as Href)
); );
}; };
@@ -161,13 +158,11 @@ export const DownloadItems: React.FC<DownloadProps> = ({
id: item.Id!, id: item.Id!,
execute: async () => await initiateDownload(item), execute: async () => await initiateDownload(item),
item, item,
})), }))
); );
} }
} else { } else {
toast.error( toast.error(t("home.downloads.toasts.you_are_not_allowed_to_download_files"));
t("home.downloads.toasts.you_are_not_allowed_to_download_files"),
);
} }
}, [ }, [
queue, queue,
@@ -190,7 +185,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
(itemsNotDownloaded.length === 1 && !selectedMediaSource?.Id) (itemsNotDownloaded.length === 1 && !selectedMediaSource?.Id)
) { ) {
throw new Error( throw new Error(
"DownloadItem ~ initiateDownload: No api or user or item", "DownloadItem ~ initiateDownload: No api or user or item"
); );
} }
let mediaSource = selectedMediaSource; let mediaSource = selectedMediaSource;
@@ -221,7 +216,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
if (!res) { if (!res) {
Alert.alert( Alert.alert(
t("home.downloads.something_went_wrong"), t("home.downloads.something_went_wrong"),
t("home.downloads.could_not_get_stream_url_from_jellyfin"), t("home.downloads.could_not_get_stream_url_from_jellyfin")
); );
continue; continue;
} }
@@ -251,7 +246,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
usingOptimizedServer, usingOptimizedServer,
startBackgroundDownload, startBackgroundDownload,
startRemuxing, startRemuxing,
], ]
); );
const renderBackdrop = useCallback( const renderBackdrop = useCallback(
@@ -262,7 +257,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
appearsOnIndex={0} appearsOnIndex={0}
/> />
), ),
[], []
); );
useFocusEffect( useFocusEffect(
useCallback(() => { useCallback(() => {
@@ -275,7 +270,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
setSelectedAudioStream(audioIndex ?? 0); setSelectedAudioStream(audioIndex ?? 0);
setSelectedSubtitleStream(subtitleIndex ?? -1); setSelectedSubtitleStream(subtitleIndex ?? -1);
setMaxBitrate(bitrate); setMaxBitrate(bitrate);
}, [items, itemsNotDownloaded, settings]), }, [items, itemsNotDownloaded, settings])
); );
const renderButtonContent = () => { const renderButtonContent = () => {
@@ -283,18 +278,18 @@ export const DownloadItems: React.FC<DownloadProps> = ({
return progress === 0 ? ( return progress === 0 ? (
<Loader /> <Loader />
) : ( ) : (
<View className='-rotate-45'> <View className="-rotate-45">
<ProgressCircle <ProgressCircle
size={24} size={24}
fill={progress} fill={progress}
width={4} width={4}
tintColor='#9334E9' tintColor="#9334E9"
backgroundColor='#bdc3c7' backgroundColor="#bdc3c7"
/> />
</View> </View>
); );
} else if (itemsQueued) { } else if (itemsQueued) {
return <Ionicons name='hourglass' size={24} color='white' />; return <Ionicons name="hourglass" size={24} color="white" />;
} else if (allItemsDownloaded) { } else if (allItemsDownloaded) {
return <DownloadedIconComponent />; return <DownloadedIconComponent />;
} else { } else {
@@ -332,19 +327,16 @@ export const DownloadItems: React.FC<DownloadProps> = ({
backdropComponent={renderBackdrop} backdropComponent={renderBackdrop}
> >
<BottomSheetView> <BottomSheetView>
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'> <View className="flex flex-col space-y-4 px-4 pb-8 pt-2">
<View> <View>
<Text className='font-bold text-2xl text-neutral-100'> <Text className="font-bold text-2xl text-neutral-100">
{title} {title}
</Text> </Text>
<Text className='text-neutral-300'> <Text className="text-neutral-300">
{subtitle || {subtitle || t("item_card.download.download_x_item", {item_count: itemsNotDownloaded.length})}
t("item_card.download.download_x_item", {
item_count: itemsNotDownloaded.length,
})}
</Text> </Text>
</View> </View>
<View className='flex flex-col space-y-2 w-full items-start'> <View className="flex flex-col space-y-2 w-full items-start">
<BitrateSelector <BitrateSelector
inverted inverted
onChange={setMaxBitrate} onChange={setMaxBitrate}
@@ -358,7 +350,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
selected={selectedMediaSource} selected={selectedMediaSource}
/> />
{selectedMediaSource && ( {selectedMediaSource && (
<View className='flex flex-col space-y-2'> <View className="flex flex-col space-y-2">
<AudioTrackSelector <AudioTrackSelector
source={selectedMediaSource} source={selectedMediaSource}
onChange={setSelectedAudioStream} onChange={setSelectedAudioStream}
@@ -375,14 +367,14 @@ export const DownloadItems: React.FC<DownloadProps> = ({
)} )}
</View> </View>
<Button <Button
className='mt-auto' className="mt-auto"
onPress={acceptDownloadOptions} onPress={acceptDownloadOptions}
color='purple' color="purple"
> >
{t("item_card.download.download_button")} {t("item_card.download.download_button")}
</Button> </Button>
<View className='opacity-70 text-center w-full flex items-center'> <View className="opacity-70 text-center w-full flex items-center">
<Text className='text-xs'> <Text className="text-xs">
{usingOptimizedServer {usingOptimizedServer
? t("item_card.download.using_optimized_server") ? t("item_card.download.using_optimized_server")
: t("item_card.download.using_default_method")} : t("item_card.download.using_default_method")}
@@ -399,23 +391,19 @@ export const DownloadSingleItem: React.FC<{
size?: "default" | "large"; size?: "default" | "large";
item: BaseItemDto; item: BaseItemDto;
}> = ({ item, size = "default" }) => { }> = ({ item, size = "default" }) => {
if (Platform.isTV) return;
return ( return (
<DownloadItems <DownloadItems
size={size} size={size}
title={ title={item.Type == "Episode"
item.Type == "Episode" ? t("item_card.download.download_episode")
? t("item_card.download.download_episode") : t("item_card.download.download_movie")}
: t("item_card.download.download_movie")
}
subtitle={item.Name!} subtitle={item.Name!}
items={[item]} items={[item]}
MissingDownloadIconComponent={() => ( MissingDownloadIconComponent={() => (
<Ionicons name='cloud-download-outline' size={24} color='white' /> <Ionicons name="cloud-download-outline" size={24} color="white" />
)} )}
DownloadedIconComponent={() => ( DownloadedIconComponent={() => (
<Ionicons name='cloud-download' size={26} color='#9333ea' /> <Ionicons name="cloud-download" size={26} color="#9333ea" />
)} )}
/> />
); );

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
import { AudioTrackSelector } from "@/components/AudioTrackSelector"; import { AudioTrackSelector } from "@/components/AudioTrackSelector";
import { type Bitrate, BitrateSelector } from "@/components/BitrateSelector"; import { Bitrate, BitrateSelector } from "@/components/BitrateSelector";
import { DownloadSingleItem } from "@/components/DownloadItem"; import { DownloadSingleItem } from "@/components/DownloadItem";
import { OverviewText } from "@/components/OverviewText"; import { OverviewText } from "@/components/OverviewText";
import { ParallaxScrollView } from "@/components/ParallaxPage"; import { ParallaxScrollView } from "@/components/ParallaxPage";
@@ -15,26 +15,26 @@ import { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarous
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings"; import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
import { useImageColors } from "@/hooks/useImageColors"; import { useImageColors } from "@/hooks/useImageColors";
import { useOrientation } from "@/hooks/useOrientation"; import { useOrientation } from "@/hooks/useOrientation";
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { apiAtom } from "@/providers/JellyfinProvider"; import { apiAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById"; import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
import type { import {
BaseItemDto, BaseItemDto,
MediaSourceInfo, MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models"; } from "@jellyfin/sdk/lib/generated-client/models";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { useNavigation } from "expo-router"; import { useNavigation } from "expo-router";
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import React, { useEffect, useMemo, useState } from "react"; import React, { useEffect, useMemo, useState } from "react";
import { Platform, View } from "react-native"; import { Platform, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { AddToFavorites } from "./AddToFavorites"; const Chromecast = !Platform.isTV ? require("./Chromecast") : null;
import { ItemHeader } from "./ItemHeader"; import { ItemHeader } from "./ItemHeader";
import { ItemTechnicalDetails } from "./ItemTechnicalDetails"; import { ItemTechnicalDetails } from "./ItemTechnicalDetails";
import { MediaSourceSelector } from "./MediaSourceSelector"; import { MediaSourceSelector } from "./MediaSourceSelector";
import { MoreMoviesWithActor } from "./MoreMoviesWithActor"; import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
const Chromecast = !Platform.isTV ? require("./Chromecast") : null; import { AddToFavorites } from "./AddToFavorites";
export type SelectedOptions = { export type SelectedOptions = {
bitrate: Bitrate; bitrate: Bitrate;
@@ -86,19 +86,17 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
navigation.setOptions({ navigation.setOptions({
headerRight: () => headerRight: () =>
item && ( item && (
<View className='flex flex-row items-center space-x-2'> <View className="flex flex-row items-center space-x-2">
<Chromecast.Chromecast <Chromecast.Chromecast
background='blur' background="blur"
width={22} width={22}
height={22} height={22}
/> />
{item.Type !== "Program" && ( {item.Type !== "Program" && (
<View className='flex flex-row items-center space-x-2'> <View className="flex flex-row items-center space-x-2">
{!Platform.isTV && ( <DownloadSingleItem item={item} size="large" />
<DownloadSingleItem item={item} size='large' /> <PlayedStatus items={[item]} size="large" />
)} <AddToFavorites item={item} type="item" />
<PlayedStatus items={[item]} size='large' />
<AddToFavorites item={item} />
</View> </View>
)} )}
</View> </View>
@@ -123,7 +121,7 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
return ( return (
<View <View
className='flex-1 relative' className="flex-1 relative"
style={{ style={{
paddingLeft: insets.left, paddingLeft: insets.left,
paddingRight: insets.right, paddingRight: insets.right,
@@ -165,22 +163,23 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
</> </>
} }
> >
<View className='flex flex-col bg-transparent shrink'> <View className="flex flex-col bg-transparent shrink">
<View className='flex flex-col px-4 w-full space-y-2 pt-2 mb-2 shrink'> {/* {!Platform.isTV && ( */}
<ItemHeader item={item} className='mb-4' /> <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 && ( {item.Type !== "Program" && !Platform.isTV && (
<View className='flex flex-row items-center justify-start w-full h-16'> <View className="flex flex-row items-center justify-start w-full h-16">
<BitrateSelector <BitrateSelector
className='mr-1' className="mr-1"
onChange={(val) => onChange={(val) =>
setSelectedOptions( setSelectedOptions(
(prev) => prev && { ...prev, bitrate: val }, (prev) => prev && { ...prev, bitrate: val }
) )
} }
selected={selectedOptions.bitrate} selected={selectedOptions.bitrate}
/> />
<MediaSourceSelector <MediaSourceSelector
className='mr-1' className="mr-1"
item={item} item={item}
onChange={(val) => onChange={(val) =>
setSelectedOptions( setSelectedOptions(
@@ -188,13 +187,13 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
prev && { prev && {
...prev, ...prev,
mediaSource: val, mediaSource: val,
}, }
) )
} }
selected={selectedOptions.mediaSource} selected={selectedOptions.mediaSource}
/> />
<AudioTrackSelector <AudioTrackSelector
className='mr-1' className="mr-1"
source={selectedOptions.mediaSource} source={selectedOptions.mediaSource}
onChange={(val) => { onChange={(val) => {
setSelectedOptions( setSelectedOptions(
@@ -202,7 +201,7 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
prev && { prev && {
...prev, ...prev,
audioIndex: val, audioIndex: val,
}, }
); );
}} }}
selected={selectedOptions.audioIndex} selected={selectedOptions.audioIndex}
@@ -215,7 +214,7 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
prev && { prev && {
...prev, ...prev,
subtitleIndex: val, subtitleIndex: val,
}, }
) )
} }
selected={selectedOptions.subtitleIndex} selected={selectedOptions.subtitleIndex}
@@ -223,11 +222,13 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
</View> </View>
)} )}
{/* {!Platform.isTV && ( */}
<PlayButton <PlayButton
className='grow' className="grow"
selectedOptions={selectedOptions} selectedOptions={selectedOptions}
item={item} item={item}
/> />
{/* )} */}
</View> </View>
{item.Type === "Episode" && ( {item.Type === "Episode" && (
@@ -235,24 +236,24 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
)} )}
<ItemTechnicalDetails source={selectedOptions.mediaSource} /> <ItemTechnicalDetails source={selectedOptions.mediaSource} />
<OverviewText text={item.Overview} className='px-4 mb-4' /> <OverviewText text={item.Overview} className="px-4 mb-4" />
{item.Type !== "Program" && ( {item.Type !== "Program" && (
<> <>
{item.Type === "Episode" && ( {item.Type === "Episode" && (
<CurrentSeries item={item} className='mb-4' /> <CurrentSeries item={item} className="mb-4" />
)} )}
<CastAndCrew item={item} className='mb-4' loading={loading} /> <CastAndCrew item={item} className="mb-4" loading={loading} />
{item.People && item.People.length > 0 && ( {item.People && item.People.length > 0 && (
<View className='mb-4'> <View className="mb-4">
{item.People.slice(0, 3).map((person, idx) => ( {item.People.slice(0, 3).map((person, idx) => (
<MoreMoviesWithActor <MoreMoviesWithActor
currentItem={item} currentItem={item}
key={idx} key={idx}
actorId={person.Id!} actorId={person.Id!}
className='mb-4' className="mb-4"
/> />
))} ))}
</View> </View>
@@ -265,5 +266,5 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
</ParallaxScrollView> </ParallaxScrollView>
</View> </View>
); );
}, }
); );

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,22 +1,18 @@
import { useHaptic } from "@/hooks/useHaptic"; import { Platform } from "react-native";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor"; import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl"; import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl"; import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { chromecast } from "@/utils/profiles/chromecast";
import { chromecasth265 } from "@/utils/profiles/chromecasth265";
import ios from "@/utils/profiles/ios"; import ios from "@/utils/profiles/ios";
import { runtimeTicksToMinutes } from "@/utils/time"; import { runtimeTicksToMinutes } from "@/utils/time";
import { useActionSheet } from "@expo/react-native-action-sheet"; import { useActionSheet } from "@expo/react-native-action-sheet";
import { Feather, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons"; import { Feather, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { useRouter } from "expo-router"; import { useRouter } from "expo-router";
import { useAtom, useAtomValue } from "jotai"; import { useAtom, useAtomValue } from "jotai";
import { useCallback, useEffect } from "react"; import { useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Platform, Pressable } from "react-native";
import { Alert, TouchableOpacity, View } from "react-native"; import { Alert, TouchableOpacity, View } from "react-native";
import CastContext, { import CastContext, {
CastButton, CastButton,
@@ -34,8 +30,13 @@ import Animated, {
useSharedValue, useSharedValue,
withTiming, withTiming,
} from "react-native-reanimated"; } from "react-native-reanimated";
import type { Button } from "./Button"; import { Button } from "./Button";
import type { SelectedOptions } from "./ItemContent"; import { SelectedOptions } from "./ItemContent";
const chromecastProfile = !Platform.isTV
? require("@/utils/profiles/chromecast")
: null;
import { useTranslation } from "react-i18next";
import { useHaptic } from "@/hooks/useHaptic";
interface Props extends React.ComponentProps<typeof Button> { interface Props extends React.ComponentProps<typeof Button> {
item: BaseItemDto; item: BaseItemDto;
@@ -71,14 +72,13 @@ export const PlayButton: React.FC<Props> = ({
const lightHapticFeedback = useHaptic("light"); const lightHapticFeedback = useHaptic("light");
const goToPlayer = useCallback( const goToPlayer = useCallback(
(q: string) => { (q: string, bitrateValue: number | undefined) => {
router.push(`/player/direct-player?${q}`); router.push(`/player/direct-player?${q}`);
}, },
[router], [router]
); );
const onPress = useCallback(async () => { const onPress = useCallback(async () => {
console.log("onPress");
if (!item) return; if (!item) return;
lightHapticFeedback(); lightHapticFeedback();
@@ -94,7 +94,7 @@ export const PlayButton: React.FC<Props> = ({
const queryString = queryParams.toString(); const queryString = queryParams.toString();
if (!client) { if (!client) {
goToPlayer(queryString); goToPlayer(queryString, selectedOptions.bitrate?.value);
return; return;
} }
@@ -113,19 +113,16 @@ export const PlayButton: React.FC<Props> = ({
switch (selectedIndex) { switch (selectedIndex) {
case 0: case 0:
await CastContext.getPlayServicesState().then(async (state) => { if (!Platform.isTV) {
if (state && state !== PlayServicesState.SUCCESS) { await CastContext.getPlayServicesState().then(async (state) => {
CastContext.showPlayServicesErrorDialog(state); if (state && state !== PlayServicesState.SUCCESS)
} else { CastContext.showPlayServicesErrorDialog(state);
// Check if user wants H265 for Chromecast else {
const enableH265 = settings.enableH265ForChromecast; // Get a new URL with the Chromecast device profile:
// Get a new URL with the Chromecast device profile
try {
const data = await getStreamUrl({ const data = await getStreamUrl({
api, api,
item, item,
deviceProfile: enableH265 ? chromecasth265 : chromecast, deviceProfile: chromecastProfile,
startTimeTicks: item?.UserData?.PlaybackPositionTicks!, startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
userId: user?.Id, userId: user?.Id,
audioStreamIndex: selectedOptions.audioIndex, audioStreamIndex: selectedOptions.audioIndex,
@@ -134,13 +131,11 @@ export const PlayButton: React.FC<Props> = ({
subtitleStreamIndex: selectedOptions.subtitleIndex, subtitleStreamIndex: selectedOptions.subtitleIndex,
}); });
console.log("URL: ", data?.url, enableH265);
if (!data?.url) { if (!data?.url) {
console.warn("No URL returned from getStreamUrl", data); console.warn("No URL returned from getStreamUrl", data);
Alert.alert( Alert.alert(
t("player.client_error"), t("player.client_error"),
t("player.could_not_create_stream_for_chromecast"), t("player.could_not_create_stream_for_chromecast")
); );
return; return;
} }
@@ -170,36 +165,36 @@ export const PlayButton: React.FC<Props> = ({
], ],
} }
: item.Type === "Movie" : item.Type === "Movie"
? { ? {
type: "movie", type: "movie",
title: item.Name || "", title: item.Name || "",
subtitle: item.Overview || "", subtitle: item.Overview || "",
images: [ images: [
{ {
url: getPrimaryImageUrl({ url: getPrimaryImageUrl({
api, api,
item, item,
quality: 90, quality: 90,
width: 2000, width: 2000,
})!, })!,
}, },
], ],
} }
: { : {
type: "generic", type: "generic",
title: item.Name || "", title: item.Name || "",
subtitle: item.Overview || "", subtitle: item.Overview || "",
images: [ images: [
{ {
url: getPrimaryImageUrl({ url: getPrimaryImageUrl({
api, api,
item, item,
quality: 90, quality: 90,
width: 2000, width: 2000,
})!, })!,
}, },
], ],
}, },
}, },
startTime: 0, startTime: 0,
}) })
@@ -210,19 +205,17 @@ export const PlayButton: React.FC<Props> = ({
} }
CastContext.showExpandedControls(); CastContext.showExpandedControls();
}); });
} catch (e) {
console.log(e);
} }
} });
}); }
break; break;
case 1: case 1:
goToPlayer(queryString); goToPlayer(queryString, selectedOptions.bitrate?.value);
break; break;
case cancelButtonIndex: case cancelButtonIndex:
break; break;
} }
}, }
); );
}, [ }, [
item, item,
@@ -243,7 +236,7 @@ export const PlayButton: React.FC<Props> = ({
return userData.PlaybackPositionTicks > 0 return userData.PlaybackPositionTicks > 0
? Math.max( ? Math.max(
(userData.PlaybackPositionTicks / item.RunTimeTicks) * 100, (userData.PlaybackPositionTicks / item.RunTimeTicks) * 100,
MIN_PLAYBACK_WIDTH, MIN_PLAYBACK_WIDTH
) )
: 0; : 0;
} }
@@ -260,7 +253,7 @@ export const PlayButton: React.FC<Props> = ({
easing: Easing.bezier(0.7, 0, 0.3, 1.0), easing: Easing.bezier(0.7, 0, 0.3, 1.0),
}); });
}, },
[item], [item]
); );
useAnimatedReaction( useAnimatedReaction(
@@ -273,7 +266,7 @@ export const PlayButton: React.FC<Props> = ({
easing: Easing.bezier(0.9, 0, 0.31, 0.99), easing: Easing.bezier(0.9, 0, 0.31, 0.99),
}); });
}, },
[colorAtom], [colorAtom]
); );
useEffect(() => { useEffect(() => {
@@ -294,7 +287,7 @@ export const PlayButton: React.FC<Props> = ({
backgroundColor: interpolateColor( backgroundColor: interpolateColor(
colorChangeProgress.value, colorChangeProgress.value,
[0, 1], [0, 1],
[startColor.value.primary, endColor.value.primary], [startColor.value.primary, endColor.value.primary]
), ),
})); }));
@@ -302,7 +295,7 @@ export const PlayButton: React.FC<Props> = ({
backgroundColor: interpolateColor( backgroundColor: interpolateColor(
colorChangeProgress.value, colorChangeProgress.value,
[0, 1], [0, 1],
[startColor.value.primary, endColor.value.primary], [startColor.value.primary, endColor.value.primary]
), ),
})); }));
@@ -310,7 +303,7 @@ export const PlayButton: React.FC<Props> = ({
width: `${interpolate( width: `${interpolate(
widthProgress.value, widthProgress.value,
[0, 1], [0, 1],
[startWidth.value, targetWidth.value], [startWidth.value, targetWidth.value]
)}%`, )}%`,
})); }));
@@ -318,7 +311,7 @@ export const PlayButton: React.FC<Props> = ({
color: interpolateColor( color: interpolateColor(
colorChangeProgress.value, colorChangeProgress.value,
[0, 1], [0, 1],
[startColor.value.text, endColor.value.text], [startColor.value.text, endColor.value.text]
), ),
})); }));
/** /**
@@ -326,62 +319,75 @@ export const PlayButton: React.FC<Props> = ({
*/ */
return ( return (
<TouchableOpacity <View>
disabled={!item} <TouchableOpacity
accessibilityLabel='Play button' disabled={!item}
accessibilityHint='Tap to play the media' accessibilityLabel="Play button"
onPress={onPress} accessibilityHint="Tap to play the media"
className={`relative`} onPress={onPress}
{...props} className={`relative`}
> {...props}
<View className='absolute w-full h-full top-0 left-0 rounded-xl z-10 overflow-hidden'>
<Animated.View
style={[
animatedPrimaryStyle,
animatedWidthStyle,
{
height: "100%",
},
]}
/>
</View>
<Animated.View
style={[animatedAverageStyle, { opacity: 0.5 }]}
className='absolute w-full h-full top-0 left-0 rounded-xl'
/>
<View
style={{
borderWidth: 1,
borderColor: colorAtom.primary,
borderStyle: "solid",
}}
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="absolute w-full h-full top-0 left-0 rounded-xl z-10 overflow-hidden">
<Animated.Text style={[animatedTextStyle, { fontWeight: "bold" }]}> <Animated.View
{runtimeTicksToMinutes(item?.RunTimeTicks)} style={[
</Animated.Text> animatedPrimaryStyle,
<Animated.Text style={animatedTextStyle}> animatedWidthStyle,
<Ionicons name='play-circle' size={24} /> {
</Animated.Text> height: "100%",
{client && ( },
<Animated.Text style={animatedTextStyle}> ]}
<Feather name='cast' size={22} /> />
<CastButton tintColor='transparent' />
</Animated.Text>
)}
{!client && settings?.openInVLC && (
<Animated.Text style={animatedTextStyle}>
<MaterialCommunityIcons
name='vlc'
size={18}
color={animatedTextStyle.color}
/>
</Animated.Text>
)}
</View> </View>
</View>
</TouchableOpacity> <Animated.View
style={[animatedAverageStyle, { opacity: 0.5 }]}
className="absolute w-full h-full top-0 left-0 rounded-xl"
/>
<View
style={{
borderWidth: 1,
borderColor: colorAtom.primary,
borderStyle: "solid",
}}
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">
<Animated.Text style={[animatedTextStyle, { fontWeight: "bold" }]}>
{runtimeTicksToMinutes(item?.RunTimeTicks)}
</Animated.Text>
<Animated.Text style={animatedTextStyle}>
<Ionicons name="play-circle" size={24} />
</Animated.Text>
{client && (
<Animated.Text style={animatedTextStyle}>
<Feather name="cast" size={22} />
<CastButton tintColor="transparent" />
</Animated.Text>
)}
{!client && settings?.openInVLC && (
<Animated.Text style={animatedTextStyle}>
<MaterialCommunityIcons
name="vlc"
size={18}
color={animatedTextStyle.color}
/>
</Animated.Text>
)}
</View>
</View>
</TouchableOpacity>
{/* <View className="mt-2 flex flex-row items-center">
<Ionicons
name="information-circle"
size={12}
className=""
color={"#9BA1A6"}
/>
<Text className="text-neutral-500 ml-1">
{directStream ? "Direct stream" : "Transcoded stream"}
</Text>
</View> */}
</View>
); );
}; };

View File

@@ -1,16 +1,14 @@
import { useHaptic } from "@/hooks/useHaptic"; import { Platform } from "react-native";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor"; import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { runtimeTicksToMinutes } from "@/utils/time"; import { runtimeTicksToMinutes } from "@/utils/time";
import { useActionSheet } from "@expo/react-native-action-sheet"; import { useActionSheet } from "@expo/react-native-action-sheet";
import { Feather, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons"; import { Feather, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { useRouter } from "expo-router"; import { useRouter } from "expo-router";
import { useAtom, useAtomValue } from "jotai"; import { useAtom, useAtomValue } from "jotai";
import { useCallback, useEffect } from "react"; import { useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Platform } from "react-native";
import { Alert, TouchableOpacity, View } from "react-native"; import { Alert, TouchableOpacity, View } from "react-native";
import Animated, { import Animated, {
Easing, Easing,
@@ -22,8 +20,10 @@ import Animated, {
useSharedValue, useSharedValue,
withTiming, withTiming,
} from "react-native-reanimated"; } from "react-native-reanimated";
import type { Button } from "./Button"; import { Button } from "./Button";
import type { SelectedOptions } from "./ItemContent"; import { SelectedOptions } from "./ItemContent";
import { useTranslation } from "react-i18next";
import { useHaptic } from "@/hooks/useHaptic";
interface Props extends React.ComponentProps<typeof Button> { interface Props extends React.ComponentProps<typeof Button> {
item: BaseItemDto; item: BaseItemDto;
@@ -57,14 +57,13 @@ export const PlayButton: React.FC<Props> = ({
const lightHapticFeedback = useHaptic("light"); const lightHapticFeedback = useHaptic("light");
const goToPlayer = useCallback( const goToPlayer = useCallback(
(q: string) => { (q: string, bitrateValue: number | undefined) => {
router.push(`/player/direct-player?${q}`); router.push(`/player/direct-player?${q}`);
}, },
[router], [router]
); );
const onPress = () => { const onPress = useCallback(async () => {
console.log("onpress");
if (!item) return; if (!item) return;
lightHapticFeedback(); lightHapticFeedback();
@@ -78,9 +77,17 @@ export const PlayButton: React.FC<Props> = ({
}); });
const queryString = queryParams.toString(); const queryString = queryParams.toString();
goToPlayer(queryString); goToPlayer(queryString, selectedOptions.bitrate?.value);
return; return;
}; }, [
item,
settings,
api,
user,
router,
showActionSheetWithOptions,
selectedOptions,
]);
const derivedTargetWidth = useDerivedValue(() => { const derivedTargetWidth = useDerivedValue(() => {
if (!item || !item.RunTimeTicks) return 0; if (!item || !item.RunTimeTicks) return 0;
@@ -89,7 +96,7 @@ export const PlayButton: React.FC<Props> = ({
return userData.PlaybackPositionTicks > 0 return userData.PlaybackPositionTicks > 0
? Math.max( ? Math.max(
(userData.PlaybackPositionTicks / item.RunTimeTicks) * 100, (userData.PlaybackPositionTicks / item.RunTimeTicks) * 100,
MIN_PLAYBACK_WIDTH, MIN_PLAYBACK_WIDTH
) )
: 0; : 0;
} }
@@ -106,7 +113,7 @@ export const PlayButton: React.FC<Props> = ({
easing: Easing.bezier(0.7, 0, 0.3, 1.0), easing: Easing.bezier(0.7, 0, 0.3, 1.0),
}); });
}, },
[item], [item]
); );
useAnimatedReaction( useAnimatedReaction(
@@ -119,7 +126,7 @@ export const PlayButton: React.FC<Props> = ({
easing: Easing.bezier(0.9, 0, 0.31, 0.99), easing: Easing.bezier(0.9, 0, 0.31, 0.99),
}); });
}, },
[colorAtom], [colorAtom]
); );
useEffect(() => { useEffect(() => {
@@ -140,7 +147,7 @@ export const PlayButton: React.FC<Props> = ({
backgroundColor: interpolateColor( backgroundColor: interpolateColor(
colorChangeProgress.value, colorChangeProgress.value,
[0, 1], [0, 1],
[startColor.value.primary, endColor.value.primary], [startColor.value.primary, endColor.value.primary]
), ),
})); }));
@@ -148,7 +155,7 @@ export const PlayButton: React.FC<Props> = ({
backgroundColor: interpolateColor( backgroundColor: interpolateColor(
colorChangeProgress.value, colorChangeProgress.value,
[0, 1], [0, 1],
[startColor.value.primary, endColor.value.primary], [startColor.value.primary, endColor.value.primary]
), ),
})); }));
@@ -156,7 +163,7 @@ export const PlayButton: React.FC<Props> = ({
width: `${interpolate( width: `${interpolate(
widthProgress.value, widthProgress.value,
[0, 1], [0, 1],
[startWidth.value, targetWidth.value], [startWidth.value, targetWidth.value]
)}%`, )}%`,
})); }));
@@ -164,7 +171,7 @@ export const PlayButton: React.FC<Props> = ({
color: interpolateColor( color: interpolateColor(
colorChangeProgress.value, colorChangeProgress.value,
[0, 1], [0, 1],
[startColor.value.text, endColor.value.text], [startColor.value.text, endColor.value.text]
), ),
})); }));
/** /**
@@ -172,55 +179,69 @@ export const PlayButton: React.FC<Props> = ({
*/ */
return ( return (
<TouchableOpacity <View>
accessibilityLabel='Play button' <TouchableOpacity
accessibilityHint='Tap to play the media' disabled={!item}
onPress={onPress} accessibilityLabel="Play button"
className={`relative`} accessibilityHint="Tap to play the media"
{...props} onPress={onPress}
> className={`relative`}
<View className='absolute w-full h-full top-0 left-0 rounded-xl z-10 overflow-hidden'> {...props}
<Animated.View
style={[
animatedPrimaryStyle,
animatedWidthStyle,
{
height: "100%",
},
]}
/>
</View>
<Animated.View
style={[animatedAverageStyle, { opacity: 0.5 }]}
className='absolute w-full h-full top-0 left-0 rounded-xl'
/>
<View
style={{
borderWidth: 1,
borderColor: colorAtom.primary,
borderStyle: "solid",
}}
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="absolute w-full h-full top-0 left-0 rounded-xl z-10 overflow-hidden">
<Animated.Text style={[animatedTextStyle, { fontWeight: "bold" }]}> <Animated.View
{runtimeTicksToMinutes(item?.RunTimeTicks)} style={[
</Animated.Text> animatedPrimaryStyle,
<Animated.Text style={animatedTextStyle}> animatedWidthStyle,
<Ionicons name='play-circle' size={24} /> {
</Animated.Text> height: "100%",
{settings?.openInVLC && ( },
<Animated.Text style={animatedTextStyle}> ]}
<MaterialCommunityIcons />
name='vlc'
size={18}
color={animatedTextStyle.color}
/>
</Animated.Text>
)}
</View> </View>
</View>
</TouchableOpacity> <Animated.View
style={[animatedAverageStyle, { opacity: 0.5 }]}
className="absolute w-full h-full top-0 left-0 rounded-xl"
/>
<View
style={{
borderWidth: 1,
borderColor: colorAtom.primary,
borderStyle: "solid",
}}
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">
<Animated.Text style={[animatedTextStyle, { fontWeight: "bold" }]}>
{runtimeTicksToMinutes(item?.RunTimeTicks)}
</Animated.Text>
<Animated.Text style={animatedTextStyle}>
<Ionicons name="play-circle" size={24} />
</Animated.Text>
{settings?.openInVLC && (
<Animated.Text style={animatedTextStyle}>
<MaterialCommunityIcons
name="vlc"
size={18}
color={animatedTextStyle.color}
/>
</Animated.Text>
)}
</View>
</View>
</TouchableOpacity>
{/* <View className="mt-2 flex flex-row items-center">
<Ionicons
name="information-circle"
size={12}
className=""
color={"#9BA1A6"}
/>
<Text className="text-neutral-500 ml-1">
{directStream ? "Direct stream" : "Transcoded stream"}
</Text>
</View> */}
</View>
); );
}; };

View File

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

View File

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

View File

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

View File

@@ -1,18 +1,12 @@
import { useJellyseerr } from "@/hooks/useJellyseerr"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { MediaType } from "@/utils/jellyseerr/server/constants/media"; import { View, ViewProps } from "react-native";
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"; 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";
interface Props extends ViewProps { interface Props extends ViewProps {
item?: BaseItemDto | null; item?: BaseItemDto | null;
@@ -21,21 +15,21 @@ interface Props extends ViewProps {
export const Ratings: React.FC<Props> = ({ item, ...props }) => { export const Ratings: React.FC<Props> = ({ item, ...props }) => {
if (!item) return null; if (!item) return null;
return ( return (
<View className='flex flex-row items-center mt-2 space-x-2' {...props}> <View className="flex flex-row items-center mt-2 space-x-2" {...props}>
{item.OfficialRating && ( {item.OfficialRating && (
<Badge text={item.OfficialRating} variant='gray' /> <Badge text={item.OfficialRating} variant="gray" />
)} )}
{item.CommunityRating && ( {item.CommunityRating && (
<Badge <Badge
text={item.CommunityRating.toFixed(1)} text={item.CommunityRating.toFixed(1)}
variant='gray' variant="gray"
iconLeft={<Ionicons name='star' size={14} color='gold' />} iconLeft={<Ionicons name="star" size={14} color="gold" />}
/> />
)} )}
{item.CriticRating && ( {item.CriticRating && (
<Badge <Badge
text={item.CriticRating} text={item.CriticRating}
variant='gray' variant="gray"
iconLeft={ iconLeft={
<Image <Image
source={ source={
@@ -55,17 +49,14 @@ export const Ratings: React.FC<Props> = ({ item, ...props }) => {
); );
}; };
export const JellyserrRatings: React.FC<{ export const JellyserrRatings: React.FC<{ result: MovieResult | TvResult }> = ({
result: MovieResult | TvResult | TvDetails | MovieDetails; result,
}> = ({ result }) => { }) => {
const { jellyseerrApi, getMediaType } = useJellyseerr(); const { jellyseerrApi } = useJellyseerr();
const mediaType = useMemo(() => getMediaType(result), [result]);
const { data, isLoading } = useQuery({ const { data, isLoading } = useQuery({
queryKey: ["jellyseerr", result.id, mediaType, "ratings"], queryKey: ["jellyseerr", result.id, result.mediaType, "ratings"],
queryFn: async () => { queryFn: async () => {
return mediaType === MediaType.MOVIE return result.mediaType === MediaType.MOVIE
? jellyseerrApi?.movieRatings(result.id) ? jellyseerrApi?.movieRatings(result.id)
: jellyseerrApi?.tvRatings(result.id); : jellyseerrApi?.tvRatings(result.id);
}, },
@@ -79,14 +70,14 @@ export const JellyserrRatings: React.FC<{
!!result.voteCount || !!result.voteCount ||
(data?.criticsRating && !!data?.criticsScore) || (data?.criticsRating && !!data?.criticsScore) ||
(data?.audienceRating && !!data?.audienceScore)) && ( (data?.audienceRating && !!data?.audienceScore)) && (
<View className='flex flex-row flex-wrap space-x-1'> <View className="flex flex-row flex-wrap space-x-1">
{data?.criticsRating && !!data?.criticsScore && ( {data?.criticsRating && !!data?.criticsScore && (
<Badge <Badge
text={`${data.criticsScore}%`} text={`${data.criticsScore}%`}
variant='gray' variant="gray"
iconLeft={ iconLeft={
<Image <Image
className='mr-1' className="mr-1"
source={ source={
data?.criticsRating === "Rotten" data?.criticsRating === "Rotten"
? require("@/utils/jellyseerr/src/assets/rt_rotten.svg") ? require("@/utils/jellyseerr/src/assets/rt_rotten.svg")
@@ -103,10 +94,10 @@ export const JellyserrRatings: React.FC<{
{data?.audienceRating && !!data?.audienceScore && ( {data?.audienceRating && !!data?.audienceScore && (
<Badge <Badge
text={`${data.audienceScore}%`} text={`${data.audienceScore}%`}
variant='gray' variant="gray"
iconLeft={ iconLeft={
<Image <Image
className='mr-1' className="mr-1"
source={ source={
data?.audienceRating === "Spilled" data?.audienceRating === "Spilled"
? require("@/utils/jellyseerr/src/assets/rt_aud_rotten.svg") ? require("@/utils/jellyseerr/src/assets/rt_aud_rotten.svg")
@@ -123,10 +114,10 @@ export const JellyserrRatings: React.FC<{
{!!result.voteCount && ( {!!result.voteCount && (
<Badge <Badge
text={`${Math.round(result.voteAverage * 10)}%`} text={`${Math.round(result.voteAverage * 10)}%`}
variant='gray' variant="gray"
iconLeft={ iconLeft={
<Image <Image
className='mr-1' className="mr-1"
source={require("@/utils/jellyseerr/src/assets/tmdb_logo.svg")} source={require("@/utils/jellyseerr/src/assets/tmdb_logo.svg")}
style={{ style={{
width: 14, width: 14,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,13 +1,13 @@
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null; const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
import { Platform, TouchableOpacity, View, ViewProps } from "react-native";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import DisabledSetting from "@/components/settings/DisabledSetting";
import React, { import React, {
type PropsWithChildren, PropsWithChildren,
type ReactNode, ReactNode,
useEffect, useEffect,
useState, useState,
} from "react"; } from "react";
import { Platform, TouchableOpacity, View, type ViewProps } from "react-native"; import DisabledSetting from "@/components/settings/DisabledSetting";
interface Props<T> { interface Props<T> {
data: T[]; data: T[];
@@ -18,10 +18,10 @@ interface Props<T> {
title: string | ReactNode; title: string | ReactNode;
label: string; label: string;
onSelected: (...item: T[]) => void; onSelected: (...item: T[]) => void;
multiple?: boolean; multi?: boolean;
} }
const Dropdown = <T,>({ const Dropdown = <T extends unknown>({
data, data,
disabled, disabled,
placeholderText, placeholderText,
@@ -30,7 +30,7 @@ const Dropdown = <T,>({
title, title,
label, label,
onSelected, onSelected,
multiple = false, multi = false,
...props ...props
}: PropsWithChildren<Props<T> & ViewProps>) => { }: PropsWithChildren<Props<T> & ViewProps>) => {
if (Platform.isTV) return null; if (Platform.isTV) return null;
@@ -47,10 +47,10 @@ const Dropdown = <T,>({
<DropdownMenu.Root> <DropdownMenu.Root>
<DropdownMenu.Trigger> <DropdownMenu.Trigger>
{typeof title === "string" ? ( {typeof title === "string" ? (
<View className='flex flex-col'> <View className="flex flex-col">
<Text className='opacity-50 mb-1 text-xs'>{title}</Text> <Text className="opacity-50 mb-1 text-xs">{title}</Text>
<TouchableOpacity className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'> <TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between">
<Text style={{}} className='' numberOfLines={1}> <Text style={{}} className="" numberOfLines={1}>
{selected?.length !== undefined {selected?.length !== undefined
? selected.map(titleExtractor).join(",") ? selected.map(titleExtractor).join(",")
: placeholderText} : placeholderText}
@@ -63,8 +63,8 @@ const Dropdown = <T,>({
</DropdownMenu.Trigger> </DropdownMenu.Trigger>
<DropdownMenu.Content <DropdownMenu.Content
loop={false} loop={false}
side='bottom' side="bottom"
align='center' align="center"
alignOffset={0} alignOffset={0}
avoidCollisions={true} avoidCollisions={true}
collisionPadding={0} collisionPadding={0}
@@ -72,7 +72,7 @@ const Dropdown = <T,>({
> >
<DropdownMenu.Label>{label}</DropdownMenu.Label> <DropdownMenu.Label>{label}</DropdownMenu.Label>
{data.map((item, idx) => {data.map((item, idx) =>
multiple ? ( multi ? (
<DropdownMenu.CheckboxItem <DropdownMenu.CheckboxItem
value={ value={
selected?.some((s) => keyExtractor(s) == keyExtractor(item)) selected?.some((s) => keyExtractor(s) == keyExtractor(item))
@@ -80,7 +80,7 @@ const Dropdown = <T,>({
: "off" : "off"
} }
key={keyExtractor(item)} key={keyExtractor(item)}
onValueChange={(next: "on" | "off", previous: "on" | "off") => { onValueChange={(next, previous) =>
setSelected((p) => { setSelected((p) => {
const prev = p || []; const prev = p || [];
if (next == "on") { if (next == "on") {
@@ -88,11 +88,11 @@ const Dropdown = <T,>({
} }
return [ return [
...prev.filter( ...prev.filter(
(p) => keyExtractor(p) !== keyExtractor(item), (p) => keyExtractor(p) !== keyExtractor(item)
), ),
]; ];
}); })
}} }
> >
<DropdownMenu.ItemTitle> <DropdownMenu.ItemTitle>
{titleExtractor(item)} {titleExtractor(item)}
@@ -107,7 +107,7 @@ const Dropdown = <T,>({
{titleExtractor(item)} {titleExtractor(item)}
</DropdownMenu.ItemTitle> </DropdownMenu.ItemTitle>
</DropdownMenu.Item> </DropdownMenu.Item>
), )
)} )}
</DropdownMenu.Content> </DropdownMenu.Content>
</DropdownMenu.Root> </DropdownMenu.Root>

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,11 +1,12 @@
import { useImageColors } from "@/hooks/useImageColors";
import { apiAtom } from "@/providers/JellyfinProvider"; import { apiAtom } from "@/providers/JellyfinProvider";
import { getItemImage } from "@/utils/getItemImage"; import { getItemImage } from "@/utils/getItemImage";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { Image, type ImageProps } from "expo-image"; import { Image, ImageProps, ImageSource } from "expo-image";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { type FC, useMemo } from "react"; import { useMemo } from "react";
import { View, type ViewProps } from "react-native"; import { View } from "react-native";
interface Props extends ImageProps { interface Props extends ImageProps {
item: BaseItemDto; item: BaseItemDto;
@@ -24,7 +25,7 @@ interface Props extends ImageProps {
onError?: () => void; onError?: () => void;
} }
export const ItemImage: FC<Props> = ({ export const ItemImage: React.FC<Props> = ({
item, item,
variant = "Primary", variant = "Primary",
quality = 90, quality = 90,
@@ -52,13 +53,13 @@ export const ItemImage: FC<Props> = ({
if (!source?.uri) if (!source?.uri)
return ( return (
<View <View
{...(props as ViewProps)} {...props}
className='flex flex-col items-center justify-center border border-neutral-800 bg-neutral-900' className="flex flex-col items-center justify-center border border-neutral-800 bg-neutral-900"
> >
<Ionicons <Ionicons
name='image-outline' name="image-outline"
size={24} size={24}
color='white' color="white"
style={{ opacity: 0.4 }} style={{ opacity: 0.4 }}
/> />
</View> </View>

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